Claude Skill

api-cms-strapi

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-cms-strapi_skills_api-cms-strapi-3a51ef5.zip · 21 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-cms-strapi/skills/api-cms-strapi
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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



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

Files (skills)
  • examples
    • auth.md 7.5 KB
      # Strapi Authentication Examples
      
      > JWT authentication, user registration, login, and authenticated requests. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for REST API patterns.
      
      **Prerequisites**: Understand REST API request/response format from core examples first.
      
      ---
      
      ## Pattern 1: User Registration
      
      ### Good Example -- Register with Error Handling
      
      ```typescript
      const STRAPI_URL = process.env.STRAPI_URL;
      
      interface AuthResponse {
        jwt: string;
        refreshToken?: string;
        user: {
          id: number;
          documentId: string;
          username: string;
          email: string;
        };
      }
      
      async function registerUser(
        username: string,
        email: string,
        password: string,
      ): Promise<AuthResponse> {
        const response = await fetch(`${STRAPI_URL}/api/auth/local/register`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ username, email, password }),
        });
      
        if (!response.ok) {
          const error = await response.json();
          throw new Error(error.error?.message || "Registration failed");
        }
      
        return response.json();
      }
      
      // Usage
      const { jwt, user } = await registerUser(
        "jane",
        "jane@example.com",
        "SecurePass123!",
      );
      // jwt: "eyJhbGciOi..." -- store securely
      // user: { id: 1, documentId: "...", username: "jane", email: "jane@example.com" }
      ```
      
      **Why good:** Registration endpoint returns JWT immediately (user is logged in after register), error response parsed for descriptive messages, typed response interface
      
      ---
      
      ## Pattern 2: User Login
      
      ### Good Example -- Login with JWT Storage
      
      ```typescript
      async function loginUser(
        identifier: string, // Email or username
        password: string,
      ): Promise<AuthResponse> {
        const response = await fetch(`${STRAPI_URL}/api/auth/local`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ identifier, password }),
        });
      
        if (!response.ok) {
          const error = await response.json();
          throw new Error(error.error?.message || "Login failed");
        }
      
        return response.json();
      }
      
      // Usage
      const { jwt, refreshToken, user } = await loginUser(
        "jane@example.com",
        "SecurePass123!",
      );
      ```
      
      **Why good:** `identifier` accepts either email or username, returns JWT and optional refresh token (depends on `jwtManagement` config), typed response
      
      ### Bad Example -- Insecure Token Storage
      
      ```typescript
      // BAD: Storing JWT in localStorage (XSS-vulnerable)
      const { jwt } = await loginUser("jane@example.com", "pass");
      localStorage.setItem("jwt", jwt); // Accessible by any script on the page
      ```
      
      **Why bad:** `localStorage` is accessible to any JavaScript on the page (XSS vulnerability), consider httpOnly cookies or secure session management for production
      
      ---
      
      ## Pattern 3: Authenticated Requests
      
      ### Good Example -- Bearer Token in Headers
      
      ```typescript
      async function fetchProtectedData<T>(path: string, token: string): Promise<T> {
        const response = await fetch(`${STRAPI_URL}${path}`, {
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
        });
      
        if (response.status === 401) {
          throw new Error("Authentication expired -- please login again");
        }
      
        if (!response.ok) {
          throw new Error(`Request failed: ${response.statusText}`);
        }
      
        return response.json();
      }
      
      // Fetch current user profile
      const me = await fetchProtectedData("/api/users/me", jwt);
      
      // Fetch protected content
      const drafts = await fetchProtectedData(`/api/articles?status=draft`, jwt);
      ```
      
      **Why good:** JWT passed in `Authorization: Bearer` header (Strapi convention), 401 handled separately for auth-specific error flow, reusable for any protected endpoint, `/api/users/me` returns the authenticated user
      
      ---
      
      ## Pattern 4: Token Refresh
      
      ### Good Example -- Refresh Token Flow
      
      ```typescript
      async function refreshAccessToken(refreshToken: string): Promise<AuthResponse> {
        const response = await fetch(`${STRAPI_URL}/api/auth/refresh`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ refreshToken }),
        });
      
        if (!response.ok) {
          throw new Error("Token refresh failed -- please login again");
        }
      
        return response.json();
      }
      
      // Usage: Auto-refresh on 401
      async function fetchWithRefresh<T>(
        path: string,
        accessToken: string,
        refreshToken: string,
      ): Promise<{ data: T; newTokens?: { jwt: string; refreshToken: string } }> {
        try {
          const data = await fetchProtectedData<T>(path, accessToken);
          return { data };
        } catch (error) {
          if (
            error instanceof Error &&
            error.message.includes("Authentication expired")
          ) {
            // Attempt refresh
            const newAuth = await refreshAccessToken(refreshToken);
            const data = await fetchProtectedData<T>(path, newAuth.jwt);
            return {
              data,
              newTokens: { jwt: newAuth.jwt, refreshToken: newAuth.refreshToken! },
            };
          }
          throw error;
        }
      }
      ```
      
      **Why good:** Refresh flow only triggers on 401 (expired token), returns new tokens to the caller for storage, falls through to re-throw for non-auth errors
      
      **Note:** Token refresh requires `jwtManagement: 'refresh'` in the Users & Permissions plugin config. The default `'legacy-support'` mode does not support refresh tokens.
      
      ---
      
      ## Pattern 5: API Token Authentication (Server-to-Server)
      
      ### Good Example -- Using API Tokens
      
      ```typescript
      // For server-to-server or script access (not end-user auth)
      const API_TOKEN = process.env.STRAPI_API_TOKEN;
      
      async function fetchWithApiToken<T>(path: string): Promise<T> {
        const response = await fetch(`${STRAPI_URL}${path}`, {
          headers: {
            Authorization: `Bearer ${API_TOKEN}`,
          },
        });
      
        if (!response.ok) {
          throw new Error(`API request failed: ${response.statusText}`);
        }
      
        return response.json();
      }
      ```
      
      **Why good:** API tokens are created in the admin panel (Settings > API Tokens), suitable for server-to-server communication, CI/CD scripts, and static site generation where user login is inappropriate
      
      **API Token types:**
      
      - **Read-only** -- Can only `find` and `findOne`
      - **Full access** -- All CRUD operations on all content types
      - **Custom** -- Fine-grained per-content-type, per-action permissions
      
      ---
      
      ## Pattern 6: Permissions Configuration
      
      ### Configuring Public Access
      
      Permissions are set in the Strapi admin panel under **Settings > Users & Permissions plugin > Roles**.
      
      ```
      Admin Panel > Settings > Users & Permissions > Roles > Public
        - Application
          - Article
            - [x] find       (GET /api/articles)
            - [x] findOne    (GET /api/articles/:documentId)
            - [ ] create     (POST /api/articles)
            - [ ] update     (PUT /api/articles/:documentId)
            - [ ] delete     (DELETE /api/articles/:documentId)
      ```
      
      ### Configuring Authenticated Access
      
      ```
      Admin Panel > Settings > Users & Permissions > Roles > Authenticated
        - Application
          - Article
            - [x] find
            - [x] findOne
            - [x] create
            - [x] update
            - [x] delete
      ```
      
      ### JWT Configuration
      
      ```typescript
      // config/plugins.ts
      export default ({ env }) => ({
        "users-permissions": {
          config: {
            jwt: {
              expiresIn: "7d", // Access token expiration
            },
            jwtManagement: "refresh", // Enable refresh tokens
            // or 'legacy-support' for simple JWT (no refresh)
          },
        },
      });
      ```
      
      **Why good:** JWT expiration configured via plugin config (not hardcoded), `jwtManagement: 'refresh'` enables the modern refresh token flow, permissions managed declaratively in the admin panel
      
      ---
      
      _For REST API patterns, see [core.md](core.md). For backend customization, see [backend.md](backend.md)._
      
    • backend.md 13.7 KB
      # Strapi Backend Customization Examples
      
      > Custom controllers, services, routes, policies, middlewares, and lifecycle hooks. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for REST API and Document Service patterns.
      
      **Prerequisites**: Understand the Document Service API from core examples first.
      
      ---
      
      ## Pattern 1: Custom Controller with Sanitization
      
      ### Good Example -- Extending Core Controller
      
      ```typescript
      // src/api/article/controllers/article.ts
      import { factories } from "@strapi/strapi";
      
      export default factories.createCoreController(
        "api::article.article",
        ({ strapi }) => ({
          // Override default find with additional logic
          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 });
          },
      
          // Custom action: find article by slug
          async findBySlug(ctx) {
            const { slug } = ctx.params;
      
            const article = await strapi.documents("api::article.article").findFirst({
              filters: { slug: { $eq: slug } },
              status: "published",
              populate: { author: true, categories: true, cover: true },
            });
      
            if (!article) {
              return ctx.notFound("Article not found");
            }
      
            const sanitized = await this.sanitizeOutput(article, ctx);
            return this.transformResponse(sanitized);
          },
      
          // Custom action: increment view count
          async incrementViews(ctx) {
            const { documentId } = ctx.params;
            const CONTENT_TYPE_UID = "api::article.article";
      
            const article = await strapi.documents(CONTENT_TYPE_UID).findOne({
              documentId,
              fields: ["viewCount"],
            });
      
            if (!article) {
              return ctx.notFound("Article not found");
            }
      
            const updated = await strapi.documents(CONTENT_TYPE_UID).update({
              documentId,
              data: { viewCount: (article.viewCount || 0) + 1 },
            });
      
            const sanitized = await this.sanitizeOutput(updated, ctx);
            return this.transformResponse(sanitized);
          },
        }),
      );
      ```
      
      **Why good:** `validateQuery` throws on invalid query params, `sanitizeQuery` strips fields the user's role can't access, `sanitizeOutput` removes private fields from the response, `transformResponse` wraps output in the standard `{ data, meta }` envelope, `ctx.notFound()` returns proper 404
      
      ### Bad Example -- No Sanitization
      
      ```typescript
      // BAD: Bypasses all permission checks
      async find(ctx) {
        const articles = await strapi.documents("api::article.article").findMany({
          populate: "*",
        });
        return { data: articles }; // Exposes private fields, no permission enforcement
      }
      ```
      
      **Why bad:** Skips `sanitizeQuery`, `sanitizeOutput`, and `validateQuery`, which means private fields are exposed and role-based field restrictions are bypassed
      
      ---
      
      ## Pattern 2: Custom Controller for Non-CRUD Operations
      
      ### Good Example -- Custom Business Logic Controller
      
      ```typescript
      // src/api/article/controllers/article.ts (continued)
      import { factories } from "@strapi/strapi";
      
      export default factories.createCoreController(
        "api::article.article",
        ({ strapi }) => ({
          // Custom: Search across multiple fields
          async search(ctx) {
            const { query: searchTerm } = ctx.request.query;
      
            if (!searchTerm || typeof searchTerm !== "string") {
              return ctx.badRequest("Query parameter 'query' is required");
            }
      
            const articles = await strapi.documents("api::article.article").findMany({
              status: "published",
              filters: {
                $or: [
                  { title: { $containsi: searchTerm } },
                  { body: { $containsi: searchTerm } },
                ],
              },
              populate: { author: { fields: ["name"] } },
              sort: [{ publishedAt: "desc" }],
              pagination: { page: 1, pageSize: 20 },
            });
      
            const sanitized = await this.sanitizeOutput(articles, ctx);
            return this.transformResponse(sanitized);
          },
        }),
      );
      ```
      
      **Why good:** Validates input before querying, `$containsi` for case-insensitive search, `$or` searches across multiple fields, pagination prevents unbounded results, output sanitized
      
      ---
      
      ## Pattern 3: Custom Routes
      
      ### Good Example -- Registering Custom Routes
      
      ```typescript
      // src/api/article/routes/custom-article.ts
      export default {
        routes: [
          {
            method: "GET",
            path: "/articles/slug/:slug",
            handler: "api::article.article.findBySlug",
            config: {
              auth: false, // Public route
            },
          },
          {
            method: "POST",
            path: "/articles/:documentId/views",
            handler: "api::article.article.incrementViews",
            config: {
              auth: false, // Public (no login needed to increment views)
            },
          },
          {
            method: "GET",
            path: "/articles/search",
            handler: "api::article.article.search",
            config: {
              auth: false,
              policies: [],
              middlewares: [],
            },
          },
        ],
      };
      ```
      
      ### Good Example -- Restricting Core Routes
      
      ```typescript
      // src/api/article/routes/article.ts
      import { factories } from "@strapi/strapi";
      
      export default factories.createCoreRouter("api::article.article", {
        // Only expose find and findOne (disable create/update/delete via REST)
        only: ["find", "findOne"],
        config: {
          find: {
            auth: false, // Public listing
            middlewares: ["api::article.populate-defaults"],
          },
          findOne: {
            auth: false, // Public detail
          },
        },
      });
      ```
      
      **Why good:** Core router with `only` limits exposed endpoints (disabling write operations via REST), per-action config applies middlewares and auth settings, custom routes in a separate file to avoid conflicts
      
      ---
      
      ## Pattern 4: Route Middleware
      
      ### Good Example -- Default Population Middleware
      
      ```typescript
      // src/api/article/middlewares/populate-defaults.ts
      export default (config, { strapi }) => {
        return async (ctx, next) => {
          // Set default population if none provided
          if (!ctx.query.populate) {
            ctx.query.populate = {
              author: { fields: ["name"] },
              categories: { fields: ["name", "slug"] },
              cover: { fields: ["url", "alternativeText"] },
            };
          }
      
          await next();
        };
      };
      ```
      
      **Why good:** Middleware sets sensible default population so consumers don't need to specify it every time, only applies when no populate is explicitly provided, passes through to the next middleware/controller via `await next()`
      
      ---
      
      ## Pattern 5: Policies
      
      ### Good Example -- Owner-Only Policy
      
      ```typescript
      // src/api/article/policies/is-owner.ts
      export default async (policyContext, config, { strapi }) => {
        const user = policyContext.state.user;
      
        if (!user) {
          return false; // Not authenticated
        }
      
        const { documentId } = policyContext.params;
      
        const article = await strapi.documents("api::article.article").findOne({
          documentId,
          populate: { author: true },
        });
      
        if (!article) {
          return false; // Document not found
        }
      
        // Only allow if the authenticated user is the author
        return article.author?.documentId === user.documentId;
      };
      ```
      
      ### Good Example -- Rate Limit Policy
      
      ```typescript
      // src/policies/rate-limit.ts
      const MAX_REQUESTS_PER_MINUTE = 60;
      const requestCounts = new Map<string, { count: number; resetAt: number }>();
      
      export default async (policyContext, config) => {
        const ip = policyContext.request.ip;
        const now = Date.now();
        const limit = config?.limit || MAX_REQUESTS_PER_MINUTE;
        const windowMs = config?.windowMs || 60_000;
      
        const entry = requestCounts.get(ip);
      
        if (!entry || now > entry.resetAt) {
          requestCounts.set(ip, { count: 1, resetAt: now + windowMs });
          return true;
        }
      
        if (entry.count >= limit) {
          return false; // Rate limited
        }
      
        entry.count += 1;
        return true;
      };
      ```
      
      ### Applying Policies to Routes
      
      ```typescript
      // In core router config
      export default factories.createCoreRouter("api::article.article", {
        config: {
          update: {
            policies: ["api::article.is-owner"],
          },
          delete: {
            policies: ["api::article.is-owner"],
          },
        },
      });
      
      // In custom routes
      {
        method: "POST",
        path: "/articles/:documentId/publish",
        handler: "api::article.article.publishArticle",
        config: {
          policies: [
            "api::article.is-owner",
            { name: "global::rate-limit", config: { limit: 10 } },
          ],
        },
      }
      ```
      
      **Why good:** Policies return `true`/`false` (read-only, cannot modify request), inline config passed to configurable policies, multiple policies chain together (all must pass)
      
      ---
      
      ## Pattern 6: Lifecycle Hooks
      
      ### Good Example -- Content Type Lifecycle
      
      ```typescript
      // src/api/article/content-types/article/lifecycles.ts
      export default {
        async beforeCreate(event) {
          const { data } = event.params;
      
          // Auto-generate slug if not provided
          if (data.title && !data.slug) {
            data.slug = data.title
              .toLowerCase()
              .replace(/[^a-z0-9]+/g, "-")
              .replace(/^-|-$/g, "");
          }
        },
      
        async afterCreate(event) {
          const { result } = event;
          strapi.log.info(
            `Article created: ${result.documentId} - "${result.title}"`,
          );
        },
      
        async beforeUpdate(event) {
          const { data } = event.params;
      
          // Trim title whitespace
          if (data.title) {
            data.title = data.title.trim();
          }
        },
      
        async afterDelete(event) {
          const { result } = event;
          strapi.log.info(`Article deleted: ${result.documentId}`);
          // Cleanup: remove from search index, invalidate cache, etc.
        },
      };
      ```
      
      **Why good:** `beforeCreate` can mutate `event.params.data` to transform input, `afterCreate` has access to `event.result` (the created document), lifecycle hooks run for both REST API and Document Service operations, logging for audit trail
      
      ### Good Example -- Programmatic Lifecycle Subscription
      
      ```typescript
      // src/index.ts -- Register in bootstrap
      export default {
        async bootstrap({ strapi }) {
          strapi.db.lifecycles.subscribe({
            models: ["api::article.article"],
      
            async afterCreate(event) {
              const { result } = event;
              // Send notification, update analytics, etc.
            },
          });
        },
      };
      ```
      
      **Why good:** Programmatic subscription allows subscribing to multiple content types from a single location, useful for cross-cutting concerns like audit logging or search indexing
      
      ---
      
      ## Pattern 7: Custom Services
      
      ### Good Example -- Service with Complex Business Logic
      
      ```typescript
      // src/api/article/services/article.ts
      import { factories } from "@strapi/strapi";
      
      const CONTENT_TYPE_UID = "api::article.article";
      const MAX_RELATED_ARTICLES = 5;
      
      export default factories.createCoreService(CONTENT_TYPE_UID, ({ strapi }) => ({
        async findRelated(documentId: string) {
          const article = await strapi.documents(CONTENT_TYPE_UID).findOne({
            documentId,
            populate: { categories: true },
          });
      
          if (!article?.categories?.length) {
            return [];
          }
      
          const categoryIds = article.categories.map((c) => c.documentId);
      
          return strapi.documents(CONTENT_TYPE_UID).findMany({
            status: "published",
            filters: {
              documentId: { $ne: documentId }, // Exclude current article
              categories: { documentId: { $in: categoryIds } },
            },
            sort: [{ publishedAt: "desc" }],
            pagination: { page: 1, pageSize: MAX_RELATED_ARTICLES },
            populate: { cover: { fields: ["url", "alternativeText"] } },
          });
        },
      
        async publishAndNotify(documentId: string) {
          const result = await strapi.documents(CONTENT_TYPE_UID).publish({
            documentId,
          });
      
          // Trigger side effects after publishing
          const article = await strapi.documents(CONTENT_TYPE_UID).findOne({
            documentId,
            status: "published",
            populate: { author: true },
          });
      
          strapi.log.info(`Article published: ${article?.title}`);
      
          return result;
        },
      }));
      ```
      
      **Why good:** Service encapsulates reusable business logic, named constant for limit, `$ne` filter excludes the current document, `$in` matches any of the category IDs, called from controllers via `strapi.service('api::article.article').findRelated(id)`
      
      ---
      
      ## Pattern 8: Document Service Middleware
      
      Document Service middleware is the recommended v5 approach for intercepting content operations. It provides more predictable behavior than database lifecycle hooks, especially with draft/publish workflows.
      
      ### Good Example -- Audit Logging Middleware
      
      ```typescript
      // src/index.ts
      export default {
        register({ strapi }) {
          strapi.documents.use(async (ctx, next) => {
            const result = await next();
      
            if (
              ["create", "update", "delete", "publish", "unpublish"].includes(
                ctx.action,
              )
            ) {
              strapi.log.info(
                `[audit] ${ctx.action} on ${ctx.uid} by user ${ctx.params?.data?.updatedBy || "system"}`,
              );
            }
      
            return result;
          });
        },
      };
      ```
      
      **Why good:** Registered in `register()` (not `bootstrap()`), intercepts all Document Service operations, logs after the operation completes (uses `await next()`), filters by action type to avoid noisy find/count logs
      
      ### Good Example -- Default Populate Middleware
      
      ```typescript
      // src/index.ts
      export default {
        register({ strapi }) {
          strapi.documents.use(async (ctx, next) => {
            if (ctx.uid === "api::article.article" && ctx.action === "findMany") {
              ctx.params = {
                ...ctx.params,
                populate: ctx.params?.populate ?? { author: true, categories: true },
              };
            }
      
            return next();
          });
        },
      };
      ```
      
      **Why good:** Modifies params before the operation (`next()` called after), only applies to a specific content type and action, preserves explicitly-set populate params via nullish coalescing
      
      ---
      
      _For REST API and Document Service patterns, see [core.md](core.md). For authentication, see [auth.md](auth.md)._
      
    • core.md 13.4 KB
      # Strapi Core Examples
      
      > Content type schemas, REST API querying, Document Service API, and error handling. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Backend customization:** See [backend.md](backend.md). **Authentication:** See [auth.md](auth.md).
      
      ---
      
      ## Pattern 1: Content Type Schemas
      
      ### Good Example -- Collection Type
      
      ```json
      {
        "kind": "collectionType",
        "collectionName": "articles",
        "info": {
          "singularName": "article",
          "pluralName": "articles",
          "displayName": "Article",
          "description": "Blog articles"
        },
        "options": {
          "draftAndPublish": true
        },
        "attributes": {
          "title": {
            "type": "string",
            "required": true,
            "maxLength": 120
          },
          "slug": {
            "type": "uid",
            "targetField": "title"
          },
          "body": {
            "type": "richtext"
          },
          "cover": {
            "type": "media",
            "allowedTypes": ["images"],
            "multiple": false
          },
          "author": {
            "type": "relation",
            "relation": "manyToOne",
            "target": "api::author.author",
            "inversedBy": "articles"
          },
          "categories": {
            "type": "relation",
            "relation": "manyToMany",
            "target": "api::category.category",
            "inversedBy": "articles"
          },
          "seo": {
            "type": "component",
            "component": "shared.seo",
            "required": false
          },
          "blocks": {
            "type": "dynamiczone",
            "components": ["blocks.hero", "blocks.rich-text", "blocks.gallery"]
          }
        }
      }
      ```
      
      **Why good:** `draftAndPublish: true` enables draft/publish workflow, `uid` auto-generates slugs from `targetField`, `media` restricts to images, relation types define cardinality with `inversedBy`, component and dynamic zone fields compose reusable blocks
      
      ### Good Example -- Single Type
      
      ```json
      {
        "kind": "singleType",
        "collectionName": "site_settings",
        "info": {
          "singularName": "site-setting",
          "pluralName": "site-settings",
          "displayName": "Site Settings"
        },
        "attributes": {
          "siteName": {
            "type": "string",
            "required": true
          },
          "logo": {
            "type": "media",
            "allowedTypes": ["images"]
          },
          "defaultSeo": {
            "type": "component",
            "component": "shared.seo"
          }
        }
      }
      ```
      
      **Why good:** `singleType` creates a single-document endpoint (`GET /api/site-setting`) for global config like site settings, navigation, or footer content
      
      ---
      
      ## Pattern 2: REST API -- Fetching with qs
      
      ### Good Example -- Typed Fetch with Filters, Populate, and Pagination
      
      ```typescript
      import qs from "qs";
      
      const STRAPI_URL = process.env.STRAPI_URL;
      const DEFAULT_PAGE_SIZE = 25;
      
      interface StrapiResponse<T> {
        data: T;
        meta: {
          pagination?: {
            page: number;
            pageSize: number;
            pageCount: number;
            total: number;
          };
        };
      }
      
      interface Article {
        id: number;
        documentId: string;
        title: string;
        slug: string;
        publishedAt: string | null;
        author?: { id: number; documentId: string; name: string };
        categories?: Array<{
          id: number;
          documentId: string;
          name: string;
          slug: string;
        }>;
        cover?: {
          url: string;
          alternativeText: string;
          width: number;
          height: number;
        };
      }
      
      async function fetchArticles(
        page = 1,
        pageSize = DEFAULT_PAGE_SIZE,
      ): Promise<StrapiResponse<Article[]>> {
        const query = qs.stringify(
          {
            filters: {
              publishedAt: { $notNull: true },
            },
            populate: {
              author: { fields: ["name"] },
              categories: { fields: ["name", "slug"] },
              cover: { fields: ["url", "alternativeText", "width", "height"] },
            },
            sort: ["publishedAt:desc"],
            pagination: { page, pageSize },
          },
          { encodeValuesOnly: true },
        );
      
        const response = await fetch(`${STRAPI_URL}/api/articles?${query}`);
      
        if (!response.ok) {
          throw new Error(
            `Failed to fetch articles: ${response.status} ${response.statusText}`,
          );
        }
      
        return response.json();
      }
      ```
      
      **Why good:** `qs.stringify` with `encodeValuesOnly` handles nested params correctly, targeted populate with field selection avoids over-fetching, named constant for page size, typed response interface matches v5 flat format, error handling for non-OK responses
      
      ### Bad Example -- Manual Query String
      
      ```typescript
      // BAD: Manual query string construction
      const url = `/api/articles?populate=*&filters[title][$contains]=${userInput}`;
      const data = await fetch(url).then((r) => r.json());
      ```
      
      **Why bad:** `populate=*` over-fetches all relations (security risk), user input not encoded (XSS/injection), no error handling, missing pagination leads to unbounded result sets
      
      ---
      
      ## Pattern 3: REST API -- Complex Filters
      
      ### Good Example -- Logical Operators with qs
      
      ```typescript
      // Find articles in "tech" OR "science" categories, published after a date
      const MIN_DATE = "2025-01-01";
      
      const query = qs.stringify(
        {
          filters: {
            $and: [
              { publishedAt: { $gte: MIN_DATE } },
              {
                $or: [
                  { categories: { slug: { $eq: "technology" } } },
                  { categories: { slug: { $eq: "science" } } },
                ],
              },
            ],
          },
          populate: {
            categories: { fields: ["name", "slug"] },
          },
          sort: ["publishedAt:desc"],
          pagination: { page: 1, pageSize: 10 },
        },
        { encodeValuesOnly: true },
      );
      ```
      
      **Why good:** `$and` and `$or` compose complex queries, relation fields can be filtered directly, named constant for the date threshold, pagination prevents unbounded results
      
      ---
      
      ## Pattern 4: REST API -- Fetching Single Document by Slug
      
      ### Good Example -- Find by Slug with Deep Population
      
      ```typescript
      async function fetchArticleBySlug(slug: string): Promise<Article | null> {
        const query = qs.stringify(
          {
            filters: { slug: { $eq: slug } },
            populate: {
              author: { fields: ["name", "email"] },
              categories: { fields: ["name", "slug"] },
              cover: { fields: ["url", "alternativeText", "width", "height"] },
              seo: { populate: "*" }, // Component population
              blocks: {
                on: {
                  "blocks.hero": { populate: { background: { fields: ["url"] } } },
                  "blocks.rich-text": { populate: "*" },
                  "blocks.gallery": {
                    populate: { images: { fields: ["url", "alternativeText"] } },
                  },
                },
              },
            },
          },
          { encodeValuesOnly: true },
        );
      
        const response = await fetch(`${STRAPI_URL}/api/articles?${query}`);
      
        if (!response.ok) {
          throw new Error(`Failed to fetch article: ${response.status}`);
        }
      
        const { data } = await response.json();
        return data.length > 0 ? data[0] : null;
      }
      ```
      
      **Why good:** Filters by slug to get a specific article, component populated via nested populate, dynamic zone uses `on` syntax to populate per-component type, returns `null` when not found instead of crashing, separate field selection per relation
      
      ---
      
      ## Pattern 5: REST API -- Creating and Updating
      
      ### Good Example -- Create a Document
      
      ```typescript
      async function createArticle(
        articleData: { title: string; body: string; slug: string },
        token: string,
      ): Promise<Article> {
        const response = await fetch(`${STRAPI_URL}/api/articles`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify({ data: articleData }),
        });
      
        if (!response.ok) {
          const error = await response.json();
          throw new Error(
            `Failed to create article: ${error.error?.message || response.statusText}`,
          );
        }
      
        const { data } = await response.json();
        return data;
      }
      ```
      
      ### Good Example -- Update a Document
      
      ```typescript
      async function updateArticle(
        documentId: string,
        updates: Partial<{ title: string; body: string }>,
        token: string,
      ): Promise<Article> {
        const response = await fetch(`${STRAPI_URL}/api/articles/${documentId}`, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify({ data: updates }),
        });
      
        if (!response.ok) {
          const error = await response.json();
          throw new Error(
            `Failed to update article: ${error.error?.message || response.statusText}`,
          );
        }
      
        const { data } = await response.json();
        return data;
      }
      ```
      
      **Why good:** Request body wraps fields in `data` (required by Strapi), `Authorization: Bearer` header for JWT, error response parsed for descriptive error messages, `PUT` for updates uses `documentId` in the URL path
      
      ### Bad Example -- Missing Data Wrapper
      
      ```typescript
      // BAD: Fields not wrapped in data object
      await fetch(`${STRAPI_URL}/api/articles`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: "New Article", body: "Content..." }),
        // Missing: { data: { title: "...", body: "..." } }
      });
      ```
      
      **Why bad:** Strapi expects request body in `{ data: { ...fields } }` format, sending fields at the top level results in empty document creation
      
      ---
      
      ## Pattern 6: REST API -- Delete and Media Upload
      
      ### Good Example -- Delete Document
      
      ```typescript
      async function deleteArticle(documentId: string, token: string): Promise<void> {
        const response = await fetch(`${STRAPI_URL}/api/articles/${documentId}`, {
          method: "DELETE",
          headers: { Authorization: `Bearer ${token}` },
        });
      
        if (!response.ok && response.status !== 204) {
          throw new Error(`Failed to delete article: ${response.statusText}`);
        }
      }
      ```
      
      ### Good Example -- Upload Media
      
      ```typescript
      async function uploadMedia(file: File, token: string) {
        const formData = new FormData();
        formData.append("files", file);
      
        const response = await fetch(`${STRAPI_URL}/api/upload`, {
          method: "POST",
          headers: { Authorization: `Bearer ${token}` },
          // No Content-Type header -- FormData sets it with boundary
          body: formData,
        });
      
        if (!response.ok) {
          throw new Error(`Failed to upload: ${response.statusText}`);
        }
      
        const uploadedFiles = await response.json();
        return uploadedFiles[0]; // Returns array of uploaded files
      }
      
      // Link uploaded media to a document
      async function setArticleCover(
        documentId: string,
        mediaId: number,
        token: string,
      ) {
        await fetch(`${STRAPI_URL}/api/articles/${documentId}`, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify({ data: { cover: mediaId } }),
        });
      }
      ```
      
      **Why good:** DELETE returns 204 with no body (handle accordingly), media upload uses FormData without explicit Content-Type header, uploaded file ID used to link media to a content field
      
      ---
      
      ## Pattern 7: Document Service API -- Server-Side CRUD
      
      ### Good Example -- Service with Document Service API
      
      ```typescript
      // src/api/article/services/article.ts
      import { factories } from "@strapi/strapi";
      
      const CONTENT_TYPE_UID = "api::article.article";
      
      export default factories.createCoreService(CONTENT_TYPE_UID, ({ strapi }) => ({
        async findPublished(filters = {}) {
          return strapi.documents(CONTENT_TYPE_UID).findMany({
            status: "published",
            filters,
            populate: { author: true, categories: true },
            sort: [{ publishedAt: "desc" }],
          });
        },
      
        async findBySlug(slug: string) {
          const article = await strapi.documents(CONTENT_TYPE_UID).findFirst({
            filters: { slug: { $eq: slug } },
            status: "published",
            populate: { author: true, categories: true, cover: true },
          });
      
          return article; // null if not found
        },
      
        async publishArticle(documentId: string) {
          return strapi.documents(CONTENT_TYPE_UID).publish({ documentId });
        },
      }));
      ```
      
      **Why good:** `createCoreService` inherits default CRUD methods, custom methods use Document Service for type-safe access, `status: 'published'` explicitly requests published content, `findFirst()` returns single document or null
      
      ### Bad Example -- Using Entity Service (v4 API)
      
      ```typescript
      // BAD: Entity Service is removed in Strapi v5
      const articles = await strapi.entityService.findMany("api::article.article", {
        filters: { publishedAt: { $notNull: true } },
        populate: { author: true },
      });
      ```
      
      **Why bad:** `strapi.entityService` does not exist in Strapi v5, use `strapi.documents()` instead
      
      ---
      
      ## Pattern 8: Error Handling
      
      ### Good Example -- Consistent Error Handling for REST API
      
      ```typescript
      class StrapiError extends Error {
        status: number;
        details: unknown;
      
        constructor(message: string, status: number, details?: unknown) {
          super(message);
          this.name = "StrapiError";
          this.status = status;
          this.details = details;
        }
      }
      
      async function strapiRequest<T>(
        path: string,
        options: RequestInit = {},
      ): Promise<StrapiResponse<T>> {
        const url = `${STRAPI_URL}${path}`;
        const response = await fetch(url, {
          ...options,
          headers: {
            "Content-Type": "application/json",
            ...options.headers,
          },
        });
      
        if (!response.ok) {
          const errorBody = await response.json().catch(() => null);
          throw new StrapiError(
            errorBody?.error?.message || `Request failed: ${response.statusText}`,
            response.status,
            errorBody?.error?.details,
          );
        }
      
        // DELETE returns 204 with no body
        if (response.status === 204) {
          return { data: null as T, meta: {} };
        }
      
        return response.json();
      }
      ```
      
      **Why good:** Custom error class preserves HTTP status and Strapi error details, handles 204 (no body) from DELETE operations, reusable for all REST API calls, Strapi error format (`error.message`, `error.details`) extracted
      
      ---
      
      _For backend customization patterns, see [backend.md](backend.md). For authentication, see [auth.md](auth.md)._
      
  • reference.md 12.7 KB
    # Strapi Reference
    
    > CLI commands, REST API operators, filter cheat sheet, and quick-lookup tables. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples.
    
    ---
    
    ## Strapi CLI Commands
    
    ### Project Setup
    
    ```bash
    # Create a new Strapi project
    npx create-strapi-app@latest my-project
    
    # Create with TypeScript (default in v5)
    npx create-strapi-app@latest my-project --typescript
    
    # Start development server (default: http://localhost:1337)
    npm run develop
    
    # Start production server
    npm run start
    
    # Build admin panel
    npm run build
    ```
    
    ### Type Generation
    
    ```bash
    # Generate TypeScript types from content schemas
    npm run strapi ts:generate-types
    
    # With debug output
    npm run strapi ts:generate-types --debug
    ```
    
    ### Content Management
    
    ```bash
    # Interactive generator (select api, controller, service, content-type, policy, middleware, or migration)
    npm run strapi generate
    ```
    
    ---
    
    ## Environment Variables
    
    ```bash
    # .env
    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
    TRANSFER_TOKEN_SALT=your-transfer-token-salt
    JWT_SECRET=your-jwt-secret
    
    # Database (default: SQLite)
    DATABASE_CLIENT=postgres
    DATABASE_HOST=127.0.0.1
    DATABASE_PORT=5432
    DATABASE_NAME=strapi
    DATABASE_USERNAME=strapi
    DATABASE_PASSWORD=strapi
    DATABASE_SSL=false
    ```
    
    ---
    
    ## REST API Endpoints
    
    ### Collection Types
    
    | Method | URL                             | Purpose          |
    | ------ | ------------------------------- | ---------------- |
    | GET    | `/api/:pluralApiId`             | List documents   |
    | POST   | `/api/:pluralApiId`             | Create document  |
    | GET    | `/api/:pluralApiId/:documentId` | Get one document |
    | PUT    | `/api/:pluralApiId/:documentId` | Update document  |
    | DELETE | `/api/:pluralApiId/:documentId` | Delete document  |
    
    ### Single Types
    
    | Method | URL                   | Purpose             |
    | ------ | --------------------- | ------------------- |
    | GET    | `/api/:singularApiId` | Get the document    |
    | PUT    | `/api/:singularApiId` | Update the document |
    | DELETE | `/api/:singularApiId` | Delete the document |
    
    ### Authentication (Users & Permissions)
    
    | Method | URL                            | Purpose                |
    | ------ | ------------------------------ | ---------------------- |
    | POST   | `/api/auth/local`              | Login (JWT)            |
    | POST   | `/api/auth/local/register`     | Register user          |
    | POST   | `/api/auth/forgot-password`    | Request password reset |
    | POST   | `/api/auth/reset-password`     | Reset with token       |
    | GET    | `/api/auth/email-confirmation` | Confirm email          |
    | POST   | `/api/auth/refresh`            | Refresh JWT \*         |
    | POST   | `/api/auth/logout`             | Revoke session \*      |
    | GET    | `/api/users/me`                | Get current user       |
    
    \* Requires `jwtManagement: 'refresh'` in Users & Permissions plugin config (session management mode).
    
    ### Media Upload
    
    | Method | URL                     | Purpose        |
    | ------ | ----------------------- | -------------- |
    | POST   | `/api/upload`           | Upload file(s) |
    | GET    | `/api/upload/files`     | List files     |
    | GET    | `/api/upload/files/:id` | Get file       |
    | DELETE | `/api/upload/files/:id` | Delete file    |
    
    ---
    
    ## REST API Response Format (v5)
    
    ```json
    {
      "data": {
        "id": 1,
        "documentId": "a1b2c3d4e5f6g7h8i9j0klm",
        "title": "Article Title",
        "slug": "article-title",
        "publishedAt": "2025-01-15T10:00:00.000Z",
        "createdAt": "2025-01-14T08:00:00.000Z",
        "updatedAt": "2025-01-15T10:00:00.000Z"
      },
      "meta": {}
    }
    ```
    
    **v5 change:** Fields are at the top level of `data`, NOT nested under `data.attributes` like in v4.
    
    ---
    
    ## Filter Operators
    
    ### Comparison
    
    | Operator | Description              | Example                      |
    | -------- | ------------------------ | ---------------------------- |
    | `$eq`    | Equal                    | `filters[title][$eq]=Hello`  |
    | `$eqi`   | Equal (case-insensitive) | `filters[title][$eqi]=hello` |
    | `$ne`    | Not equal                | `filters[title][$ne]=Hello`  |
    | `$nei`   | Not equal (case-insens.) | `filters[title][$nei]=hello` |
    | `$lt`    | Less than                | `filters[rating][$lt]=5`     |
    | `$lte`   | Less than or equal       | `filters[rating][$lte]=5`    |
    | `$gt`    | Greater than             | `filters[rating][$gt]=3`     |
    | `$gte`   | Greater than or equal    | `filters[rating][$gte]=3`    |
    
    ### String
    
    | Operator        | Description                  | Example                               |
    | --------------- | ---------------------------- | ------------------------------------- |
    | `$contains`     | Contains substring           | `filters[title][$contains]=strapi`    |
    | `$notContains`  | Does not contain             | `filters[title][$notContains]=draft`  |
    | `$containsi`    | Contains (case-insensitive)  | `filters[title][$containsi]=Strapi`   |
    | `$notContainsi` | Not contains (case-insens.)  | `filters[title][$notContainsi]=Draft` |
    | `$startsWith`   | Starts with                  | `filters[title][$startsWith]=How`     |
    | `$startsWithi`  | Starts with (case-insens.)   | `filters[title][$startsWithi]=how`    |
    | `$endsWith`     | Ends with                    | `filters[slug][$endsWith]=-guide`     |
    | `$endsWithi`    | Ends with (case-insensitive) | `filters[slug][$endsWithi]=-Guide`    |
    
    ### Array and Null
    
    | Operator   | Description   | Example                                          |
    | ---------- | ------------- | ------------------------------------------------ |
    | `$in`      | In array      | `filters[status][$in][0]=draft&...[1]=published` |
    | `$notIn`   | Not in array  | `filters[status][$notIn][0]=archived`            |
    | `$null`    | Is null       | `filters[publishedAt][$null]=true`               |
    | `$notNull` | Is not null   | `filters[publishedAt][$notNull]=true`            |
    | `$between` | Between range | `filters[rating][$between][0]=3&...[1]=5`        |
    
    ### Logical
    
    | Operator | Description        | Usage                                                  |
    | -------- | ------------------ | ------------------------------------------------------ |
    | `$and`   | All must match     | `filters[$and][0][title][$eq]=A&...[1][rating][$gt]=3` |
    | `$or`    | At least one match | `filters[$or][0][title][$eq]=A&...[1][title][$eq]=B`   |
    | `$not`   | Negate condition   | `filters[$not][0][title][$eq]=Hidden`                  |
    
    ---
    
    ## Sort and Pagination
    
    ### Sorting
    
    | Pattern           | Syntax                                       |
    | ----------------- | -------------------------------------------- |
    | Single ascending  | `sort=title` or `sort=title:asc`             |
    | Single descending | `sort=title:desc`                            |
    | Multiple fields   | `sort[0]=title:asc&sort[1]=publishedAt:desc` |
    
    ### Pagination (Page-Based)
    
    | Parameter               | Type    | Default |
    | ----------------------- | ------- | ------- |
    | `pagination[page]`      | Integer | 1       |
    | `pagination[pageSize]`  | Integer | 25      |
    | `pagination[withCount]` | Boolean | true    |
    
    ### Pagination (Offset-Based)
    
    | Parameter               | Type    | Default |
    | ----------------------- | ------- | ------- |
    | `pagination[start]`     | Integer | 0       |
    | `pagination[limit]`     | Integer | 25      |
    | `pagination[withCount]` | Boolean | true    |
    
    **Note:** Never mix page-based and offset-based pagination in the same query.
    
    ---
    
    ## Document Service API Methods
    
    | Method           | Signature                                                | Description                 |
    | ---------------- | -------------------------------------------------------- | --------------------------- |
    | `findMany()`     | `(params) => Document[]`                                 | List documents with filters |
    | `findOne()`      | `({ documentId, ...params }) => Document`                | Get one by documentId       |
    | `findFirst()`    | `(params) => Document`                                   | First matching document     |
    | `create()`       | `({ data, ...params }) => Document`                      | Create draft document       |
    | `update()`       | `({ documentId, data, ...params }) => Document`          | Update draft version        |
    | `delete()`       | `({ documentId, ...params }) => { documentId, entries }` | Delete document             |
    | `publish()`      | `({ documentId, ...params }) => { documentId, entries }` | Publish draft               |
    | `unpublish()`    | `({ documentId, ...params }) => { documentId, entries }` | Unpublish to draft          |
    | `discardDraft()` | `({ documentId, ...params }) => { documentId, entries }` | Revert draft to published   |
    | `count()`        | `(params) => number`                                     | Count matching documents    |
    
    **Common params:** `locale`, `status`, `filters`, `fields`, `populate`, `sort`, `pagination`
    
    ---
    
    ## Content Type Schema Field Types
    
    | Type          | Description                             |
    | ------------- | --------------------------------------- |
    | `string`      | Short text (up to 255 characters)       |
    | `text`        | Long text (no character limit)          |
    | `richtext`    | Rich text with Markdown support         |
    | `blocks`      | Block-based rich text editor (v5)       |
    | `integer`     | Whole number                            |
    | `biginteger`  | Large whole number                      |
    | `float`       | Decimal number (float)                  |
    | `decimal`     | Decimal number (precise)                |
    | `boolean`     | True/false                              |
    | `date`        | Date without time                       |
    | `time`        | Time without date                       |
    | `datetime`    | Date and time                           |
    | `email`       | Validated email string                  |
    | `password`    | Hashed password (never returned in API) |
    | `uid`         | URL-safe unique identifier (slug)       |
    | `enumeration` | Predefined set of string values         |
    | `json`        | Arbitrary JSON data                     |
    | `media`       | File upload (images, videos, documents) |
    | `relation`    | Reference to another content type       |
    | `component`   | Embedded reusable component             |
    | `dynamiczone` | Array of mixed component types          |
    
    ---
    
    ## Relation Types
    
    | Relation     | Description                 | Schema syntax              |
    | ------------ | --------------------------- | -------------------------- |
    | `oneToOne`   | One document links to one   | `"relation": "oneToOne"`   |
    | `oneToMany`  | One document links to many  | `"relation": "oneToMany"`  |
    | `manyToOne`  | Many documents link to one  | `"relation": "manyToOne"`  |
    | `manyToMany` | Many documents link to many | `"relation": "manyToMany"` |
    
    Use `inversedBy` / `mappedBy` to define bidirectional relations.
    
    ---
    
    ## Lifecycle Hook Events
    
    | Hook             | Trigger                        |
    | ---------------- | ------------------------------ |
    | `beforeCreate`   | Before a document is created   |
    | `afterCreate`    | After a document is created    |
    | `beforeUpdate`   | Before a document is updated   |
    | `afterUpdate`    | After a document is updated    |
    | `beforeDelete`   | Before a document is deleted   |
    | `afterDelete`    | After a document is deleted    |
    | `beforeFindOne`  | Before a single document query |
    | `afterFindOne`   | After a single document query  |
    | `beforeFindMany` | Before a list query            |
    | `afterFindMany`  | After a list query             |
    | `beforeCount`    | Before a count query           |
    | `afterCount`     | After a count query            |
    
    **Event object properties:** `action`, `params` (`data`, `where`, `select`, `populate`, `orderBy`, `limit`, `offset`), `result` (after hooks only), `state` (shared between before/after)
    
    ---
    
    ## File Structure
    
    ```
    src/
      api/
        article/
          content-types/
            article/
              schema.json         # Content type definition
              lifecycles.ts       # Lifecycle hooks
          controllers/
            article.ts            # Core + custom controller
          services/
            article.ts            # Core + custom service
          routes/
            article.ts            # Core router
            custom-article.ts     # Custom routes
          policies/
            is-owner.ts           # Custom policy
          middlewares/
            analytics.ts          # Route middleware
      components/
        shared/
          seo.json                # Reusable component schema
        blocks/
          hero.json               # Dynamic zone component
      extensions/                 # Plugin customizations
    config/
      api.ts                      # API config (pagination defaults)
      database.ts                 # Database connection
      middlewares.ts              # Global middleware config
      plugins.ts                  # Plugin config
      server.ts                   # Server config (host, port)
    ```
    
  • SKILL.md 20.2 KB
    ---
    name: api-cms-strapi
    description: Open-source headless CMS — content type schemas, REST API, Document Service API, custom controllers, lifecycle hooks, authentication, TypeScript
    ---
    
    # 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](reference.md)
    
    **Core & REST API:**
    
    - [examples/core.md](examples/core.md) -- Content type schemas, REST API querying, Document Service API, error handling
    
    **Backend Customization:**
    
    - [examples/backend.md](examples/backend.md) -- Custom controllers, services, routes, policies, middlewares, lifecycle hooks, Document Service middleware
    
    **Authentication:**
    
    - [examples/auth.md](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).
    
    ```json
    {
      "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](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.
    
    ```typescript
    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](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).
    
    ```typescript
    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](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.
    
    ```typescript
    // 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](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.
    
    ```typescript
    // 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](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.
    
    ```typescript
    // 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](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.
    
    ```typescript
    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](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.
    
    ```typescript
    // 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](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>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related