We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide22 min read

Strapi

Strapi is the most popular open-source headless CMS with visual content builder, REST/GraphQL API, customizable admin panel and self-hosting capabilities.

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

  1. 100% Open Source (MIT License) - Full access to the code, no vendor lock-in
  2. Visual Content-Type Builder - Create data structures without writing code
  3. Automatic REST and GraphQL API - API generated automatically from Content Types
  4. Customizable Admin Panel - Tailor the panel to your content team's needs
  5. Self-hosted or Cloud - Full control over infrastructure
  6. Plugin System - Extend functionality without modifying the core
  7. Multilingual (i18n) - Built-in support for multiple languages
  8. Role-Based Access Control - Granular permissions for users

Strapi vs other CMS

FeatureStrapiContentfulSanityWordPress
TypeHeadlessHeadlessHeadlessTraditional
Open Sourceβœ… Yes (MIT)❌ No❌ Noβœ… Yes
Self-hostingβœ… Full❌ Cloud only❌ Cloud onlyβœ… Yes
Price (Self-hosted)FreeN/AN/AFree
APIREST + GraphQLREST + GraphQLGROQ + GraphQLREST
Visual Builderβœ… Yesβœ… Yesβœ… Yesβœ… Yes
TypeScriptβœ… NativeSDKSDK❌ PHP
Pluginsβœ… Marketplaceβœ… Appsβœ… Pluginsβœ… Huge
Learning CurveMediumLowMediumLow

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

Code
Bash
# 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

Code
Bash
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/api

Strapi project structure

Code
TEXT
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.json

Database configuration

TSconfig/database.ts
TypeScript
// 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
JSON
// 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
JSON
// 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:

MethodEndpointDescription
GET/api/articlesList all articles
GET/api/articles/:idSingle article
POST/api/articlesCreate an article
PUT/api/articles/:idUpdate an article (full)
DELETE/api/articles/:idDelete an article

Fetching data

Code
TypeScript
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

Code
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
// 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

Code
Bash
npm install @strapi/plugin-graphql
# or
yarn add @strapi/plugin-graphql

GraphQL Playground is available at: http://localhost:1337/graphql

Queries

Code
GraphQL
# 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

Code
GraphQL
# 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

TSlib/strapi-graphql.ts
TypeScript
// 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

TSsrc/api/article/controllers/article.ts
TypeScript
// 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

TSsrc/api/article/routes/custom-article.ts
TypeScript
// 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

TSsrc/api/article/services/article.ts
TypeScript
// 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

TSsrc/api/article/middlewares/article-logger.ts
TypeScript
// 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

TSsrc/api/article/content-types/article/lifecycles.ts
TypeScript
// 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.

Code
TypeScript
// 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):

Code
TypeScript
// 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 endpoint

Permissions configuration

TSconfig/plugins.ts
TypeScript
// config/plugins.ts
export default {
  'users-permissions': {
    config: {
      jwt: {
        expiresIn: '7d',
      },
      register: {
        allowedFields: ['username', 'email', 'password', 'firstName', 'lastName'],
      },
    },
  },
}

Custom policies

TSsrc/policies/is-owner.ts
TypeScript
// 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

Code
TypeScript
// 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

TSconfig/plugins.ts
TypeScript
// 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

TSconfig/plugins.ts
TypeScript
// 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

TSpages/api/strapi-webhook.ts
TypeScript
// 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

TSconfig/plugins.ts
TypeScript
// 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
JSON
// 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

Code
TypeScript
// 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

Code
Bash
# 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

Code
DOCKERFILE
# 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
YAML
# 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

Code
Bash
# Login to Strapi Cloud
npx @strapi/cloud login

# Deploy
npx @strapi/cloud deploy

Railway, Render, DigitalOcean

Strapi can be easily deployed on PaaS platforms - just connect your GitHub repo and configure environment variables.

Pricing

Self-hosted (Open Source)

PlanPriceFeatures
CommunityFreeFull CMS, REST/GraphQL API, Media Library, i18n, Roles

Strapi Cloud

PlanPriceFeatures
Pro$99/mo1M API calls, 100GB storage, 10 users
Team$499/mo10M API calls, 500GB storage, 25 users, SSO
EnterpriseCustomUnlimited, SLA, dedicated support

Enterprise features (Self-hosted)

PlanPriceFeatures
Self-Hosted EnterpriseCustomSSO, 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.