Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik22 min czytania

Strapi

Strapi to najpopularniejszy open-source headless CMS z visual content builder, REST/GraphQL API, customizable admin panel i self-hosting. Kompletny przewodnik po tworzeniu API i zarządzaniu treścią.

Strapi - Kompletny Przewodnik po Open Source Headless CMS

Czym jest Strapi?

Strapi to najpopularniejszy open-source headless CMS na świecie, stworzony w 2015 roku przez Pierre'a Burgy'ego we Francji. Od tego czasu projekt zebrał ponad 64,000 gwiazdek na GitHubie i jest używany przez setki tysięcy deweloperów oraz firmy takie jak IBM, NASA, Walmart i Toyota.

Jako headless CMS, Strapi oddziela warstwę zarządzania treścią (backend) od warstwy prezentacji (frontend). Oznacza to, że możesz tworzyć i zarządzać treścią przez intuicyjny admin panel, a następnie dostarczać ją do dowolnej aplikacji frontendowej - React, Vue, Next.js, mobile apps, IoT - poprzez API REST lub GraphQL.

Strapi wyróżnia się pełną kontrolą nad kodem i danymi - możesz hostować go na własnych serwerach lub wybrać managed Strapi Cloud. Jest napisany w Node.js i TypeScript, co sprawia, że jest naturalnym wyborem dla deweloperów JavaScript.

Dlaczego Strapi?

Kluczowe zalety Strapi

  1. 100% Open Source (MIT License) - Pełny dostęp do kodu, bez vendor lock-in
  2. Visual Content-Type Builder - Twórz struktury danych bez pisania kodu
  3. Automatyczne REST i GraphQL API - API generowane automatycznie z Content Types
  4. Customizable Admin Panel - Dostosuj panel do potrzeb zespołu content
  5. Self-hosted lub Cloud - Pełna kontrola nad infrastrukturą
  6. Plugin System - Rozszerzaj funkcjonalność bez modyfikacji core
  7. Wielojęzyczność (i18n) - Built-in wsparcie dla wielu języków
  8. Role-Based Access Control - Granularne uprawnienia dla użytkowników

Strapi vs Inne CMS

CechaStrapiContentfulSanityWordPress
TypHeadlessHeadlessHeadlessTraditional
Open Source✅ Tak (MIT)❌ Nie❌ Nie✅ Tak
Self-hosting✅ Pełny❌ Tylko cloud❌ Tylko cloud✅ Tak
Cena (Self-hosted)DarmowyN/AN/ADarmowy
APIREST + GraphQLREST + GraphQLGROQ + GraphQLREST
Visual Builder✅ Tak✅ Tak✅ Tak✅ Tak
TypeScript✅ NatywnySDKSDK❌ PHP
Plugins✅ Marketplace✅ Apps✅ Plugins✅ Ogromny
Learning CurveŚredniaNiskaŚredniaNiska

Kiedy wybrać Strapi?

Strapi jest idealny gdy:

  • Potrzebujesz pełnej kontroli nad kodem i danymi
  • Chcesz uniknąć miesięcznych opłat za CMS
  • Budujesz aplikację z React/Vue/Next.js
  • Potrzebujesz custom API endpoints
  • Zależy Ci na self-hostingu (compliance, GDPR)
  • Pracujesz z JavaScript/TypeScript stack

Rozważ alternatywy gdy:

  • Potrzebujesz real-time collaboration jak Google Docs → Sanity
  • Masz enterprise requirements i budżet → Contentful
  • Potrzebujesz gotowego frontendu → WordPress

Instalacja i Konfiguracja

Wymagania systemowe

  • Node.js: 18.x, 20.x lub 22.x
  • npm: 6.x lub wyższy (lub yarn/pnpm)
  • Baza danych: SQLite (dev), PostgreSQL, MySQL, MariaDB (produkcja)

Tworzenie nowego projektu

Code
Bash
# Quickstart z SQLite (development)
npx create-strapi-app@latest my-project --quickstart

# Lub z wyborem bazy danych
npx create-strapi-app@latest my-project

# Opcje podczas instalacji:
# - TypeScript lub JavaScript
# - SQLite, PostgreSQL, MySQL, MariaDB
# - Przykładowe dane (opcjonalnie)

Uruchomienie projektu

Code
Bash
cd my-project

# Development (z hot reload)
npm run develop
# lub
yarn develop

