Skip to main content
Technology & EngineeringCms Services378 lines

Strapi

Build with Strapi for self-hosted headless CMS. Use this skill when the

Quick Summary21 lines
You are a backend CMS specialist who integrates Strapi into projects. Strapi
is an open-source, self-hosted headless CMS built on Node.js. It generates
REST and GraphQL APIs from content types you define, with a full admin panel
for content editors and role-based access control.

## Key Points

- Use `uid` fields for slugs — they auto-generate from a source field and enforce uniqueness
- Use components for reusable field groups (SEO, social sharing, metadata)
- Use API tokens with scoped permissions instead of user JWT for server-to-server calls
- Use `populate` selectively — avoid `populate=*` in production; it fetches everything
- Use lifecycle hooks for computed fields, cache invalidation, and external notifications
- Keep custom controllers thin — put business logic in services
- Use the `entityService` API in services for type-safe database operations
- Set `draftAndPublish: true` for editorial workflows — editors can preview before publishing
- Using `populate=*` or deep populate in production — fetches all relations recursively
- Modifying Strapi core `node_modules` — use controllers, services, and plugins to extend
- Storing API tokens in client-side code — use server-side routes or API routes as proxies
- Skipping input validation in custom controllers — use policies or middleware for auth checks
skilldb get cms-services-skills/StrapiFull skill: 378 lines
Paste into your CLAUDE.md or agent config

Strapi Integration

You are a backend CMS specialist who integrates Strapi into projects. Strapi is an open-source, self-hosted headless CMS built on Node.js. It generates REST and GraphQL APIs from content types you define, with a full admin panel for content editors and role-based access control.

Core Philosophy

Own your data and infrastructure

Strapi runs on your own server with your own database. You have full control over the data, the API, and the deployment. This matters for compliance, performance, and cost at scale. Choose Strapi when data sovereignty is a requirement.

Content types drive the API

When you define a content type in Strapi, it automatically generates REST endpoints, GraphQL resolvers, database tables, and admin panel forms. The content type schema is the single source of truth for your entire content layer.

Extend, don't fork

Strapi is designed to be extended through its plugin system, lifecycle hooks, custom controllers, and middleware. When you need custom behavior, write a plugin or override a controller. Do not modify Strapi core files.

Setup

Install

npx create-strapi@latest my-project
# Starts the interactive setup wizard

# Or with specific options
npx create-strapi@latest my-project \
  --dbclient postgres \
  --dbname strapi_db \
  --no-run

Environment variables

HOST=0.0.0.0
PORT=1337
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your-api-token-salt
ADMIN_JWT_SECRET=your-admin-jwt-secret
JWT_SECRET=your-jwt-secret
DATABASE_CLIENT=postgres
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=your-password

Project structure

src/
  api/            # Content types and their APIs
    post/
      content-types/post/schema.json
      controllers/post.ts
      routes/post.ts
      services/post.ts
  plugins/        # Custom plugins
  middlewares/    # Custom middleware
  policies/      # Authorization policies
config/
  database.ts
  server.ts
  middlewares.ts
  plugins.ts

Key Techniques

Content type schema

// src/api/post/content-types/post/schema.json
{
  "kind": "collectionType",
  "collectionName": "posts",
  "info": {
    "singularName": "post",
    "pluralName": "posts",
    "displayName": "Post"
  },
  "options": {
    "draftAndPublish": true
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true,
      "maxLength": 200
    },
    "slug": {
      "type": "uid",
      "targetField": "title",
      "required": true
    },
    "body": {
      "type": "richtext"
    },
    "excerpt": {
      "type": "text",
      "maxLength": 500
    },
    "cover": {
      "type": "media",
      "allowedTypes": ["images"],
      "multiple": false
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::author.author",
      "inversedBy": "posts"
    },
    "categories": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::category.category"
    },
    "seo": {
      "type": "component",
      "component": "shared.seo"
    }
  }
}

Custom controller

// src/api/post/controllers/post.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController(
  'api::post.post',
  ({ strapi }) => ({
    // Override find to add custom logic
    async find(ctx) {
      // Add default population
      ctx.query = {
        ...ctx.query,
        populate: {
          author: { fields: ['name'] },
          cover: { fields: ['url', 'alternativeText', 'width', 'height'] },
          categories: { fields: ['name', 'slug'] },
        },
      };

      const { data, meta } = await super.find(ctx);
      return { data, meta };
    },

    // Custom action: find by slug
    async findBySlug(ctx) {
      const { slug } = ctx.params;

      const entity = await strapi.db.query('api::post.post').findOne({
        where: { slug },
        populate: {
          author: true,
          cover: true,
          categories: true,
          seo: true,
        },
      });

      if (!entity) {
        return ctx.notFound('Post not found');
      }

      const sanitized = await this.sanitizeOutput(entity, ctx);
      return this.transformResponse(sanitized);
    },
  })
);

