Skip to main content
Technology & EngineeringCms Services379 lines

Payload

Build with Payload CMS for code-first TypeScript content management. Use this

Quick Summary36 lines
You are a CMS specialist who integrates Payload into projects. Payload is a
code-first, TypeScript-native headless CMS that runs inside your Next.js
application. There is no separate CMS server — Payload lives in your Next.js
app, shares your database, and gives you a local API with zero network overhead.

## Key Points

- Run `payload generate:types` after schema changes to keep TypeScript types in sync
- Use the local API (`getPayload`) instead of REST when code runs in the same process
- Use `depth` to control relationship resolution — avoid deep nesting for list views
- Use `versions.drafts` for editorial workflows with autosave
- Put access control on every collection — default is open to all authenticated users
- Use `group` fields for logically related data (SEO, social sharing, metadata)
- Use field-level hooks for data normalization (trimming, slugifying, computing)
- Use collection hooks for side effects (cache invalidation, notifications, syncing)
- Defining collections without access control — every collection needs explicit read/write rules
- Using `depth: 0` when you need related data or `depth: 5` when you do not — be intentional
- Putting business logic in API route handlers instead of hooks — hooks run for all API surfaces
- Not generating types after schema changes — leads to runtime errors that TypeScript would catch

## Quick Example

```bash
npx create-payload-app@latest

# Or add to existing Next.js project
npm install payload @payloadcms/next @payloadcms/db-mongodb @payloadcms/richtext-lexical
```

```env
DATABASE_URI=mongodb://localhost:27017/payload
PAYLOAD_SECRET=your-secret-key-min-32-chars
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
```
skilldb get cms-services-skills/PayloadFull skill: 379 lines
Paste into your CLAUDE.md or agent config

Payload CMS Integration

You are a CMS specialist who integrates Payload into projects. Payload is a code-first, TypeScript-native headless CMS that runs inside your Next.js application. There is no separate CMS server — Payload lives in your Next.js app, shares your database, and gives you a local API with zero network overhead.

Core Philosophy

Code-first, not UI-first

Everything in Payload is defined in TypeScript config files. Collections, fields, hooks, access control, and custom components are all code. The admin panel is auto-generated from your config. This means your CMS is version- controlled, type-safe, and reviewable in pull requests.

Your app, not a separate service

Payload 3.x runs inside Next.js. The admin panel is a set of Next.js routes. The API endpoints are Next.js API routes. The local API calls your database directly with no HTTP overhead. There is no separate process to deploy or manage.

TypeScript all the way

Payload generates TypeScript types from your collections. The local API, hooks, access control functions, and custom components are all fully typed. If your schema changes, TypeScript catches every affected callsite.

Setup

Install

npx create-payload-app@latest

# Or add to existing Next.js project
npm install payload @payloadcms/next @payloadcms/db-mongodb @payloadcms/richtext-lexical

Environment variables

DATABASE_URI=mongodb://localhost:27017/payload
PAYLOAD_SECRET=your-secret-key-min-32-chars
NEXT_PUBLIC_SERVER_URL=http://localhost:3000

Payload config

// payload.config.ts
import { buildConfig } from 'payload';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import { Posts } from './collections/Posts';
import { Authors } from './collections/Authors';
import { Media } from './collections/Media';
import { Users } from './collections/Users';

export default buildConfig({
  editor: lexicalEditor(),
  db: mongooseAdapter({ url: process.env.DATABASE_URI! }),
  collections: [Posts, Authors, Media, Users],
  secret: process.env.PAYLOAD_SECRET!,
  typescript: {
    outputFile: 'payload-types.ts', // auto-generated types
  },
  admin: {
    user: Users.slug,
  },
});

Key Techniques

Collection definitions

// collections/Posts.ts
import type { CollectionConfig } from 'payload';

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'author', 'status', 'publishedAt'],
  },
  access: {
    read: () => true, // public
    create: ({ req: { user } }) => Boolean(user), // authenticated
    update: ({ req: { user } }) => Boolean(user),
    delete: ({ req: { user } }) => user?.role === 'admin',
  },
  versions: {
    drafts: {
      autosave: true,
    },
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      maxLength: 200,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      admin: {
        position: 'sidebar',
      },
      hooks: {
        beforeValidate: [
          ({ value, data }) => {
            if (!value && data?.title) {
              return data.title
                .toLowerCase()
                .replace(/[^a-z0-9]+/g, '-')
                .replace(/(^-|-$)/g, '');
            }
            return value;
          },
        ],
      },
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'authors',
      required: true,
      admin: { position: 'sidebar' },
    },
    {
      name: 'featuredImage',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'content',
      type: 'richText', // Lexical editor
    },
    {
      name: 'excerpt',
      type: 'textarea',
      maxLength: 500,
    },
    {
      name: 'tags',
      type: 'array',
      fields: [
        { name: 'tag', type: 'text', required: true },
      ],
    },
    {
      name: 'seo',
      type: 'group',
      fields: [
        { name: 'metaTitle', type: 'text', maxLength: 60 },
        { name: 'metaDescription', type: 'textarea', maxLength: 160 },
        { name: 'ogImage', type: 'upload', relationTo: 'media' },
      ],
    },
    {
      name: 'publishedAt',
      type: 'date',
      admin: { position: 'sidebar' },
    },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
      ],
      admin: { position: 'sidebar' },
    },
  ],
};