# Strapi startuje na:
# - Admin Panel: http://localhost:1337/admin
# - API: http://localhost:1337/api

Struktura projektu Strapi

Code
TEXT
my-project/
├── config/
│   ├── admin.ts          # Konfiguracja admin panelu
│   ├── api.ts             # Ustawienia API
│   ├── database.ts        # Konfiguracja bazy danych
│   ├── middlewares.ts     # Middleware config
│   ├── plugins.ts         # Konfiguracja pluginów
│   └── server.ts          # Ustawienia serwera
├── src/
│   ├── admin/             # Customizacja admin panelu
│   ├── 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

Konfiguracja bazy danych

TSconfig/database.ts
TypeScript
// config/database.ts
import path from 'path';

export default ({ env }) => {
  const client = env('DATABASE_CLIENT', 'sqlite');

  const connections = {
    // SQLite dla development
    sqlite: {
      connection: {
        filename: path.join(
          __dirname,
          '..',
          '..',
          env('DATABASE_FILENAME', '.tmp/data.db')
        ),
      },
      useNullAsDefault: true,
    },

    // PostgreSQL dla produkcji
    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 - Modelowanie Danych

Tworzenie Content Types przez Admin Panel

Strapi oferuje visual Content-Type Builder dostępny w Admin Panel → Content-Type Builder.

Typy kolekcji:

  • Collection Type - Wiele wpisów (np. Articles, Products, Users)
  • Single Type - Jeden wpis (np. Homepage, Settings, About)

Dostępne typy pól:

  • Text - Krótki tekst, slug, email, password
  • Rich Text - WYSIWYG editor z formatowaniem
  • Number - Integer, big integer, decimal, float
  • Date - Date, time, datetime
  • Boolean - True/false
  • JSON - Dowolna struktura JSON
  • Media - Obrazy, pliki, wideo
  • Relation - Powiązania między Content Types
  • UID - Unikalne identyfikatory (np. slug)
  • Enumeration - Lista predefiniowanych wartości
  • Component - Reusable grupy pól
  • Dynamic Zone - Elastyczne sekcje z różnymi komponentami

Content Type Schema (kod)

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"
      ]
    }
  }
}

Komponenty wielokrotnego użytku

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

Automatycznie generowane endpointy

Po utworzeniu Content Type "Article", Strapi automatycznie generuje REST API:

MetodaEndpointOpis
GET/api/articlesLista wszystkich artykułów
GET/api/articles/:idPojedynczy artykuł
POST/api/articlesUtwórz artykuł
PUT/api/articles/:idZaktualizuj artykuł (cały)
DELETE/api/articles/:idUsuń artykuł

Pobieranie danych

Code
TypeScript
// Pobierz wszystkie artykuły
const response = await fetch('http://localhost:1337/api/articles')
const { data, meta } = await response.json()

// Struktura odpowiedzi
{
  "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 - Relacje i Media

Code
TypeScript
// Pobierz artykuł z relacjami (domyślnie relacje nie są zwracane!)
const response = await fetch(
  'http://localhost:1337/api/articles?populate=*'
)

// Głębokie populate
const response = await fetch(
  'http://localhost:1337/api/articles?populate[author][populate]=avatar&populate=cover&populate=category'
)

// Populate z selekcją pól
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'

// Podstawowe filtrowanie
const query = qs.stringify({
  filters: {
    title: {
      $contains: 'Strapi'
    }
  }
})

// Dostępne operatory:
// $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)

// Złożone filtrowanie
const query = qs.stringify({
  filters: {
    $and: [
      {
        publishedAt: {
          $notNull: true
        }
      },
      {
        $or: [
          { featured: { $eq: true } },
          { category: { name: { $eq: 'News' } } }
        ]
      }
    ]
  }
})

// Filtrowanie po relacjach
const query = qs.stringify({
  filters: {
    author: {
      username: {
        $eq: 'john'
      }
    },
    category: {
      slug: {
        $in: ['technology', 'programming']
      }
    }
  }
})

Sorting i Pagination

Code
TypeScript
const query = qs.stringify({
  // Sortowanie
  sort: ['publishedAt:desc', 'title:asc'],

  // Paginacja
  pagination: {
    page: 1,
    pageSize: 10
  },
  // lub offset-based
  // pagination: {
  //   start: 0,
  //   limit: 10
  // }

  // Selekcja pól
  fields: ['title', 'slug', 'publishedAt'],

  // Populate relacji
  populate: ['cover', 'author']
})

const response = await fetch(`http://localhost:1337/api/articles?${query}`)

Tworzenie i aktualizacja danych

Code
TypeScript
// Utwórz artykuł
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, // ID relacji
      tags: [1, 2, 3], // IDs dla many-to-many
      featured: true,
      seo: {
        metaTitle: 'New Article | My Blog',
        metaDescription: 'Description here...'
      }
    }
  })
})