Custom routes

// src/api/post/routes/custom-post.ts
export default {
  routes: [
    {
      method: 'GET',
      path: '/posts/slug/:slug',
      handler: 'post.findBySlug',
      config: {
        auth: false, // public
        policies: [],
      },
    },
  ],
};

Custom service

// src/api/post/services/post.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService(
  'api::post.post',
  ({ strapi }) => ({
    async findPublished(filters = {}) {
      return strapi.entityService.findMany('api::post.post', {
        filters: {
          ...filters,
          publishedAt: { $notNull: true },
        },
        sort: { publishedAt: 'desc' },
        populate: {
          author: { fields: ['name'] },
          cover: { fields: ['url', 'alternativeText'] },
        },
      });
    },

    async findRelated(postId: string, limit = 3) {
      const post = await strapi.entityService.findOne(
        'api::post.post',
        postId,
        { populate: { categories: true } }
      );

      const categoryIds = post.categories.map((c: any) => c.id);

      return strapi.entityService.findMany('api::post.post', {
        filters: {
          id: { $ne: postId },
          categories: { id: { $in: categoryIds } },
          publishedAt: { $notNull: true },
        },
        limit,
        populate: { cover: true, author: true },
      });
    },
  })
);

Lifecycle hooks

// src/api/post/content-types/post/lifecycles.ts
export default {
  async beforeCreate(event: any) {
    const { data } = event.params;
    // Auto-generate excerpt from body if not provided
    if (!data.excerpt && data.body) {
      data.excerpt = data.body.replace(/<[^>]*>/g, '').slice(0, 200) + '...';
    }
  },

  async afterCreate(event: any) {
    const { result } = event;
    // Notify external service
    await fetch(process.env.WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ event: 'post.created', data: result }),
    });
  },

  async beforeUpdate(event: any) {
    const { data } = event.params;
    if (data.title) {
      // Regenerate slug on title change
      data.slug = data.title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/(^-|-$)/g, '');
    }
  },
};

Custom middleware

// src/middlewares/rate-limit.ts
import type { Strapi } from '@strapi/strapi';

export default (config: any, { strapi }: { strapi: Strapi }) => {
  const requests = new Map<string, number[]>();

  return async (ctx: any, next: () => Promise<void>) => {
    const ip = ctx.request.ip;
    const now = Date.now();
    const windowMs = config.windowMs || 60000;
    const max = config.max || 100;

    const timestamps = (requests.get(ip) || []).filter(
      (t) => now - t < windowMs
    );

    if (timestamps.length >= max) {
      ctx.status = 429;
      ctx.body = { error: 'Too many requests' };
      return;
    }

    timestamps.push(now);
    requests.set(ip, timestamps);

    await next();
  };
};

Frontend fetching (Next.js)

// lib/strapi.ts
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;

export async function fetchStrapi<T>(
  path: string,
  params: Record<string, string> = {}
): Promise<T> {
  const url = new URL(`/api${path}`, STRAPI_URL);
  Object.entries(params).forEach(([key, value]) =>
    url.searchParams.set(key, value)
  );

  const res = await fetch(url.toString(), {
    headers: {
      Authorization: `Bearer ${STRAPI_TOKEN}`,
    },
    next: { revalidate: 60 },
  });

  if (!res.ok) throw new Error(`Strapi error: ${res.status}`);
  return res.json();
}

// Usage
const { data: posts } = await fetchStrapi<{ data: Post[] }>('/posts', {
  'populate[author][fields][0]': 'name',
  'populate[cover][fields][0]': 'url',
  'sort[0]': 'publishedAt:desc',
  'pagination[pageSize]': '10',
});

Best Practices

  • Use uid fields for slugs — they auto-generate from a source field and enforce uniqueness
  • Use components for reusable field groups (SEO, social sharing, metadata)
  • Use API tokens with scoped permissions instead of user JWT for server-to-server calls
  • Use populate selectively — avoid populate=* in production; it fetches everything
  • Use lifecycle hooks for computed fields, cache invalidation, and external notifications
  • Keep custom controllers thin — put business logic in services
  • Use the entityService API in services for type-safe database operations
  • Set draftAndPublish: true for editorial workflows — editors can preview before publishing

Anti-Patterns

  • Using populate=* or deep populate in production — fetches all relations recursively
  • Modifying Strapi core node_modules — use controllers, services, and plugins to extend
  • Storing API tokens in client-side code — use server-side routes or API routes as proxies
  • Skipping input validation in custom controllers — use policies or middleware for auth checks
  • Not setting up CORS properly — leads to blocked requests from your frontend domain
  • Running Strapi with SQLite in production — use PostgreSQL or MySQL for concurrency
  • Ignoring draft/publish status in custom queries — always filter by publishedAt for public APIs
  • Not backing up the database before content type schema changes — migrations can be destructive

Install this skill directly: skilldb add cms-services-skills

Get CLI access →