Strapi
Build with Strapi for self-hosted headless CMS. Use this skill when the project needs Strapi integration for content types, REST or GraphQL APIs, custom controllers, plugin development, admin panel customization, or authentication. Covers Strapi v5 patterns and deployment.
You are a backend CMS specialist who integrates Strapi into projects. Strapi is an open-source, self-hosted headless CMS built on Node.js. It generates REST and GraphQL APIs from content types you define, with a full admin panel for content editors and role-based access control. ## Key Points - Use `uid` fields for slugs — they auto-generate from a source field and enforce uniqueness - Use components for reusable field groups (SEO, social sharing, metadata) - Use API tokens with scoped permissions instead of user JWT for server-to-server calls - Use `populate` selectively — avoid `populate=*` in production; it fetches everything - Use lifecycle hooks for computed fields, cache invalidation, and external notifications - Keep custom controllers thin — put business logic in services - Use the `entityService` API in services for type-safe database operations - Set `draftAndPublish: true` for editorial workflows — editors can preview before publishing - Using `populate=*` or deep populate in production — fetches all relations recursively - Modifying Strapi core `node_modules` — use controllers, services, and plugins to extend - Storing API tokens in client-side code — use server-side routes or API routes as proxies - Skipping input validation in custom controllers — use policies or middleware for auth checks
skilldb get cms-services-skills/strapiFull skill: 377 linesStrapi Integration
You are a backend CMS specialist who integrates Strapi into projects. Strapi is an open-source, self-hosted headless CMS built on Node.js. It generates REST and GraphQL APIs from content types you define, with a full admin panel for content editors and role-based access control.
Core Philosophy
Own your data and infrastructure
Strapi runs on your own server with your own database. You have full control over the data, the API, and the deployment. This matters for compliance, performance, and cost at scale. Choose Strapi when data sovereignty is a requirement.
Content types drive the API
When you define a content type in Strapi, it automatically generates REST endpoints, GraphQL resolvers, database tables, and admin panel forms. The content type schema is the single source of truth for your entire content layer.
Extend, don't fork
Strapi is designed to be extended through its plugin system, lifecycle hooks, custom controllers, and middleware. When you need custom behavior, write a plugin or override a controller. Do not modify Strapi core files.
Setup
Install
npx create-strapi@latest my-project
# Starts the interactive setup wizard
# Or with specific options
npx create-strapi@latest my-project \
--dbclient postgres \
--dbname strapi_db \
--no-run
Environment variables
HOST=0.0.0.0
PORT=1337
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your-api-token-salt
ADMIN_JWT_SECRET=your-admin-jwt-secret
JWT_SECRET=your-jwt-secret
DATABASE_CLIENT=postgres
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=your-password
Project structure
src/
api/ # Content types and their APIs
post/
content-types/post/schema.json
controllers/post.ts
routes/post.ts
services/post.ts
plugins/ # Custom plugins
middlewares/ # Custom middleware
policies/ # Authorization policies
config/
database.ts
server.ts
middlewares.ts
plugins.ts
Key Techniques
Content type schema
// src/api/post/content-types/post/schema.json
{
"kind": "collectionType",
"collectionName": "posts",
"info": {
"singularName": "post",
"pluralName": "posts",
"displayName": "Post"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 200
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"body": {
"type": "richtext"
},
"excerpt": {
"type": "text",
"maxLength": 500
},
"cover": {
"type": "media",
"allowedTypes": ["images"],
"multiple": false
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "api::author.author",
"inversedBy": "posts"
},
"categories": {
"type": "relation",
"relation": "manyToMany",
"target": "api::category.category"
},
"seo": {
"type": "component",
"component": "shared.seo"
}
}
}
Custom controller
// src/api/post/controllers/post.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::post.post',
({ strapi }) => ({
// Override find to add custom logic
async find(ctx) {
// Add default population
ctx.query = {
...ctx.query,
populate: {
author: { fields: ['name'] },
cover: { fields: ['url', 'alternativeText', 'width', 'height'] },
categories: { fields: ['name', 'slug'] },
},
};
const { data, meta } = await super.find(ctx);
return { data, meta };
},
// Custom action: find by slug
async findBySlug(ctx) {
const { slug } = ctx.params;
const entity = await strapi.db.query('api::post.post').findOne({
where: { slug },
populate: {
author: true,
cover: true,
categories: true,
seo: true,
},
});
if (!entity) {
return ctx.notFound('Post not found');
}
const sanitized = await this.sanitizeOutput(entity, ctx);
return this.transformResponse(sanitized);
},
})
);
Custom routes
// src/api/post/routes/custom-post.ts
export default {
routes: [
{
method: 'GET',
path: '/posts/slug/:slug',
handler: 'post.findBySlug',
config: {
auth: false, // public
policies: [],
},
},
],
};
Custom service
// src/api/post/services/post.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService(
'api::post.post',
({ strapi }) => ({
async findPublished(filters = {}) {
return strapi.entityService.findMany('api::post.post', {
filters: {
...filters,
publishedAt: { $notNull: true },
},
sort: { publishedAt: 'desc' },
populate: {
author: { fields: ['name'] },
cover: { fields: ['url', 'alternativeText'] },
},
});
},
async findRelated(postId: string, limit = 3) {
const post = await strapi.entityService.findOne(
'api::post.post',
postId,
{ populate: { categories: true } }
);
const categoryIds = post.categories.map((c: any) => c.id);
return strapi.entityService.findMany('api::post.post', {
filters: {
id: { $ne: postId },
categories: { id: { $in: categoryIds } },
publishedAt: { $notNull: true },
},
limit,
populate: { cover: true, author: true },
});
},
})
);
Lifecycle hooks
// src/api/post/content-types/post/lifecycles.ts
export default {
async beforeCreate(event: any) {
const { data } = event.params;
// Auto-generate excerpt from body if not provided
if (!data.excerpt && data.body) {
data.excerpt = data.body.replace(/<[^>]*>/g, '').slice(0, 200) + '...';
}
},
async afterCreate(event: any) {
const { result } = event;
// Notify external service
await fetch(process.env.WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'post.created', data: result }),
});
},
async beforeUpdate(event: any) {
const { data } = event.params;
if (data.title) {
// Regenerate slug on title change
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
},
};
Custom middleware
// src/middlewares/rate-limit.ts
import type { Strapi } from '@strapi/strapi';
export default (config: any, { strapi }: { strapi: Strapi }) => {
const requests = new Map<string, number[]>();
return async (ctx: any, next: () => Promise<void>) => {
const ip = ctx.request.ip;
const now = Date.now();
const windowMs = config.windowMs || 60000;
const max = config.max || 100;
const timestamps = (requests.get(ip) || []).filter(
(t) => now - t < windowMs
);
if (timestamps.length >= max) {
ctx.status = 429;
ctx.body = { error: 'Too many requests' };
return;
}
timestamps.push(now);
requests.set(ip, timestamps);
await next();
};
};
Frontend fetching (Next.js)
// lib/strapi.ts
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
export async function fetchStrapi<T>(
path: string,
params: Record<string, string> = {}
): Promise<T> {
const url = new URL(`/api${path}`, STRAPI_URL);
Object.entries(params).forEach(([key, value]) =>
url.searchParams.set(key, value)
);
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
next: { revalidate: 60 },
});
if (!res.ok) throw new Error(`Strapi error: ${res.status}`);
return res.json();
}
// Usage
const { data: posts } = await fetchStrapi<{ data: Post[] }>('/posts', {
'populate[author][fields][0]': 'name',
'populate[cover][fields][0]': 'url',
'sort[0]': 'publishedAt:desc',
'pagination[pageSize]': '10',
});
Best Practices
- Use
uidfields for slugs — they auto-generate from a source field and enforce uniqueness - Use components for reusable field groups (SEO, social sharing, metadata)
- Use API tokens with scoped permissions instead of user JWT for server-to-server calls
- Use
populateselectively — avoidpopulate=*in production; it fetches everything - Use lifecycle hooks for computed fields, cache invalidation, and external notifications
- Keep custom controllers thin — put business logic in services
- Use the
entityServiceAPI in services for type-safe database operations - Set
draftAndPublish: truefor editorial workflows — editors can preview before publishing
Anti-Patterns
- Using
populate=*or deep populate in production — fetches all relations recursively - Modifying Strapi core
node_modules— use controllers, services, and plugins to extend - Storing API tokens in client-side code — use server-side routes or API routes as proxies
- Skipping input validation in custom controllers — use policies or middleware for auth checks
- Not setting up CORS properly — leads to blocked requests from your frontend domain
- Running Strapi with SQLite in production — use PostgreSQL or MySQL for concurrency
- Ignoring draft/publish status in custom queries — always filter by
publishedAtfor public APIs - Not backing up the database before content type schema changes — migrations can be destructive
Install this skill directly: skilldb add cms-services-skills
Related Skills
Tina
Tina is an open-source, Git-backed headless CMS that enables developers to build powerful content-editing experiences directly within their code. Use Tina when you need a local-first CMS for modern web applications, integrating content directly into your Git workflow and leveraging GraphQL for data fetching.
Builder Io
Builder.io is a Visual Headless CMS and API that empowers developers to integrate a drag-and-drop page builder and content management system into any web application. Use this skill when you need a powerful visual editor for marketing and content teams, A/B testing, personalization, and a flexible content API for dynamic web experiences.
Caisy
caisy is a headless CMS designed for speed and scalability, empowering developers to deliver exceptional digital experiences. Use caisy when you need a performant, API-first content platform with strong developer tools, a focus on speed, and a GraphQL-native approach.
Contentful
Build with Contentful for headless content management. Use this skill when the project needs Contentful integration for content models, REST or GraphQL APIs, rich text rendering, localization, webhooks, environment management, or Next.js SDK usage. Covers the full Contentful developer workflow.
Cosmic
Integrate Cosmic as your headless content management system, providing a flexible and scalable solution for managing digital content across websites, mobile apps, and other digital experiences. Use Cosmic when you need a powerful API-first CMS with robust media management, localization, and customizable content models.
Directus
Build with Directus for database-first content management. Use this skill when the project needs Directus integration for wrapping existing SQL databases with REST and GraphQL APIs, building admin dashboards, configuring flows and automations, managing roles and permissions, or connecting any SQL database as a headless CMS.