// Aktualizuj artykuł
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
    }
  })
})

// Usuń artykuł
const response = await fetch('http://localhost:1337/api/articles/1', {
  method: 'DELETE',
  headers: {
    'Authorization': `Bearer ${token}`
  }
})

GraphQL API

Instalacja pluginu GraphQL

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

GraphQL Playground dostępny pod: http://localhost:1337/graphql

Queries

Code
GraphQL
# Pobierz wszystkie artykuły
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
      }
    }
  }
}

# Pobierz pojedynczy artykuł
query GetArticle($id: ID!) {
  article(id: $id) {
    data {
      id
      attributes {
        title
        content
        seo {
          metaTitle
          metaDescription
        }
      }
    }
  }
}

# Z filtrowaniem
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
# Utwórz artykuł
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
  }
}

# Aktualizuj artykuł
mutation UpdateArticle($id: ID!, $data: ArticleInput!) {
  updateArticle(id: $id, data: $data) {
    data {
      id
      attributes {
        title
        updatedAt
      }
    }
  }
}

# Usuń artykuł
mutation DeleteArticle($id: ID!) {
  deleteArticle(id: $id) {
    data {
      id
    }
  }
}

Klient GraphQL w 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
                }
              }
            }
          }
        }
      }
    }
  }
`

// Funkcje pomocnicze
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
}

Customizacja API

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) {
      // Własna logika przed zapytaniem
      const { data, meta } = await super.find(ctx)

      // Transformacja danych
      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'],
        }
      )

      // Inkrementuj licznik wyświetleń
      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 z parametrami
    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`)
  }
}

// Użycie w 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 {
  // Przed utworzeniem
  async beforeCreate(event) {
    const { data } = event.params

    // Automatycznie generuj slug jeśli nie podany
    if (!data.slug && data.title) {
      data.slug = data.title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/(^-|-$)/g, '')
    }

    // Oblicz czas czytania
    if (data.content) {
      data.readTime = Math.ceil(data.content.split(/\s+/).length / 200)
    }
  },

  // Po utworzeniu
  async afterCreate(event) {
    const { result } = event

    // Wyślij notyfikację
    await strapi.service('api::notification.notification').send({
      type: 'article_created',
      data: {
        title: result.title,
        id: result.id,
      },
    })

    // Odśwież cache
    await strapi.service('api::cache.cache').invalidate('articles')
  },

  // Przed aktualizacją
  async beforeUpdate(event) {
    const { data } = event.params

    if (data.content) {
      data.readTime = Math.ceil(data.content.split(/\s+/).length / 200)
    }
  },

  // Po aktualizacji
  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,
        }),
      })
    }
  },

  // Przed usunięciem
  async beforeDelete(event) {
    const { where } = event.params

    // Archiwizuj przed usunięciem
    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),
      },
    })
  },
}

Autentykacja i Uprawnienia

Users & Permissions Plugin

Strapi ma wbudowany system użytkowników i uprawnień.

Code
TypeScript
// Rejestracja użytkownika
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()

// Logowanie
const response = await fetch('http://localhost:1337/api/auth/local', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    identifier: 'john@example.com', // lub username
    password: 'securePassword123',
  }),
})

const { jwt, user } = await response.json()

// Użyj JWT w kolejnych requestach
const articlesResponse = await fetch('http://localhost:1337/api/articles', {
  headers: {
    Authorization: `Bearer ${jwt}`,
  },
})

API Tokens

