Strapi - complete guide to open source headless CMS
What is Strapi?
Strapi is the most popular open-source headless CMS in the world, created in 2015 by Pierre Burgy in France. Since then, the project has gathered over 64,000 stars on GitHub and is used by hundreds of thousands of developers and companies like IBM, NASA, Walmart, and Toyota.
As a headless CMS, Strapi separates the content management layer (backend) from the presentation layer (frontend). This means you can create and manage content through an intuitive admin panel, and then deliver it to any frontend application - React, Vue, Next.js, mobile apps, IoT - via REST or GraphQL API.
Strapi stands out with full control over code and data - you can host it on your own servers or choose the managed Strapi Cloud. It is written in Node.js and TypeScript, making it a natural choice for JavaScript developers.
Why Strapi?
Key advantages of Strapi
- 100% Open Source (MIT License) - Full access to the code, no vendor lock-in
- Visual Content-Type Builder - Create data structures without writing code
- Automatic REST and GraphQL API - API generated automatically from Content Types
- Customizable Admin Panel - Tailor the panel to your content team's needs
- Self-hosted or Cloud - Full control over infrastructure
- Plugin System - Extend functionality without modifying the core
- Multilingual (i18n) - Built-in support for multiple languages
- Role-Based Access Control - Granular permissions for users
Strapi vs other CMS
| Feature | Strapi | Contentful | Sanity | WordPress |
|---|---|---|---|---|
| Type | Headless | Headless | Headless | Traditional |
| Open Source | β Yes (MIT) | β No | β No | β Yes |
| Self-hosting | β Full | β Cloud only | β Cloud only | β Yes |
| Price (Self-hosted) | Free | N/A | N/A | Free |
| API | REST + GraphQL | REST + GraphQL | GROQ + GraphQL | REST |
| Visual Builder | β Yes | β Yes | β Yes | β Yes |
| TypeScript | β Native | SDK | SDK | β PHP |
| Plugins | β Marketplace | β Apps | β Plugins | β Huge |
| Learning Curve | Medium | Low | Medium | Low |
When to choose Strapi?
Strapi is ideal when:
- You need full control over code and data
- You want to avoid monthly CMS fees
- You are building an application with React/Vue/Next.js
- You need custom API endpoints
- Self-hosting matters to you (compliance, GDPR)
- You work with a JavaScript/TypeScript stack
Consider alternatives when:
- You need real-time collaboration like Google Docs β Sanity
- You have enterprise requirements and budget β Contentful
- You need a ready-made frontend β WordPress
Installation and configuration
System requirements
- Node.js: 18.x, 20.x, or 22.x
- npm: 6.x or higher (or yarn/pnpm)
- Database: SQLite (dev), PostgreSQL, MySQL, MariaDB (production)
Creating a new project
# Quickstart with SQLite (development)
npx create-strapi-app@latest my-project --quickstart
# Or with database selection
npx create-strapi-app@latest my-project
# Options during installation:
# - TypeScript or JavaScript
# - SQLite, PostgreSQL, MySQL, MariaDB
# - Sample data (optional)Running the project
cd my-project
# Development (with hot reload)
npm run develop
# or
yarn develop
# Strapi starts on:
# - Admin Panel: http://localhost:1337/admin
# - API: http://localhost:1337/apiStrapi project structure
my-project/
βββ config/
β βββ admin.ts # Admin panel configuration
β βββ api.ts # API settings
β βββ database.ts # Database configuration
β βββ middlewares.ts # Middleware config
β βββ plugins.ts # Plugin configuration
β βββ server.ts # Server settings
βββ src/
β βββ admin/ # Admin panel customization
β βββ api/ # Custom API (controllers, routes, services)
β β βββ article/
β β βββ content-types/
β β β βββ article/
β β β βββ schema.json
β β βββ controllers/
β β βββ routes/
β β βββ services/
β βββ components/ # Reusable component schemas
β βββ extensions/ # Plugin extensions
β βββ plugins/ # Custom plugins
βββ public/
β βββ uploads/ # Uploaded media files
βββ database/
β βββ migrations/ # Database migrations
βββ types/ # TypeScript types (auto-generated)
βββ package.jsonDatabase configuration
// config/database.ts
import path from 'path';
export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
const connections = {
// SQLite for development
sqlite: {
connection: {
filename: path.join(
__dirname,
'..',
'..',
env('DATABASE_FILENAME', '.tmp/data.db')
),
},
useNullAsDefault: true,
},
// PostgreSQL for production
postgres: {
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', ''),
ssl: env.bool('DATABASE_SSL', false) && {
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT', true),
},
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
},
},
// MySQL
mysql: {
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', ''),
},
},
};
return {
connection: {
client,
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};Content Types - data modeling
Creating Content Types through the Admin Panel
Strapi offers a visual Content-Type Builder available in Admin Panel β Content-Type Builder.
Collection types:
- Collection Type - Multiple entries (e.g., Articles, Products, Users)
- Single Type - A single entry (e.g., Homepage, Settings, About)
Available field types:
- Text - Short text, slug, email, password
- Rich Text - WYSIWYG editor with formatting
- Number - Integer, big integer, decimal, float
- Date - Date, time, datetime
- Boolean - True/false
- JSON - Any JSON structure
- Media - Images, files, video
- Relation - Relationships between Content Types
- UID - Unique identifiers (e.g., slug)
- Enumeration - List of predefined values
- Component - Reusable groups of fields
- Dynamic Zone - Flexible sections with different components
Content Type schema (code)
// src/api/article/content-types/article/schema.json
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article",
"description": "Blog articles"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 200
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"content": {
"type": "richtext",
"required": true
},
"excerpt": {
"type": "text",
"maxLength": 300
},
"cover": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": ["images"]
},
"gallery": {
"type": "media",
"multiple": true,
"allowedTypes": ["images", "videos"]
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user",
"inversedBy": "articles"
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category",
"inversedBy": "articles"
},
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "api::tag.tag",
"mappedBy": "articles"
},
"publishedAt": {
"type": "datetime"
},
"featured": {
"type": "boolean",
"default": false
},
"readTime": {
"type": "integer",
"min": 1
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo"
},
"sections": {
"type": "dynamiczone",
"components": [
"sections.hero",
"sections.text-block",
"sections.image-gallery",
"sections.cta"
]
}
}
}Reusable components
// src/components/shared/seo.json
{
"collectionName": "components_shared_seos",
"info": {
"displayName": "SEO",
"icon": "search",
"description": "SEO metadata component"
},
"attributes": {
"metaTitle": {
"type": "string",
"maxLength": 60
},
"metaDescription": {
"type": "text",
"maxLength": 160
},
"metaImage": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"canonicalURL": {
"type": "string"
},
"keywords": {
"type": "string"
}
}
}REST API
Automatically generated endpoints
After creating the "Article" Content Type, Strapi automatically generates a REST API:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/articles | List all articles |
| GET | /api/articles/:id | Single article |
| POST | /api/articles | Create an article |
| PUT | /api/articles/:id | Update an article (full) |
| DELETE | /api/articles/:id | Delete an article |
Fetching data
const response = await fetch('http://localhost:1337/api/articles')
const { data, meta } = await response.json()
// Response structure
{
"data": [
{
"id": 1,
"attributes": {
"title": "Getting Started with Strapi",
"slug": "getting-started-with-strapi",
"content": "...",
"publishedAt": "2025-01-15T10:00:00.000Z",
"createdAt": "2025-01-15T09:00:00.000Z",
"updatedAt": "2025-01-15T10:00:00.000Z"
}
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}Populate - relations and media
// Fetch an article with relations (by default relations are NOT returned!)
const response = await fetch(
'http://localhost:1337/api/articles?populate=*'
)
// Deep populate
const response = await fetch(
'http://localhost:1337/api/articles?populate[author][populate]=avatar&populate=cover&populate=category'
)
// Populate with field selection
const response = await fetch(
'http://localhost:1337/api/articles?populate[author][fields][0]=username&populate[author][fields][1]=email'
)
// Reusable populate helper
const qs = require('qs')
const query = qs.stringify({
populate: {
cover: true,
author: {
fields: ['username', 'email'],
populate: {
avatar: true
}
},
category: {
fields: ['name', 'slug']
},
seo: {
populate: '*'
}
}
}, { encodeValuesOnly: true })
const response = await fetch(`http://localhost:1337/api/articles?${query}`)Filtering
import qs from 'qs'
// Basic filtering
const query = qs.stringify({
filters: {
title: {
$contains: 'Strapi'
}
}
})
// Available operators:
// $eq - equal
// $eqi - equal (case-insensitive)
// $ne - not equal
// $lt - less than
// $lte - less than or equal
// $gt - greater than
// $gte - greater than or equal
// $in - included in array
// $notIn - not included in array
// $contains - contains
// $notContains - does not contain
// $containsi - contains (case-insensitive)
// $notContainsi - does not contain (case-insensitive)
// $null - is null
// $notNull - is not null
// $between - between two values
// $startsWith - starts with
// $startsWithi - starts with (case-insensitive)
// $endsWith - ends with
// $endsWithi - ends with (case-insensitive)
// Complex filtering
const query = qs.stringify({
filters: {
$and: [
{
publishedAt: {
$notNull: true
}
},
{
$or: [
{ featured: { $eq: true } },
{ category: { name: { $eq: 'News' } } }
]
}
]
}
})
// Filtering by relations
const query = qs.stringify({
filters: {
author: {
username: {
$eq: 'john'
}
},
category: {
slug: {
$in: ['technology', 'programming']
}
}
}
})Sorting and pagination
const query = qs.stringify({
// Sorting
sort: ['publishedAt:desc', 'title:asc'],
// Pagination
pagination: {
page: 1,
pageSize: 10
},
// or offset-based
// pagination: {
// start: 0,
// limit: 10
// }
// Field selection
fields: ['title', 'slug', 'publishedAt'],
// Populate relations
populate: ['cover', 'author']
})
const response = await fetch(`http://localhost:1337/api/articles?${query}`)Creating and updating data
// Create an article
const response = await fetch('http://localhost:1337/api/articles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` // JWT token
},
body: JSON.stringify({
data: {
title: 'New Article',
slug: 'new-article',
content: 'Article content here...',
category: 1, // relation ID
tags: [1, 2, 3], // IDs for many-to-many
featured: true,
seo: {
metaTitle: 'New Article | My Blog',
metaDescription: 'Description here...'
}
}
})
})
// Update an article
const response = await fetch('http://localhost:1337/api/articles/1', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
data: {
title: 'Updated Title',
featured: false
}
})
})
// Delete an article
const response = await fetch('http://localhost:1337/api/articles/1', {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})GraphQL API
Installing the GraphQL plugin
npm install @strapi/plugin-graphql
# or
yarn add @strapi/plugin-graphqlGraphQL Playground is available at: http://localhost:1337/graphql
Queries
# Fetch all articles
query GetArticles {
articles {
data {
id
attributes {
title
slug
content
publishedAt
cover {
data {
attributes {
url
alternativeText
}
}
}
author {
data {
attributes {
username
email
}
}
}
category {
data {
attributes {
name
slug
}
}
}
}
}
meta {
pagination {
page
pageSize
pageCount
total
}
}
}
}
# Fetch a single article
query GetArticle($id: ID!) {
article(id: $id) {
data {
id
attributes {
title
content
seo {
metaTitle
metaDescription
}
}
}
}
}
# With filtering
query GetFeaturedArticles {
articles(
filters: { featured: { eq: true }, publishedAt: { notNull: true } }
sort: ["publishedAt:desc"]
pagination: { limit: 5 }
) {
data {
id
attributes {
title
excerpt
cover {
data {
attributes {
url
}
}
}
}
}
}
}Mutations
# Create an article
mutation CreateArticle($data: ArticleInput!) {
createArticle(data: $data) {
data {
id
attributes {
title
slug
}
}
}
}
# Variables:
{
"data": {
"title": "New Article",
"slug": "new-article",
"content": "Content here...",
"category": 1
}
}
# Update an article
mutation UpdateArticle($id: ID!, $data: ArticleInput!) {
updateArticle(id: $id, data: $data) {
data {
id
attributes {
title
updatedAt
}
}
}
}
# Delete an article
mutation DeleteArticle($id: ID!) {
deleteArticle(id: $id) {
data {
id
}
}
}GraphQL client in Next.js
// lib/strapi-graphql.ts
import { GraphQLClient, gql } from 'graphql-request'
const endpoint = process.env.STRAPI_GRAPHQL_URL || 'http://localhost:1337/graphql'
export const graphQLClient = new GraphQLClient(endpoint, {
headers: {
authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
})
// Queries
export const GET_ARTICLES = gql`
query GetArticles($page: Int, $pageSize: Int) {
articles(
pagination: { page: $page, pageSize: $pageSize }
sort: ["publishedAt:desc"]
filters: { publishedAt: { notNull: true } }
) {
data {
id
attributes {
title
slug
excerpt
publishedAt
cover {
data {
attributes {
url
formats
}
}
}
}
}
meta {
pagination {
pageCount
total
}
}
}
}
`
export const GET_ARTICLE_BY_SLUG = gql`
query GetArticleBySlug($slug: String!) {
articles(filters: { slug: { eq: $slug } }) {
data {
id
attributes {
title
content
publishedAt
seo {
metaTitle
metaDescription
metaImage {
data {
attributes {
url
}
}
}
}
}
}
}
}
`
// Helper functions
export async function getArticles(page = 1, pageSize = 10) {
const data = await graphQLClient.request(GET_ARTICLES, { page, pageSize })
return data.articles
}
export async function getArticleBySlug(slug: string) {
const data = await graphQLClient.request(GET_ARTICLE_BY_SLUG, { slug })
return data.articles.data[0] || null
}API customization
Custom controllers
// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi'
export default factories.createCoreController(
'api::article.article',
({ strapi }) => ({
// Override find
async find(ctx) {
// Custom logic before the query
const { data, meta } = await super.find(ctx)
// Data transformation
const transformedData = data.map((item) => ({
...item,
readTime: Math.ceil(item.attributes.content.length / 1000),
wordCount: item.attributes.content.split(/\s+/).length,
}))
return { data: transformedData, meta }
},
// Override findOne
async findOne(ctx) {
const { id } = ctx.params
const entity = await strapi.entityService.findOne(
'api::article.article',
id,
{
populate: ['author', 'category', 'cover'],
}
)
// Increment view counter
await strapi.entityService.update('api::article.article', id, {
data: { views: (entity.views || 0) + 1 },
})
return { data: entity }
},
// Custom action
async featured(ctx) {
const entries = await strapi.entityService.findMany(
'api::article.article',
{
filters: { featured: true, publishedAt: { $notNull: true } },
sort: { publishedAt: 'desc' },
limit: 5,
populate: ['cover', 'author'],
}
)
return { data: entries }
},
// Custom action with parameters
async byCategory(ctx) {
const { slug } = ctx.params
const { page = 1, pageSize = 10 } = ctx.query
const entries = await strapi.entityService.findMany(
'api::article.article',
{
filters: {
category: { slug: { $eq: slug } },
publishedAt: { $notNull: true },
},
sort: { publishedAt: 'desc' },
start: (page - 1) * pageSize,
limit: pageSize,
populate: ['cover'],
}
)
const total = await strapi.entityService.count('api::article.article', {
filters: {
category: { slug: { $eq: slug } },
publishedAt: { $notNull: true },
},
})
return {
data: entries,
meta: {
pagination: {
page: Number(page),
pageSize: Number(pageSize),
pageCount: Math.ceil(total / pageSize),
total,
},
},
}
},
})
)Custom routes
// src/api/article/routes/custom-article.ts
export default {
routes: [
{
method: 'GET',
path: '/articles/featured',
handler: 'article.featured',
config: {
auth: false,
policies: [],
middlewares: [],
},
},
{
method: 'GET',
path: '/articles/category/:slug',
handler: 'article.byCategory',
config: {
auth: false,
},
},
],
}Custom services
// src/api/article/services/article.ts
import { factories } from '@strapi/strapi'
export default factories.createCoreService(
'api::article.article',
({ strapi }) => ({
// Custom service methods
async findPopular(limit = 10) {
return strapi.entityService.findMany('api::article.article', {
filters: { publishedAt: { $notNull: true } },
sort: { views: 'desc' },
limit,
populate: ['cover', 'author'],
})
},
async findRelated(articleId: number, limit = 3) {
const article = await strapi.entityService.findOne(
'api::article.article',
articleId,
{ populate: ['category', 'tags'] }
)
if (!article) return []
const categoryId = article.category?.id
const tagIds = article.tags?.map((t) => t.id) || []
return strapi.entityService.findMany('api::article.article', {
filters: {
id: { $ne: articleId },
publishedAt: { $notNull: true },
$or: [
{ category: { id: { $eq: categoryId } } },
{ tags: { id: { $in: tagIds } } },
],
},
sort: { publishedAt: 'desc' },
limit,
populate: ['cover'],
})
},
async incrementViews(articleId: number) {
const article = await strapi.entityService.findOne(
'api::article.article',
articleId
)
return strapi.entityService.update('api::article.article', articleId, {
data: { views: (article?.views || 0) + 1 },
})
},
})
)Middlewares
// src/api/article/middlewares/article-logger.ts
export default (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now()
await next()
const delta = Date.now() - start
strapi.log.info(`Article API: ${ctx.method} ${ctx.url} - ${delta}ms`)
}
}
// Usage in routes
{
method: 'GET',
path: '/articles',
handler: 'article.find',
config: {
middlewares: ['api::article.article-logger'],
},
}Lifecycle hooks
// src/api/article/content-types/article/lifecycles.ts
export default {
// Before creation
async beforeCreate(event) {
const { data } = event.params
// Automatically generate slug if not provided
if (!data.slug && data.title) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
// Calculate read time
if (data.content) {
data.readTime = Math.ceil(data.content.split(/\s+/).length / 200)
}
},
// After creation
async afterCreate(event) {
const { result } = event
// Send notification
await strapi.service('api::notification.notification').send({
type: 'article_created',
data: {
title: result.title,
id: result.id,
},
})
// Refresh cache
await strapi.service('api::cache.cache').invalidate('articles')
},
// Before update
async beforeUpdate(event) {
const { data } = event.params
if (data.content) {
data.readTime = Math.ceil(data.content.split(/\s+/).length / 200)
}
},
// After update
async afterUpdate(event) {
const { result } = event
// Revalidate Next.js pages
if (result.publishedAt) {
await fetch(`${process.env.FRONTEND_URL}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.REVALIDATION_SECRET,
slug: result.slug,
}),
})
}
},
// Before deletion
async beforeDelete(event) {
const { where } = event.params
// Archive before deleting
const article = await strapi.entityService.findOne(
'api::article.article',
where.id
)
await strapi.entityService.create('api::archive.archive', {
data: {
contentType: 'article',
originalId: where.id,
data: JSON.stringify(article),
},
})
},
}Authentication and permissions
Users & Permissions plugin
Strapi has a built-in user and permissions system.
// User registration
const response = await fetch('http://localhost:1337/api/auth/local/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'johndoe',
email: 'john@example.com',
password: 'securePassword123',
}),
})
const { jwt, user } = await response.json()
// Login
const response = await fetch('http://localhost:1337/api/auth/local', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: 'john@example.com', // or username
password: 'securePassword123',
}),
})
const { jwt, user } = await response.json()
// Use JWT in subsequent requests
const articlesResponse = await fetch('http://localhost:1337/api/articles', {
headers: {
Authorization: `Bearer ${jwt}`,
},
})API tokens
For server-to-server communication, use API tokens (Settings β API Tokens):
// Full access token
const response = await fetch('http://localhost:1337/api/articles', {
headers: {
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
})
// Token types:
// - Read-only: GET requests only
// - Full access: All operations
// - Custom: Granular permissions per endpointPermissions configuration
// config/plugins.ts
export default {
'users-permissions': {
config: {
jwt: {
expiresIn: '7d',
},
register: {
allowedFields: ['username', 'email', 'password', 'firstName', 'lastName'],
},
},
},
}Custom policies
// src/policies/is-owner.ts
export default async (policyContext, config, { strapi }) => {
const { user } = policyContext.state
const { id } = policyContext.params
if (!user) {
return false
}
const article = await strapi.entityService.findOne(
'api::article.article',
id,
{ populate: ['author'] }
)
if (!article) {
return false
}
return article.author?.id === user.id
}
// Usage in routes
{
method: 'PUT',
path: '/articles/:id',
handler: 'article.update',
config: {
policies: ['is-owner'],
},
}Media Library
File upload
// Upload via form-data
const formData = new FormData()
formData.append('files', fileInput.files[0])
formData.append('ref', 'api::article.article') // Content type
formData.append('refId', '1') // Article ID
formData.append('field', 'cover') // Field name
const response = await fetch('http://localhost:1337/api/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
},
body: formData,
})
const uploadedFiles = await response.json()Upload provider configuration
// config/plugins.ts
// Cloudinary
export default {
upload: {
config: {
provider: 'cloudinary',
providerOptions: {
cloud_name: process.env.CLOUDINARY_NAME,
api_key: process.env.CLOUDINARY_KEY,
api_secret: process.env.CLOUDINARY_SECRET,
},
actionOptions: {
upload: {},
delete: {},
},
},
},
}
// AWS S3
export default {
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_ACCESS_SECRET,
region: process.env.AWS_REGION,
params: {
Bucket: process.env.AWS_BUCKET,
},
},
actionOptions: {
upload: {
ACL: null, // S3 Object Ownership
},
uploadStream: {
ACL: null,
},
},
},
},
}Image optimization
// config/plugins.ts
export default {
upload: {
config: {
breakpoints: {
xlarge: 1920,
large: 1000,
medium: 750,
small: 500,
xsmall: 64,
},
sizeLimit: 10 * 1024 * 1024, // 10MB
providerOptions: {
localServer: {
maxage: 300000,
},
},
},
},
}Webhooks
Configuring webhooks in the Admin Panel
Settings β Webhooks β Create new webhook
Available events:
- Entry: create, update, delete, publish, unpublish
- Media: create, update, delete
Example: revalidation in Next.js
// pages/api/strapi-webhook.ts (Next.js)
import type { NextApiRequest, NextApiResponse } from 'next'
interface StrapiWebhookPayload {
event: string
model: string
entry: {
id: number
slug: string
[key: string]: any
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Verify secret
const signature = req.headers['x-strapi-signature']
if (signature !== process.env.STRAPI_WEBHOOK_SECRET) {
return res.status(401).json({ message: 'Invalid signature' })
}
const { event, model, entry } = req.body as StrapiWebhookPayload
try {
// Revalidate the appropriate pages
if (model === 'article') {
await res.revalidate(`/blog/${entry.slug}`)
await res.revalidate('/blog')
if (event === 'entry.publish' || event === 'entry.unpublish') {
await res.revalidate('/')
}
}
if (model === 'category') {
await res.revalidate(`/category/${entry.slug}`)
await res.revalidate('/categories')
}
return res.json({ revalidated: true })
} catch (error) {
return res.status(500).json({ error: 'Revalidation failed' })
}
}Internationalization (i18n)
Enabling i18n
// config/plugins.ts
export default {
i18n: {
enabled: true,
config: {
locales: ['pl', 'en', 'de'],
defaultLocale: 'pl',
},
},
}Content Type with i18n
In the Content-Type Builder, enable "Enable localization for this Content-Type" for the desired fields.
// schema.json
{
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"publishDate": {
"type": "datetime"
// Not localized - shared across all versions
}
}
}Query with locale
// REST API
const response = await fetch(
'http://localhost:1337/api/articles?locale=pl&populate=localizations'
)
// GraphQL
query GetArticle($slug: String!, $locale: String!) {
articles(filters: { slug: { eq: $slug } }, locale: $locale) {
data {
id
attributes {
title
content
locale
localizations {
data {
attributes {
locale
slug
}
}
}
}
}
}
}Deployment
Self-hosting with PM2
# Build
NODE_ENV=production npm run build
# Start with PM2
pm2 start npm --name "strapi" -- run start
# ecosystem.config.js
module.exports = {
apps: [
{
name: 'strapi',
cwd: '/var/www/strapi',
script: 'npm',
args: 'start',
env: {
NODE_ENV: 'production',
DATABASE_CLIENT: 'postgres',
DATABASE_HOST: 'localhost',
DATABASE_PORT: 5432,
DATABASE_NAME: 'strapi',
DATABASE_USERNAME: 'strapi',
DATABASE_PASSWORD: 'password',
},
},
],
}Docker
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 1337
CMD ["npm", "start"]# docker-compose.yml
version: '3'
services:
strapi:
build: .
ports:
- '1337:1337'
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_NAME: strapi
DATABASE_USERNAME: strapi
DATABASE_PASSWORD: strapi
NODE_ENV: production
depends_on:
- postgres
volumes:
- ./public/uploads:/app/public/uploads
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: strapi
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:Strapi Cloud
# Login to Strapi Cloud
npx @strapi/cloud login
# Deploy
npx @strapi/cloud deployRailway, Render, DigitalOcean
Strapi can be easily deployed on PaaS platforms - just connect your GitHub repo and configure environment variables.
Pricing
Self-hosted (Open Source)
| Plan | Price | Features |
|---|---|---|
| Community | Free | Full CMS, REST/GraphQL API, Media Library, i18n, Roles |
Strapi Cloud
| Plan | Price | Features |
|---|---|---|
| Pro | $99/mo | 1M API calls, 100GB storage, 10 users |
| Team | $499/mo | 10M API calls, 500GB storage, 25 users, SSO |
| Enterprise | Custom | Unlimited, SLA, dedicated support |
Enterprise features (Self-hosted)
| Plan | Price | Features |
|---|---|---|
| Self-Hosted Enterprise | Custom | SSO, Audit logs, Review workflows, Content releases |
FAQ - frequently asked questions
Is Strapi free?
Yes, Strapi Community Edition is completely free and open source (MIT License). You can use it without any limitations, including commercial projects. Only Strapi Cloud and some enterprise features are paid.
Strapi vs WordPress - which one to choose?
WordPress is a traditional CMS with a frontend, while Strapi is a headless CMS with only a backend. Choose Strapi when you are building a custom frontend (React/Vue/Next.js), need a clean API, or want greater control over the code.
How to migrate from Strapi v4 to v5?
Strapi v5 introduces breaking changes. Use the official migration guide and codemods. Key changes include: new response API structure, Document Service instead of Entity Service, and improved TypeScript types.
Does Strapi support real-time?
Strapi does not have built-in real-time capabilities. You can use webhooks to notify frontends of changes or integrate Socket.io through a custom plugin.
How to secure the Strapi API?
- Configure appropriate permissions in Admin Panel β Settings β Roles
- Use API tokens for server-to-server communication
- Enable rate limiting in middleware
- Use HTTPS
- Keep Strapi updated regularly
Can I host Strapi on shared hosting?
Not recommended. Strapi requires Node.js and a persistent process. Better options include VPS, DigitalOcean App Platform, Railway, Render, or Strapi Cloud.
Summary
Strapi is the best choice for developers looking for an open-source headless CMS with full control over code and data. It offers:
- Visual Content-Type Builder for rapid data modeling
- Automatic REST and GraphQL API
- Extensive plugin system
- Full customization through controllers, services, middlewares
- Self-hosting or managed Strapi Cloud
- Active community with thousands of plugins
If you are building a modern application with React, Vue, Next.js, or mobile - Strapi will provide a flexible and performant backend for content management.