Media collection with image optimization

// collections/Media.ts
import type { CollectionConfig } from 'payload';

export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    mimeTypes: ['image/*'],
    imageSizes: [
      { name: 'thumbnail', width: 300, height: 200, position: 'centre' },
      { name: 'card', width: 600, height: 400, position: 'centre' },
      { name: 'hero', width: 1200, height: 630, position: 'centre' },
    ],
    adminThumbnail: 'thumbnail',
  },
  access: {
    read: () => true,
  },
  fields: [
    { name: 'alt', type: 'text', required: true },
    { name: 'caption', type: 'text' },
  ],
};

Local API (zero network overhead)

// app/blog/page.tsx
import { getPayload } from 'payload';
import config from '@payload-config';

export default async function BlogPage() {
  const payload = await getPayload({ config });

  const posts = await payload.find({
    collection: 'posts',
    where: {
      status: { equals: 'published' },
      publishedAt: { less_than_equal: new Date().toISOString() },
    },
    sort: '-publishedAt',
    limit: 20,
    depth: 1, // resolve relationships 1 level deep
  });

  return (
    <ul>
      {posts.docs.map((post) => (
        <li key={post.id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

Collection hooks

// collections/Posts.ts (hooks section)
import type { CollectionConfig } from 'payload';

export const Posts: CollectionConfig = {
  slug: 'posts',
  hooks: {
    beforeChange: [
      async ({ data, operation }) => {
        // Auto-set publishedAt when status changes to published
        if (operation === 'create' || operation === 'update') {
          if (data.status === 'published' && !data.publishedAt) {
            data.publishedAt = new Date().toISOString();
          }
        }
        return data;
      },
    ],
    afterChange: [
      async ({ doc, operation, req }) => {
        // Revalidate Next.js cache
        if (operation === 'create' || operation === 'update') {
          const { revalidateTag } = await import('next/cache');
          revalidateTag('posts');
          revalidateTag(`post-${doc.slug}`);
        }
      },
    ],
  },
  // ... fields
  fields: [],
};

Access control

// access/isAdminOrAuthor.ts
import type { Access } from 'payload';

export const isAdminOrAuthor: Access = ({ req: { user } }) => {
  if (!user) return false;
  if (user.role === 'admin') return true;

  // Authors can only see their own posts
  return {
    author: {
      equals: user.id,
    },
  };
};

// Usage in collection
// access: {
//   read: () => true,
//   update: isAdminOrAuthor,
//   delete: isAdminOrAuthor,
// },

Custom endpoints

// collections/Posts.ts
export const Posts: CollectionConfig = {
  slug: 'posts',
  endpoints: [
    {
      path: '/slug/:slug',
      method: 'get',
      handler: async (req) => {
        const slug = req.routeParams?.slug as string;
        const payload = req.payload;

        const result = await payload.find({
          collection: 'posts',
          where: { slug: { equals: slug }, status: { equals: 'published' } },
          depth: 2,
          limit: 1,
        });

        if (result.docs.length === 0) {
          return Response.json({ error: 'Not found' }, { status: 404 });
        }

        return Response.json(result.docs[0]);
      },
    },
  ],
  fields: [],
};

Field-level hooks and validation

{
  name: 'email',
  type: 'email',
  required: true,
  unique: true,
  validate: (value: string) => {
    if (value && !value.endsWith('@company.com')) {
      return 'Must be a company email address';
    }
    return true;
  },
  hooks: {
    beforeChange: [
      ({ value }) => value?.toLowerCase().trim(),
    ],
  },
}

Best Practices

  • Run payload generate:types after schema changes to keep TypeScript types in sync
  • Use the local API (getPayload) instead of REST when code runs in the same process
  • Use depth to control relationship resolution — avoid deep nesting for list views
  • Use versions.drafts for editorial workflows with autosave
  • Put access control on every collection — default is open to all authenticated users
  • Use group fields for logically related data (SEO, social sharing, metadata)
  • Use field-level hooks for data normalization (trimming, slugifying, computing)
  • Use collection hooks for side effects (cache invalidation, notifications, syncing)

Anti-Patterns

  • Defining collections without access control — every collection needs explicit read/write rules
  • Using depth: 0 when you need related data or depth: 5 when you do not — be intentional
  • Putting business logic in API route handlers instead of hooks — hooks run for all API surfaces
  • Not generating types after schema changes — leads to runtime errors that TypeScript would catch
  • Running Payload as a separate service from your Next.js app — it is designed to run inside it
  • Using the REST API from server components when the local API is available — adds unnecessary latency
  • Skipping unique: true on slug fields — causes duplicate URL conflicts
  • Hardcoding user role checks instead of using access control functions — misses API and admin panel access

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

Get CLI access →