dist/plugins/api-cms-strapi/skills/api-cms-strapi/SKILL.md
Open-source headless CMS — content type schemas, REST API, Document Service API, custom controllers, lifecycle hooks, authentication, TypeScript
npx skillsauth add agents-inc/skills api-cms-strapiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Use Strapi as an open-source headless CMS with auto-generated REST/GraphQL APIs from content type schemas. In v5, use the Document Service API (
strapi.documents()) for back-end data access instead of the deprecated Entity Service. REST API responses use a flat format (data.fieldName, notdata.attributes.fieldName). Relations and media are NOT populated by default -- always passpopulate. Useqsto build complex query strings. Content types are private by default; configure permissions via the Users & Permissions plugin or API tokens.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use strapi.documents('api::content-type.content-type') (Document Service API) for all back-end data access in Strapi v5 -- the Entity Service API is removed)
(You MUST always pass populate when you need relations, media, components, or dynamic zones -- Strapi returns NO relations by default)
(You MUST use the qs library to build complex REST API query strings with filters, populate, and sort -- manual string construction breaks with nested params)
(You MUST sanitize and validate both input and output in custom controllers using this.sanitizeQuery(ctx), this.sanitizeOutput(), and this.validateQuery(ctx))
(You MUST set permissions for every content type endpoint via the admin panel or config -- all routes are private by default)
</critical_requirements>
Auto-detection: Strapi, strapi, @strapi/strapi, createCoreController, createCoreService, createCoreRouter, Document Service, strapi.documents, content-type, schema.json, api::, plugin::, lifecycle hooks, Users & Permissions, /api/auth/local, populate, qs.stringify
When to use:
schema.json) with fields, relations, components, and dynamic zonesKey patterns covered:
schema.json)qs (filters, populate, sort, pagination)findMany, findOne, create, update, delete, publish, unpublish)beforeCreate, afterUpdate, etc.)When NOT to use:
Detailed Resources:
Core & REST API:
Backend Customization:
Authentication:
Strapi is an open-source headless CMS built on Node.js (Koa) that auto-generates RESTful and GraphQL APIs from content type schemas. Content is defined via JSON schemas, managed through an admin panel, and consumed via generated API endpoints.
Core principles:
schema.json files that describe fields, relations, components, and dynamic zones. The admin panel Content-Type Builder provides a visual editor, but schemas are code that lives in your repository./api/:pluralApiId) and optional GraphQL support. No manual route/controller creation needed for standard operations.documentId (not database id) as the primary identifier.status: 'draft'; published content requires status: 'published' or explicit publish() calls.Content types are defined in schema.json files at ./src/api/[api-name]/content-types/[content-type-name]/schema.json. Use collectionType for multi-document content (articles, products) and singleType for single-document content (site settings, homepage).
{
"kind": "collectionType",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": { "draftAndPublish": true },
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title" },
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "api::author.author",
"inversedBy": "articles"
},
"blocks": {
"type": "dynamiczone",
"components": ["blocks.hero", "blocks.rich-text"]
}
}
}
Key fields: uid auto-generates slugs from targetField, relation types define cardinality with inversedBy/mappedBy, component embeds reusable blocks, dynamiczone allows mixed component types. See examples/core.md for full collection and single type examples.
qsThe REST API accepts complex query parameters for filtering, population, sorting, and pagination. Always use the qs library to build query strings.
import qs from "qs";
const query = qs.stringify(
{
filters: { publishedAt: { $notNull: true } },
populate: {
author: { fields: ["name"] },
categories: { fields: ["name", "slug"] },
},
sort: ["publishedAt:desc"],
pagination: { page: 1, pageSize: 25 },
},
{ encodeValuesOnly: true },
);
const response = await fetch(`${STRAPI_URL}/api/articles?${query}`);
const { data, meta } = await response.json();
Key points: encodeValuesOnly prevents bracket encoding issues, filters use operators ($eq, $notNull, $containsi, $or, $and), targeted populate with fields avoids over-fetching (never use populate=* in production), dynamic zone components use on syntax. See examples/core.md for full filtering, population, and pagination examples.
The Document Service API is used in custom controllers, services, lifecycle hooks, and plugins to access content from the server side. It replaces v4's Entity Service (removed in v5).
const CONTENT_TYPE_UID = "api::article.article";
// Find published content (default is 'draft')
const articles = await strapi.documents(CONTENT_TYPE_UID).findMany({
status: "published",
filters: { categories: { slug: { $eq: "news" } } },
populate: { author: true },
});
// CRUD uses documentId (not database id)
const article = await strapi
.documents(CONTENT_TYPE_UID)
.findOne({ documentId });
const created = await strapi
.documents(CONTENT_TYPE_UID)
.create({ data: { title: "New" } });
await strapi.documents(CONTENT_TYPE_UID).publish({ documentId });
Key points: strapi.documents(uid) replaces strapi.entityService (removed in v5), documentId is the persistent identifier across locales and draft/published versions, default status is 'draft' (pass status: 'published' explicitly), dedicated publish()/unpublish()/discardDraft() methods. See examples/core.md for full CRUD and publish/unpublish examples.
Extend or replace auto-generated controller actions. Controllers handle request/response logic and delegate to services. Use createCoreController from factories to inherit sanitization helpers.
// src/api/article/controllers/article.ts
export default factories.createCoreController(
"api::article.article",
({ strapi }) => ({
async find(ctx) {
await this.validateQuery(ctx);
const sanitizedQuery = await this.sanitizeQuery(ctx);
const { results, pagination } = await strapi
.service("api::article.article")
.find(sanitizedQuery);
const sanitizedResults = await this.sanitizeOutput(results, ctx);
return this.transformResponse(sanitizedResults, { pagination });
},
}),
);
Why good: validateQuery + sanitizeQuery + sanitizeOutput enforce role-based field access, transformResponse() wraps output in { data, meta } envelope
See examples/backend.md for full controller examples including custom actions (findBySlug, incrementViews, search).
Define custom routes to expose custom controller actions. Custom route files sit alongside the core router.
// src/api/article/routes/custom-article.ts
export default {
routes: [
{
method: "GET",
path: "/articles/slug/:slug",
handler: "api::article.article.findBySlug",
config: { auth: false },
},
],
};
Why good: Separate file from core router, handler uses full UID, auth: false for public access, policies/middlewares attachable per-route
See examples/backend.md for route restriction with only, middleware attachment, and policy configuration.
Register side effects on content type operations. Hooks are defined in lifecycles.ts files alongside the content type schema.
// src/api/article/content-types/article/lifecycles.ts
export default {
async beforeCreate(event) {
/* event.params.data -- mutate to transform input */
},
async afterCreate(event) {
/* event.result -- the created document */
},
async beforeUpdate(event) {
/* event.params.data -- mutate before save */
},
async afterDelete(event) {
/* event.result -- cleanup related resources */
},
};
Why good: Hooks fire automatically on Document Service operations, beforeXxx can mutate event.params.data, afterXxx has event.result
Note: For cross-cutting concerns in v5 (audit logging, cache invalidation), prefer Document Service middleware (strapi.documents.use((ctx, next) => { ... })) registered in src/index.ts. Lifecycle hooks are still available for content-type-specific side effects.
See examples/backend.md for full lifecycle examples including slug generation, programmatic subscription, and audit logging.
Services contain reusable business logic called by controllers. Use createCoreService to inherit default CRUD and add custom methods.
export default factories.createCoreService(
"api::article.article",
({ strapi }) => ({
async findPublished(filters = {}) {
return strapi
.documents("api::article.article")
.findMany({ status: "published", filters });
},
}),
);
// Called via: strapi.service("api::article.article").findPublished()
Why good: Inherits default CRUD, custom methods encapsulate reusable query logic, called from controllers via strapi.service(uid)
See examples/backend.md for complex service examples including related-article queries and publish-with-notification patterns.
Policies are read-only functions that allow or deny access to a route. They return true (allow) or false (deny) and cannot modify the request.
// src/api/article/policies/is-owner.ts
export default async (policyContext, config, { strapi }) => {
const article = await strapi.documents("api::article.article").findOne({
documentId: policyContext.params.documentId,
populate: { author: true },
});
return article?.author?.documentId === policyContext.state.user?.documentId;
};
Why good: Read-only check (no request mutation), applied per-action on core router config
See examples/backend.md for policy application to routes, rate limiting policies, and multi-policy chaining.
</patterns><decision_framework>
Where is your code running?
+-- Client-side (browser, frontend app)
| +-- Use REST API (fetch /api/:pluralApiId)
+-- Server-side (custom controller, service, plugin, lifecycle hook)
+-- Use Document Service API (strapi.documents(uid))
Do you need related data?
+-- NO --> Don't pass populate (leaner response)
+-- YES --> What level of control?
+-- All relations, 1 level --> populate: '*' (convenient but over-fetches)
+-- Specific relations --> populate: { relation: { fields: [...] } }
+-- Nested relations --> populate: { relation: { populate: { nested: true } } }
+-- Filtered relations --> populate: { relation: { filters: { ... } } }
How many documents of this type exist?
+-- Many (articles, products, users) --> collectionType
+-- One (site settings, homepage, footer) --> singleType
Do default CRUD endpoints meet your needs?
+-- YES --> Use auto-generated routes (no custom controller needed)
+-- NO --> What do you need?
+-- Modified default behavior --> Override find/findOne/create/update/delete in createCoreController
+-- Entirely new endpoint --> Add custom action + custom route
+-- Access control logic --> Add a policy to the route config
+-- Request transformation --> Add a route middleware
Does content need editorial review before going live?
+-- YES --> Enable draftAndPublish: true in schema
| +-- Document Service defaults to status: 'draft'
| +-- Use publish()/unpublish() to manage lifecycle
| +-- REST API returns published content by default
+-- NO --> Disable draftAndPublish (content is always live)
Who is consuming the API?
+-- End users (login/register) --> Users & Permissions plugin (JWT)
+-- External services/scripts --> API tokens (admin panel > Settings > API Tokens)
+-- Admin panel users --> Admin API tokens (separate from content API)
</decision_framework>
<red_flags>
High Priority Issues:
strapi.entityService in v5 -- Entity Service is removed. Use strapi.documents() (Document Service API) for all back-end data access.populate results in null/missing fields that look like data loss.populate=* in production -- Fetches all relations one level deep, including data the user may not have permission to see. Use targeted population with field selection.qs library.sanitizeQuery(), sanitizeOutput(), and validateQuery() bypasses permission checks and exposes private fields.Medium Priority Issues:
documentId with id -- In v5, documentId is the persistent identifier across locales and draft/published versions. The database id is an internal integer. REST API and Document Service use documentId.status: 'draft'. You must explicitly pass status: 'published' to get published content.publicationState parameter (v4 syntax) -- Replaced in v5 by status parameter and dedicated publish()/unpublish() methods.encodeValuesOnly: true in qs.stringify -- Without this option, qs encodes array bracket indices, which Strapi's parser may not handle correctly.Common Mistakes:
.commit() equivalent -- Document Service methods return promises. Always await them._type or _key in components -- Dynamic zone and component fields need __component identifiers when creating/updating via the API.STRAPI_URL) for the API base URL; hardcoded localhost:1337 breaks in production.page/pageSize OR start/limit, never both in the same query.Gotchas & Edge Cases:
data.attributes; v5 puts fields directly on the data object. Existing frontend code from v4 will break.uid field type stores slugs -- The uid field type auto-generates URL-safe slugs from a target field. Query slug fields directly as top-level attributes (no nested accessor needed).beforeCreateMany, afterDeleteMany, etc. are database-level hooks. Document Service operations trigger single-document hooks only.publish()) may trigger multiple database-level hooks. For cross-cutting concerns like logging, validation, and cache invalidation, prefer Document Service middleware (strapi.documents.use()) which operates at the Document Service abstraction level.populate uses on syntax -- To populate specific components in a dynamic zone, use populate: { blocks: { on: { 'blocks.hero': { populate: '*' } } } }.upload plugin and referenced via relations. They follow the same populate rules as other relations.status=draft parameter.</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use strapi.documents('api::content-type.content-type') (Document Service API) for all back-end data access in Strapi v5 -- the Entity Service API is removed)
(You MUST always pass populate when you need relations, media, components, or dynamic zones -- Strapi returns NO relations by default)
(You MUST use the qs library to build complex REST API query strings with filters, populate, and sort -- manual string construction breaks with nested params)
(You MUST sanitize and validate both input and output in custom controllers using this.sanitizeQuery(ctx), this.sanitizeOutput(), and this.validateQuery(ctx))
(You MUST set permissions for every content type endpoint via the admin panel or config -- all routes are private by default)
Failure to follow these rules will cause missing data (no populate), security vulnerabilities (no sanitization), broken queries (no qs), and 403 errors (no permissions).
</critical_reminders>
development
Material Design component library for Vue 3
development
VitePress 1.x — Vue-powered static site generator for documentation sites, built on Vite
tools
Docusaurus 3.x documentation framework — site configuration, docs/blog plugins, sidebars, versioning, MDX, swizzling, and deployment
development
TanStack Form patterns - useForm, form.Field, validators, arrays, linked fields, createFormHook, type safety