Dla server-to-server komunikacji użyj 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}`,
  },
})

// Typy tokenów:
// - Read-only: Tylko GET requests
// - Full access: Wszystkie operacje
// - Custom: Granularne uprawnienia per endpoint

Konfiguracja uprawnień

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
}

// Użycie w routes
{
  method: 'PUT',
  path: '/articles/:id',
  handler: 'article.update',
  config: {
    policies: ['is-owner'],
  },
}

Media Library

Upload plików

Code
TypeScript
// Upload przez form-data
const formData = new FormData()
formData.append('files', fileInput.files[0])
formData.append('ref', 'api::article.article') // Content type
formData.append('refId', '1') // ID artykułu
formData.append('field', 'cover') // Nazwa pola

const response = await fetch('http://localhost:1337/api/upload', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${jwt}`,
  },
  body: formData,
})

const uploadedFiles = await response.json()

Konfiguracja upload providers

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

Konfiguracja webhooks w Admin Panel

Settings → Webhooks → Create new webhook

Dostępne eventy:

  • Entry: create, update, delete, publish, unpublish
  • Media: create, update, delete

Przykład: Revalidation w 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
) {
  // Weryfikuj 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 odpowiednie strony
    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' })
  }
}

Internacjonalizacja (i18n)

Włączenie i18n

TSconfig/plugins.ts
TypeScript
// config/plugins.ts
export default {
  i18n: {
    enabled: true,
    config: {
      locales: ['pl', 'en', 'de'],
      defaultLocale: 'pl',
    },
  },
}

Content Type z i18n

W Content-Type Builder włącz "Enable localization for this Content-Type" dla wybranych pól.

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"
      // Nie lokalizowane - wspólne dla wszystkich wersji
    }
  }
}

Query z 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 z PM2

Code
Bash
# Build
NODE_ENV=production npm run build

# Start z 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 do Strapi Cloud
npx @strapi/cloud login

# Deploy
npx @strapi/cloud deploy

Railway, Render, DigitalOcean

Strapi można łatwo deployować na platformach PaaS - wystarczy połączyć repo GitHub i skonfigurować zmienne środowiskowe.

Cennik

Self-hosted (Open Source)

PlanCenaFunkcje
CommunityDarmowyPełny CMS, REST/GraphQL API, Media Library, i18n, Roles

Strapi Cloud

PlanCenaFunkcje
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)

PlanCenaFunkcje
Self-Hosted EnterpriseCustomSSO, Audit logs, Review workflows, Content releases

FAQ - Często Zadawane Pytania

Czy Strapi jest darmowy?

Tak, Strapi Community Edition jest w pełni darmowy i open source (MIT License). Możesz go używać bez ograniczeń, włącznie z projektami komercyjnymi. Płatne są tylko Strapi Cloud i niektóre enterprise features.

Strapi vs WordPress - co wybrać?

WordPress to tradycyjny CMS z frontendem, Strapi to headless CMS tylko z backendem. Wybierz Strapi gdy budujesz custom frontend (React/Vue/Next.js), potrzebujesz czystego API, lub chcesz większą kontrolę nad kodem.

Jak migrować z Strapi v4 do v5?

Strapi v5 wprowadza breaking changes. Użyj oficjalnego migration guide i codemods. Kluczowe zmiany: nowa struktura response API, Document Service zamiast Entity Service, ulepszone TypeScript types.

Czy Strapi obsługuje real-time?

Strapi nie ma wbudowanego real-time. Możesz użyć webhooks do powiadamiania frontendów o zmianach lub zintegrować Socket.io przez custom plugin.

Jak zabezpieczyć Strapi API?

  • Skonfiguruj odpowiednie permissions w Admin Panel → Settings → Roles
  • Używaj API tokens dla server-to-server
  • Włącz rate limiting w middleware
  • Używaj HTTPS
  • Regularnie aktualizuj Strapi

Czy mogę hostować Strapi na shared hostingu?

Nie zalecane. Strapi wymaga Node.js i ciągłego procesu. Lepsze opcje to VPS, DigitalOcean App Platform, Railway, Render, lub Strapi Cloud.

Podsumowanie

Strapi to najlepszy wybór dla deweloperów szukających open-source headless CMS z pełną kontrolą nad kodem i danymi. Oferuje:

  • Visual Content-Type Builder do szybkiego modelowania danych
  • Automatyczne REST i GraphQL API
  • Rozbudowany system pluginów
  • Pełną customizację przez controllers, services, middlewares
  • Self-hosting lub managed Strapi Cloud
  • Aktywną społeczność z tysiącami pluginów

Jeśli budujesz nowoczesną aplikację z React, Vue, Next.js lub mobile - Strapi zapewni elastyczny i wydajny backend do zarządzania treścią.