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
- 100% Open Source (MIT License) - Pełny dostęp do kodu, bez vendor lock-in
- Visual Content-Type Builder - Twórz struktury danych bez pisania kodu
- Automatyczne REST i GraphQL API - API generowane automatycznie z Content Types
- Customizable Admin Panel - Dostosuj panel do potrzeb zespołu content
- Self-hosted lub Cloud - Pełna kontrola nad infrastrukturą
- Plugin System - Rozszerzaj funkcjonalność bez modyfikacji core
- Wielojęzyczność (i18n) - Built-in wsparcie dla wielu języków
- Role-Based Access Control - Granularne uprawnienia dla użytkowników
Strapi vs Inne CMS
| Cecha | Strapi | Contentful | Sanity | WordPress |
|---|---|---|---|---|
| Typ | Headless | Headless | Headless | Traditional |
| Open Source | ✅ Tak (MIT) | ❌ Nie | ❌ Nie | ✅ Tak |
| Self-hosting | ✅ Pełny | ❌ Tylko cloud | ❌ Tylko cloud | ✅ Tak |
| Cena (Self-hosted) | Darmowy | N/A | N/A | Darmowy |
| API | REST + GraphQL | REST + GraphQL | GROQ + GraphQL | REST |
| Visual Builder | ✅ Tak | ✅ Tak | ✅ Tak | ✅ Tak |
| TypeScript | ✅ Natywny | SDK | SDK | ❌ PHP |
| Plugins | ✅ Marketplace | ✅ Apps | ✅ Plugins | ✅ Ogromny |
| Learning Curve | Średnia | Niska | Średnia | Niska |
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
# 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
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/apiStruktura projektu Strapi
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.jsonKonfiguracja bazy danych
// 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
{
"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
{
"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:
| Metoda | Endpoint | Opis |
|---|---|---|
| GET | /api/articles | Lista wszystkich artykułów |
| GET | /api/articles/:id | Pojedynczy artykuł |
| POST | /api/articles | Utwórz artykuł |
| PUT | /api/articles/:id | Zaktualizuj artykuł (cały) |
| DELETE | /api/articles/:id | Usuń artykuł |
Pobieranie danych
// 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
// 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
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
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
// 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
npm install @strapi/plugin-graphql
# lub
yarn add @strapi/plugin-graphqlGraphQL Playground dostępny pod: http://localhost:1337/graphql
Queries
# 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
# 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
// 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
// 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
// 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`)
}
}
// Użycie w 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 {
// 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ń.
// 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):
// 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 endpointKonfiguracja uprawnień
// 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
}
// Użycie w routes
{
method: 'PUT',
path: '/articles/:id',
handler: 'article.update',
config: {
policies: ['is-owner'],
},
}Media Library
Upload plików
// 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
// 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
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
// 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
// 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
{
"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
// 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
# 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
# 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 do Strapi Cloud
npx @strapi/cloud login
# Deploy
npx @strapi/cloud deployRailway, Render, DigitalOcean
Strapi można łatwo deployować na platformach PaaS - wystarczy połączyć repo GitHub i skonfigurować zmienne środowiskowe.
Cennik
Self-hosted (Open Source)
| Plan | Cena | Funkcje |
|---|---|---|
| Community | Darmowy | Pełny CMS, REST/GraphQL API, Media Library, i18n, Roles |
Strapi Cloud
| Plan | Cena | Funkcje |
|---|---|---|
| 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 | Cena | Funkcje |
|---|---|---|
| Self-Hosted Enterprise | Custom | SSO, 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ą.