Skills api-cms-strapi

Open-source headless CMS — content type schemas, REST API, Document Service API, custom controllers, lifecycle hooks, authentication, TypeScript

install
source · Clone the upstream repo
git clone https://github.com/agents-inc/skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/agents-inc/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/src/skills/api-cms-strapi" ~/.claude/skills/agents-inc-skills-api-cms-strapi-ade323 && rm -rf "$T"
manifest: src/skills/api-cms-strapi/SKILL.md
source content

Strapi Patterns

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
, not
data.attributes.fieldName
). Relations and media are NOT populated by default -- always pass
populate
. Use
qs
to build complex query strings. Content types are private by default; configure permissions via the Users & Permissions plugin or API tokens.


<critical_requirements>

CRITICAL: Before Using This Skill

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:

  • Building content-managed applications with Strapi as the headless CMS
  • Defining content type schemas (
    schema.json
    ) with fields, relations, components, and dynamic zones
  • Querying the REST API with filters, populate, sort, and pagination
  • Creating custom controllers, services, routes, policies, or middlewares
  • Using the Document Service API for back-end CRUD with draft/publish workflows
  • Implementing JWT authentication with the Users & Permissions plugin
  • Adding lifecycle hooks to content types for side effects
  • Generating TypeScript types for content schemas

Key patterns covered:

  • Content type schema definition (
    schema.json
    )
  • REST API querying with
    qs
    (filters, populate, sort, pagination)
  • Document Service API (
    findMany
    ,
    findOne
    ,
    create
    ,
    update
    ,
    delete
    ,
    publish
    ,
    unpublish
    )
  • Custom controllers, services, routes, policies, and middlewares
  • Lifecycle hooks (
    beforeCreate
    ,
    afterUpdate
    , etc.)
  • JWT authentication (register, login, authenticated requests)
  • TypeScript type generation

When NOT to use:

  • Non-Strapi CMS platforms (use the dedicated skill for your CMS)
  • Direct database queries bypassing Strapi's API layer (use Strapi's Document Service)
  • Complex transactional logic requiring raw SQL (Strapi abstracts the database)

Detailed Resources:

  • For decision frameworks and quick-reference tables, see reference.md

Core & REST API:

  • examples/core.md -- Content type schemas, REST API querying, Document Service API, error handling

Backend Customization:

  • examples/backend.md -- Custom controllers, services, routes, policies, middlewares, lifecycle hooks, Document Service middleware

Authentication:

  • examples/auth.md -- JWT authentication, registration, login, roles and permissions

<philosophy>

Philosophy

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:

  1. Schema-driven content -- Content types are defined in
    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.
  2. Auto-generated APIs -- Every content type automatically gets CRUD REST endpoints (
    /api/:pluralApiId
    ) and optional GraphQL support. No manual route/controller creation needed for standard operations.
  3. Document Service API (v5) -- The back-end API for accessing content from custom code, plugins, and lifecycle hooks. Replaces v4's Entity Service. Uses
    documentId
    (not database
    id
    ) as the primary identifier.
  4. Draft & Publish -- Content types can have draft/publish workflows. The Document Service defaults to
    status: 'draft'
    ; published content requires
    status: 'published'
    or explicit
    publish()
    calls.
  5. Permission-first -- All content type endpoints are private by default. Access must be explicitly granted via the admin panel (Users & Permissions plugin) or API tokens.
  6. Backend customization -- Controllers, services, routes, policies, and middlewares can all be customized. Strapi follows an MVC-like pattern built on Koa.
</philosophy>
<patterns>

Core Patterns

Pattern 1: Content Type Schema

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.


Pattern 2: REST API Querying with
qs

The 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.


Pattern 3: Document Service API (Back-End)

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.


Pattern 4: Custom Controllers

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


Pattern 5: Custom Routes

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.


Pattern 6: Lifecycle Hooks

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.


Pattern 7: Services

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.


Pattern 8: Policies

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>

Decision Framework

REST API vs Document Service API

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

Population Strategy

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: { ... } } }

Content Type Kind

How many documents of this type exist?
+-- Many (articles, products, users) --> collectionType
+-- One (site settings, homepage, footer) --> singleType

Custom Controller vs Default

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

Draft & Publish

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)

Authentication Method

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>

RED FLAGS

High Priority Issues:

  • Using
    strapi.entityService
    in v5
    -- Entity Service is removed. Use
    strapi.documents()
    (Document Service API) for all back-end data access.
  • Not populating relations -- Strapi returns NO relations, media, components, or dynamic zones by default. Forgetting
    populate
    results in null/missing fields that look like data loss.
  • Using
    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.
  • Manual query string construction -- Building complex filter/populate URLs by hand breaks with special characters and nested params. Use the
    qs
    library.
  • Missing sanitization in custom controllers -- Skipping
    sanitizeQuery()
    ,
    sanitizeOutput()
    , and
    validateQuery()
    bypasses permission checks and exposes private fields.

Medium Priority Issues:

  • Confusing
    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
    .
  • Not setting permissions -- All content type endpoints are private by default. Without configuring permissions in the admin panel, API requests return 403.
  • Assuming default status is published -- Document Service API defaults to
    status: 'draft'
    . You must explicitly pass
    status: 'published'
    to get published content.
  • Using
    publicationState
    parameter (v4 syntax)
    -- Replaced in v5 by
    status
    parameter and dedicated
    publish()
    /
    unpublish()
    methods.
  • Forgetting
    encodeValuesOnly: true
    in
    qs.stringify
    -- Without this option,
    qs
    encodes array bracket indices, which Strapi's parser may not handle correctly.

Common Mistakes:

  • Not awaiting
    .commit()
    equivalent
    -- Document Service methods return promises. Always
    await
    them.
  • Missing
    _type
    or
    _key
    in components
    -- Dynamic zone and component fields need
    __component
    identifiers when creating/updating via the API.
  • Hardcoded Strapi URL -- Use environment variables (
    STRAPI_URL
    ) for the API base URL; hardcoded
    localhost:1337
    breaks in production.
  • Mixing pagination methods -- Use either
    page
    /
    pageSize
    OR
    start
    /
    limit
    , never both in the same query.

Gotchas & Edge Cases:

  • v5 response format is flat -- v4 nested data in
    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).
  • Bulk lifecycle hooks never fire from Document Service --
    beforeCreateMany
    ,
    afterDeleteMany
    , etc. are database-level hooks. Document Service operations trigger single-document hooks only.
  • Lifecycle hooks are database-level in v5 -- Lifecycle hooks fire on the database layer, so a single Document Service operation (e.g.,
    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.
  • Dynamic zone
    populate
    uses
    on
    syntax
    -- To populate specific components in a dynamic zone, use
    populate: { blocks: { on: { 'blocks.hero': { populate: '*' } } } }
    .
  • Media fields are relations internally -- Media uploads are stored in the
    upload
    plugin and referenced via relations. They follow the same populate rules as other relations.
  • Draft changes are invisible to REST API by default -- The REST API returns published content. To see drafts, you need a token with appropriate permissions and the
    status=draft
    parameter.

</red_flags>


<critical_reminders>

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>