Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik38 min czytania

Payload CMS

Payload to TypeScript-first headless CMS z code-based config, natywną integracją Next.js i pełną kontrolą nad kodem. Kompletny przewodnik po collections, access control i Local API.

Payload CMS - Kompletny Przewodnik po TypeScript-First Headless CMS

Czym jest Payload CMS?

Payload to nowoczesny headless CMS napisany w TypeScript, stworzony w 2021 roku przez zespół w Pittsburghu (USA). W przeciwieństwie do innych CMS-ów, Payload przyjmuje podejście code-first - cała konfiguracja odbywa się w kodzie TypeScript, nie przez GUI. To sprawia, że jest idealny dla deweloperów preferujących pełną kontrolę i wersjonowanie konfiguracji w Git.

Od wersji 2.0, Payload oferuje natywną integrację z Next.js - admin panel i API mogą działać jako część tej samej aplikacji Next.js. To eliminuje potrzebę uruchamiania osobnego backendu i upraszcza deployment.

Payload wyróżnia się brakiem vendor lock-in - możesz go hostować na własnych serwerach, używać z dowolną bazą danych (MongoDB lub PostgreSQL), a cały kod jest twój. Jest open source (MIT License) z opcjonalnym Payload Cloud dla managed hosting.

Dlaczego Payload CMS?

Kluczowe zalety Payload

  1. TypeScript-first - Pełne typy, autocomplete, bezpieczeństwo typów
  2. Code-based config - Wersjonowanie w Git, review, CI/CD
  3. Native Next.js integration - Jedna aplikacja, jeden deployment
  4. Local API - Bezpośredni dostęp do danych w Server Components
  5. Self-hosted - Pełna kontrola, bez vendor lock-in
  6. Flexible databases - MongoDB lub PostgreSQL
  7. Powerful access control - Granularne uprawnienia na poziomie pól
  8. Extensible - Hooks, plugins, custom endpoints

Payload vs Inne CMS

CechaPayloadStrapiContentfulSanity
TypSelf-hostedSelf-hostedSaaSSaaS
ConfigCode-firstVisual + CodeVisualCode + Visual
TypeScriptNativePartialSDKSDK
Next.jsNative embedSeparateSeparateSeparate
DatabaseMongoDB/PostgreSQLSQLite/PostgreSQL/MySQLN/AN/A
Cena (self-hosted)Darmowy (MIT)Darmowy (MIT)N/AN/A
Admin UIReact (customizable)ReactSaaSReact
Local API✅ Tak❌ Nie❌ Nie❌ Nie
Real-timeWebsocketsWebhooksWebhooksNative

Kiedy wybrać Payload?

Payload jest idealny gdy:

  • Pracujesz z TypeScript i Next.js
  • Preferujesz code-first configuration
  • Chcesz versionować config CMS w Git
  • Potrzebujesz Local API w Server Components
  • Zależy Ci na self-hostingu z pełną kontrolą
  • Budujesz aplikację fullstack (frontend + CMS + API)

Rozważ alternatywy gdy:

  • Potrzebujesz real-time collaboration → Sanity
  • Non-technical team zarządza content modelem → Strapi, Contentful
  • Potrzebujesz managed CDN bez DevOps → Contentful
  • Budżet jest zerowy i wolisz zero DevOps → Sanity (generous free tier)

Instalacja i Setup

Tworzenie nowego projektu

Code
Bash
# Nowy projekt Payload + Next.js
npx create-payload-app@latest my-project

# Opcje podczas instalacji:
# ✓ Select a project template: blank (lub blog, website, ecommerce)
# ✓ Database: MongoDB lub PostgreSQL
# ✓ Package manager: npm, yarn, lub pnpm

Struktura projektu

Code
TEXT
my-project/
├── app/                          # Next.js App Router
│   ├── (frontend)/              # Public pages
│   │   ├── page.tsx
│   │   └── [slug]/
│   ├── (payload)/               # Admin panel (auto-generated)
│   │   └── admin/
│   │       └── [[...segments]]/
│   │           └── page.tsx
│   └── api/                     # API routes
│       └── [...payload]/
│           └── route.ts         # Payload REST API
├── collections/                  # Collection configs
│   ├── Users.ts
│   ├── Pages.ts
│   ├── Posts.ts
│   └── Media.ts
├── globals/                      # Global configs
│   ├── Settings.ts
│   └── Navigation.ts
├── blocks/                       # Reusable content blocks
│   ├── Hero.ts
│   ├── Content.ts
│   └── CallToAction.ts
├── fields/                       # Custom field configs
│   └── slug.ts
├── hooks/                        # Lifecycle hooks
│   └── populatePublishedDate.ts
├── access/                       # Access control functions
│   ├── isAdmin.ts
│   └── isOwner.ts
├── payload.config.ts             # Main Payload config
├── payload-types.ts              # Auto-generated types
└── .env

Payload Config

TSpayload.config.ts
TypeScript
// payload.config.ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
// lub
// import { postgresAdapter } from '@payloadcms/db-postgres'
import { slateEditor } from '@payloadcms/richtext-slate'
// lub
// import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
import path from 'path'
import sharp from 'sharp'

// Collections
import { Users } from './collections/Users'
import { Pages } from './collections/Pages'
import { Posts } from './collections/Posts'
import { Media } from './collections/Media'
import { Categories } from './collections/Categories'

// Globals
import { Settings } from './globals/Settings'
import { Navigation } from './globals/Navigation'

export default buildConfig({
  // Admin panel config
  admin: {
    user: Users.slug,
    meta: {
      titleSuffix: '- My CMS',
      favicon: '/favicon.ico',
      ogImage: '/og-image.png',
    },
    components: {
      // Custom admin components
      beforeDashboard: ['@/components/admin/DashboardIntro'],
    },
  },

  // Collections (repeatable content)
  collections: [Users, Pages, Posts, Media, Categories],

  // Globals (single documents)
  globals: [Settings, Navigation],

  // Rich text editor
  editor: slateEditor({}),
  // editor: lexicalEditor({}),

  // Database adapter
  db: mongooseAdapter({
    url: process.env.DATABASE_URI!,
  }),
  // db: postgresAdapter({
  //   pool: {
  //     connectionString: process.env.DATABASE_URI!,
  //   },
  // }),

  // File uploads
  upload: {
    limits: {
      fileSize: 10000000, // 10MB
    },
  },

  // TypeScript auto-generation
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },

  // GraphQL (opcjonalne)
  graphQL: {
    schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
  },

  // Image processing
  sharp,

  // Plugins
  plugins: [
    // uploadthingStorage({
    //   collections: {
    //     media: true,
    //   },
    //   options: {
    //     token: process.env.UPLOADTHING_TOKEN,
    //   },
    // }),
  ],

  // CORS
  cors: [process.env.NEXT_PUBLIC_SITE_URL || ''].filter(Boolean),
  csrf: [process.env.NEXT_PUBLIC_SITE_URL || ''].filter(Boolean),
})

Zmienne środowiskowe

Code
ENV
# .env
DATABASE_URI=mongodb://localhost:27017/payload
# lub
# DATABASE_URI=postgresql://user:password@localhost:5432/payload

PAYLOAD_SECRET=your-super-secret-key-min-32-chars
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Collections

Collections to kolekcje dokumentów (jak tabele w bazie danych).

Podstawowa Collection

TScollections/Posts.ts
TypeScript
// collections/Posts.ts
import { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  labels: {
    singular: 'Post',
    plural: 'Posts',
  },
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'publishedDate'],
    group: 'Content',
    description: 'Blog posts and articles',
  },
  access: {
    read: () => true,
    create: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user } }) => Boolean(user),
    delete: ({ req: { user } }) => user?.role === 'admin',
  },
  versions: {
    drafts: {
      autosave: {
        interval: 300, // 5 minutes
      },
    },
    maxPerDoc: 10,
  },
  hooks: {
    beforeChange: [
      ({ data, operation }) => {
        if (operation === 'create' && !data.publishedDate) {
          data.publishedDate = new Date().toISOString()
        }
        return data
      },
    ],
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      minLength: 3,
      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: 'excerpt',
      type: 'textarea',
      maxLength: 300,
    },
    {
      name: 'content',
      type: 'richText',
      required: true,
    },
    {
      name: 'featuredImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
      required: true,
      defaultValue: ({ user }) => user?.id,
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'categories',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: true,
    },
    {
      name: 'tags',
      type: 'array',
      fields: [
        {
          name: 'tag',
          type: 'text',
          required: true,
        },
      ],
    },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
        { label: 'Archived', value: 'archived' },
      ],
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'publishedDate',
      type: 'date',
      admin: {
        position: 'sidebar',
        date: {
          pickerAppearance: 'dayAndTime',
        },
      },
    },
    {
      name: 'seo',
      type: 'group',
      fields: [
        {
          name: 'metaTitle',
          type: 'text',
          maxLength: 60,
        },
        {
          name: 'metaDescription',
          type: 'textarea',
          maxLength: 160,
        },
        {
          name: 'ogImage',
          type: 'upload',
          relationTo: 'media',
        },
      ],
    },
  ],
}

Typy pól

Code
TypeScript
// Wszystkie dostępne typy pól

const fields = [
  // Text fields
  { name: 'title', type: 'text' },
  { name: 'description', type: 'textarea' },
  { name: 'content', type: 'richText' },
  { name: 'code', type: 'code', admin: { language: 'typescript' } },
  { name: 'email', type: 'email' },

  // Number fields
  { name: 'price', type: 'number', min: 0, max: 10000 },

  // Date fields
  { name: 'publishedAt', type: 'date' },

  // Boolean
  { name: 'featured', type: 'checkbox' },

  // Select
  {
    name: 'status',
    type: 'select',
    options: [
      { label: 'Draft', value: 'draft' },
      { label: 'Published', value: 'published' },
    ],
  },
  {
    name: 'roles',
    type: 'select',
    hasMany: true,
    options: ['admin', 'editor', 'viewer'],
  },

  // Radio
  {
    name: 'priority',
    type: 'radio',
    options: [
      { label: 'Low', value: 'low' },
      { label: 'High', value: 'high' },
    ],
  },

  // Relationship (foreign key)
  {
    name: 'author',
    type: 'relationship',
    relationTo: 'users',
  },
  {
    name: 'categories',
    type: 'relationship',
    relationTo: 'categories',
    hasMany: true,
  },
  {
    name: 'relatedContent',
    type: 'relationship',
    relationTo: ['posts', 'pages'], // Polymorphic
    hasMany: true,
  },

  // Upload (media)
  {
    name: 'image',
    type: 'upload',
    relationTo: 'media',
  },

  // Array (repeatable nested)
  {
    name: 'socialLinks',
    type: 'array',
    fields: [
      { name: 'platform', type: 'text' },
      { name: 'url', type: 'text' },
    ],
  },

  // Group (nested object)
  {
    name: 'address',
    type: 'group',
    fields: [
      { name: 'street', type: 'text' },
      { name: 'city', type: 'text' },
      { name: 'zip', type: 'text' },
    ],
  },

  // Blocks (dynamic content)
  {
    name: 'layout',
    type: 'blocks',
    blocks: [HeroBlock, ContentBlock, CTABlock],
  },

  // Tabs (UI organization)
  {
    type: 'tabs',
    tabs: [
      {
        label: 'Content',
        fields: [
          { name: 'title', type: 'text' },
          { name: 'body', type: 'richText' },
        ],
      },
      {
        label: 'SEO',
        fields: [
          { name: 'metaTitle', type: 'text' },
          { name: 'metaDescription', type: 'textarea' },
        ],
      },
    ],
  },

  // Row (horizontal layout)
  {
    type: 'row',
    fields: [
      { name: 'firstName', type: 'text' },
      { name: 'lastName', type: 'text' },
    ],
  },

  // Collapsible
  {
    type: 'collapsible',
    label: 'Advanced Options',
    fields: [
      { name: 'customCSS', type: 'code', admin: { language: 'css' } },
    ],
  },

  // Point (geo coordinates)
  {
    name: 'location',
    type: 'point',
  },

  // JSON
  {
    name: 'metadata',
    type: 'json',
  },

  // UI (display only, no data)
  {
    type: 'ui',
    name: 'divider',
    admin: {
      components: {
        Field: () => <hr />,
      },
    },
  },
]

Media Collection

TScollections/Media.ts
TypeScript
// collections/Media.ts
import { CollectionConfig } from 'payload'

export const Media: CollectionConfig = {
  slug: 'media',
  labels: {
    singular: 'Media',
    plural: 'Media',
  },
  admin: {
    useAsTitle: 'filename',
    group: 'Media',
  },
  access: {
    read: () => true,
  },
  upload: {
    staticDir: 'media',
    staticURL: '/media',
    imageSizes: [
      {
        name: 'thumbnail',
        width: 400,
        height: 300,
        position: 'centre',
      },
      {
        name: 'card',
        width: 768,
        height: 1024,
        position: 'centre',
      },
      {
        name: 'feature',
        width: 1920,
        height: undefined,
        position: 'centre',
      },
    ],
    adminThumbnail: 'thumbnail',
    mimeTypes: ['image/*', 'application/pdf'],
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      required: true,
    },
    {
      name: 'caption',
      type: 'text',
    },
  ],
}

Blocks (Composable Content)

TSblocks/Hero.ts
TypeScript
// blocks/Hero.ts
import { Block } from 'payload'

export const HeroBlock: Block = {
  slug: 'hero',
  labels: {
    singular: 'Hero Section',
    plural: 'Hero Sections',
  },
  imageURL: '/blocks/hero.png',
  fields: [
    {
      name: 'heading',
      type: 'text',
      required: true,
    },
    {
      name: 'subheading',
      type: 'textarea',
    },
    {
      name: 'backgroundImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'cta',
      type: 'group',
      fields: [
        { name: 'label', type: 'text' },
        { name: 'link', type: 'text' },
      ],
    },
    {
      name: 'alignment',
      type: 'select',
      defaultValue: 'center',
      options: [
        { label: 'Left', value: 'left' },
        { label: 'Center', value: 'center' },
        { label: 'Right', value: 'right' },
      ],
    },
  ],
}

// blocks/Content.ts
export const ContentBlock: Block = {
  slug: 'content',
  labels: {
    singular: 'Content Block',
    plural: 'Content Blocks',
  },
  fields: [
    {
      name: 'content',
      type: 'richText',
      required: true,
    },
  ],
}

// collections/Pages.ts (using blocks)
export const Pages: CollectionConfig = {
  slug: 'pages',
  admin: {
    useAsTitle: 'title',
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'layout',
      type: 'blocks',
      blocks: [HeroBlock, ContentBlock, CTABlock, FeatureGridBlock],
      required: true,
    },
  ],
}

Access Control

Payload oferuje granularne access control na poziomie collection, dokumentu i pola.

TSaccess/isAdmin.ts
TypeScript
// access/isAdmin.ts
import { Access } from 'payload'

export const isAdmin: Access = ({ req: { user } }) => {
  return user?.role === 'admin'
}

// access/isAdminOrSelf.ts
export const isAdminOrSelf: Access = ({ req: { user } }) => {
  if (!user) return false
  if (user.role === 'admin') return true

  // Zwróć query constraint
  return {
    id: {
      equals: user.id,
    },
  }
}

// access/isOwner.ts
export const isOwner: Access = ({ req: { user } }) => {
  if (!user) return false
  if (user.role === 'admin') return true

  return {
    author: {
      equals: user.id,
    },
  }
}

// access/publishedOrAdmin.ts
export const publishedOrAdmin: Access = ({ req: { user } }) => {
  if (user?.role === 'admin') return true

  return {
    status: {
      equals: 'published',
    },
  }
}

Field-level access

Code
TypeScript
{
  name: 'internalNotes',
  type: 'textarea',
  access: {
    read: ({ req: { user } }) => user?.role === 'admin',
    update: ({ req: { user } }) => user?.role === 'admin',
  },
}

Collection access

Code
TypeScript
export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    // Kto może czytać
    read: publishedOrAdmin,

    // Kto może tworzyć
    create: ({ req: { user } }) => Boolean(user),

    // Kto może aktualizować (z query constraint)
    update: isOwner,

    // Kto może usuwać
    delete: isAdmin,

    // Admin UI access
    admin: ({ req: { user } }) => Boolean(user),
  },
  // ...
}

Local API

Kluczowa zaleta Payload - bezpośredni dostęp do danych bez HTTP.

TSapp/(frontend)/blog/page.tsx
TypeScript
// app/(frontend)/blog/page.tsx
import { getPayload } from 'payload'
import configPromise from '@payload-config'

export default async function BlogPage() {
  const payload = await getPayload({
    config: configPromise,
  })

  // Find all published posts
  const { docs: posts, totalDocs } = await payload.find({
    collection: 'posts',
    where: {
      status: {
        equals: 'published',
      },
    },
    sort: '-publishedDate',
    limit: 10,
    depth: 2, // Populate relationships
  })

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

// app/(frontend)/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const payload = await getPayload({ config: configPromise })

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

  const post = docs[0]

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <RichText content={post.content} />
    </article>
  )
}

Local API operations

Code
TypeScript
import { getPayload } from 'payload'
import configPromise from '@payload-config'

const payload = await getPayload({ config: configPromise })

// FIND (query)
const { docs, totalDocs, page, totalPages, hasNextPage } = await payload.find({
  collection: 'posts',
  where: {
    status: { equals: 'published' },
    categories: { contains: categoryId },
    publishedDate: { greater_than: '2024-01-01' },
  },
  sort: '-publishedDate',
  page: 1,
  limit: 10,
  depth: 2,
  locale: 'pl',
  fallbackLocale: 'en',
})

// FIND BY ID
const post = await payload.findByID({
  collection: 'posts',
  id: 'post-id',
  depth: 2,
})

// CREATE
const newPost = await payload.create({
  collection: 'posts',
  data: {
    title: 'New Post',
    slug: 'new-post',
    content: richTextContent,
    status: 'draft',
    author: userId,
  },
})

// UPDATE
const updatedPost = await payload.update({
  collection: 'posts',
  id: 'post-id',
  data: {
    title: 'Updated Title',
    status: 'published',
  },
})

// UPDATE MANY
const { docs: updated } = await payload.update({
  collection: 'posts',
  where: {
    status: { equals: 'draft' },
  },
  data: {
    status: 'archived',
  },
})

// DELETE
await payload.delete({
  collection: 'posts',
  id: 'post-id',
})

// DELETE MANY
await payload.delete({
  collection: 'posts',
  where: {
    status: { equals: 'archived' },
  },
})

// COUNT
const count = await payload.count({
  collection: 'posts',
  where: {
    status: { equals: 'published' },
  },
})

// GLOBALS
const settings = await payload.findGlobal({
  slug: 'settings',
})

await payload.updateGlobal({
  slug: 'settings',
  data: {
    siteName: 'Updated Site Name',
  },
})

Query operators

Code
TypeScript
const { docs } = await payload.find({
  collection: 'posts',
  where: {
    // Equality
    status: { equals: 'published' },
    featured: { equals: true },

    // Not equal
    status: { not_equals: 'draft' },

    // Comparison (numbers, dates)
    views: { greater_than: 100 },
    views: { greater_than_equal: 100 },
    views: { less_than: 1000 },
    views: { less_than_equal: 1000 },
    publishedDate: { greater_than: '2024-01-01' },

    // Text search
    title: { contains: 'JavaScript' },
    title: { like: '%JavaScript%' }, // SQL-like

    // Existence
    featuredImage: { exists: true },

    // Array operations
    tags: { in: ['javascript', 'typescript'] },
    tags: { not_in: ['deprecated'] },
    categories: { contains: 'category-id' },
    categories: { all: ['cat1', 'cat2'] }, // All must match

    // Logical operators
    or: [
      { status: { equals: 'published' } },
      { author: { equals: currentUserId } },
    ],

    and: [
      { status: { equals: 'published' } },
      { publishedDate: { less_than: new Date().toISOString() } },
    ],
  },
})

REST API

Payload automatycznie generuje REST API.

Code
TypeScript
// Endpointy dla collection "posts":
GET    /api/posts           // Find all
GET    /api/posts/:id       // Find by ID
POST   /api/posts           // Create
PATCH  /api/posts/:id       // Update
DELETE /api/posts/:id       // Delete

// Query parameters
GET /api/posts?where[status][equals]=published
GET /api/posts?sort=-publishedDate
GET /api/posts?limit=10&page=2
GET /api/posts?depth=2

// Globals
GET  /api/globals/settings
POST /api/globals/settings

// Auth
POST /api/users/login
POST /api/users/logout
POST /api/users/me
POST /api/users/forgot-password
POST /api/users/reset-password

Fetch z REST API

Code
TypeScript
// Client-side fetch
const response = await fetch('/api/posts?where[status][equals]=published&limit=10', {
  headers: {
    'Content-Type': 'application/json',
    // Auth (jeśli wymagane)
    'Authorization': `JWT ${token}`,
  },
})

const { docs, totalDocs, page } = await response.json()

// Create
const newPost = await fetch('/api/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `JWT ${token}`,
  },
  body: JSON.stringify({
    title: 'New Post',
    slug: 'new-post',
    status: 'draft',
  }),
})

Hooks

Lifecycle hooks pozwalają na custom logikę.

TShooks/populateSlug.ts
TypeScript
// hooks/populateSlug.ts
import { FieldHook } from 'payload'

export const populateSlug: FieldHook = ({ value, data }) => {
  if (!value && data?.title) {
    return data.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')
  }
  return value
}

// hooks/sendNotification.ts
import { CollectionAfterChangeHook } from 'payload'

export const sendNotification: CollectionAfterChangeHook = async ({
  doc,
  operation,
  req,
}) => {
  if (operation === 'create') {
    // Send email, webhook, etc.
    await fetch('https://hooks.example.com/new-post', {
      method: 'POST',
      body: JSON.stringify({
        title: doc.title,
        author: doc.author,
      }),
    })
  }

  return doc
}

// Collection z hooks
export const Posts: CollectionConfig = {
  slug: 'posts',
  hooks: {
    // Before operations
    beforeValidate: [validateData],
    beforeChange: [populatePublishedDate],
    beforeDelete: [archiveBeforeDelete],
    beforeRead: [restrictDrafts],

    // After operations
    afterChange: [sendNotification, revalidateCache],
    afterDelete: [cleanupRelated],
    afterRead: [transformData],

    // Auth hooks (Users collection)
    afterLogin: [logLogin],
    afterLogout: [logLogout],
    afterForgotPassword: [sendResetEmail],
  },
  fields: [
    {
      name: 'slug',
      type: 'text',
      hooks: {
        beforeValidate: [populateSlug],
      },
    },
  ],
}

Globals

Single documents (nie collections).

TSglobals/Settings.ts
TypeScript
// globals/Settings.ts
import { GlobalConfig } from 'payload'

export const Settings: GlobalConfig = {
  slug: 'settings',
  label: 'Site Settings',
  admin: {
    group: 'Config',
  },
  access: {
    read: () => true,
    update: ({ req: { user } }) => user?.role === 'admin',
  },
  fields: [
    {
      name: 'siteName',
      type: 'text',
      required: true,
    },
    {
      name: 'siteDescription',
      type: 'textarea',
    },
    {
      name: 'logo',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'socialLinks',
      type: 'array',
      fields: [
        {
          name: 'platform',
          type: 'select',
          options: ['twitter', 'facebook', 'instagram', 'linkedin'],
        },
        {
          name: 'url',
          type: 'text',
        },
      ],
    },
    {
      name: 'footer',
      type: 'group',
      fields: [
        { name: 'copyright', type: 'text' },
        { name: 'showSocialLinks', type: 'checkbox' },
      ],
    },
  ],
}

// Usage
const settings = await payload.findGlobal({ slug: 'settings' })
console.log(settings.siteName)

Rich Text Rendering

TScomponents/RichText.tsx
TypeScript
// components/RichText.tsx
import React from 'react'
import { SerializedEditorState } from 'lexical'
// lub dla Slate:
// import { Descendant } from 'slate'

interface RichTextProps {
  content: SerializedEditorState | null
}

// Dla Lexical (domyślny w Payload 3.0)
export function RichText({ content }: RichTextProps) {
  if (!content) return null

  // Payload dostarcza komponenty do renderowania
  return (
    <div className="prose prose-lg max-w-none">
      <LexicalContent content={content} />
    </div>
  )
}

// Alternatywnie - custom rendering
import {
  JSXConvertersFunction,
  RichText as PayloadRichText,
} from '@payloadcms/richtext-lexical/react'

const converters: JSXConvertersFunction = ({ defaultConverters }) => ({
  ...defaultConverters,
  blocks: {
    ...defaultConverters.blocks,
    // Custom block rendering
    codeBlock: ({ node }) => (
      <pre className="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto">
        <code>{node.fields.code}</code>
      </pre>
    ),
  },
})

export function RichTextContent({ content }: RichTextProps) {
  return (
    <PayloadRichText
      data={content}
      converters={converters}
    />
  )
}

Deployment

Self-hosted z Docker

Code
DOCKERFILE
# Dockerfile
FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/media ./media

EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml
YAML
# docker-compose.yml
version: '3'

services:
  app:
    build: .
    ports:
      - '3000:3000'
    environment:
      DATABASE_URI: mongodb://mongo:27017/payload
      PAYLOAD_SECRET: your-secret-key
      NEXT_PUBLIC_SITE_URL: http://localhost:3000
    depends_on:
      - mongo
    volumes:
      - ./media:/app/media

  mongo:
    image: mongo:7
    volumes:
      - mongo_data:/data/db

volumes:
  mongo_data:

Payload Cloud

Code
Bash
# Login
npx payload cloud:login

# Deploy
npx payload cloud:deploy

Vercel

Code
Bash
# Install Vercel CLI
npm i -g vercel

# Deploy
vercel

Pamiętaj o:

  • MongoDB Atlas lub Neon (PostgreSQL) jako zewnętrzna baza
  • Blob storage dla mediów (Vercel Blob, S3, Cloudinary)

Cennik

Self-hosted (Open Source)

PlanCenaFunkcje
MIT LicenseDarmowyPełny CMS, wszystkie features, bez ograniczeń

Payload Cloud

PlanCenaFunkcje
Starter$30/mo1 projekt, 10GB storage, basic support
Pro$99/mo3 projekty, 50GB storage, priority support
EnterpriseCustomUnlimited, SLA, dedicated support

FAQ - Często Zadawane Pytania

Payload vs Strapi - co wybrać?

Payload to code-first z natywnym TypeScript i integracją Next.js. Strapi to visual-first z admin UI do modelowania. Wybierz Payload gdy cenisz type safety i chcesz wszystko w kodzie. Wybierz Strapi gdy non-technical users muszą zarządzać content modelem.

Czy Payload wymaga osobnego serwera?

Nie! Od wersji 2.0 Payload może działać jako część aplikacji Next.js - admin panel i API są osadzone w tej samej aplikacji.

Jak migrować z innego CMS do Payload?

Użyj Payload Migration API lub napisz skrypt importujący dane przez Local API. Dla dużych migracji rozważ staging environment i testowanie.

Czy Payload obsługuje i18n?

Tak, Payload ma wbudowane wsparcie dla lokalizacji. Definiujesz locales w config i możesz tłumaczyć każde pole.

Jak cachować dane z Payload w Next.js?

Używaj Next.js caching z unstable_cache lub fetch with revalidate. Możesz też użyć webhooks z Payload do on-demand revalidation.

Podsumowanie

Payload CMS to idealny wybór dla deweloperów TypeScript budujących aplikacje z Next.js:

  • Code-first config - wszystko wersjonowane w Git
  • TypeScript native - pełne typy, autocomplete
  • Next.js integration - jedna aplikacja, jeden deployment
  • Local API - bezpośredni dostęp w Server Components
  • Self-hosted - pełna kontrola, zero vendor lock-in
  • Powerful access control - granularne uprawnienia

Jeśli cenisz developer experience, type safety i pełną kontrolę nad kodem, Payload to CMS stworzony dla Ciebie.


Payload CMS - a complete guide to a TypeScript-first headless CMS

What is Payload CMS?

Payload is a modern headless CMS written in TypeScript, created in 2021 by a team based in Pittsburgh (USA). Unlike other CMSs, Payload takes a code-first approach - all configuration happens in TypeScript code, not through a GUI. This makes it ideal for developers who prefer full control and versioning their configuration in Git.

Since version 2.0, Payload offers native Next.js integration - the admin panel and API can run as part of the same Next.js application. This eliminates the need to run a separate backend and simplifies deployment.

Payload stands out with its lack of vendor lock-in - you can host it on your own servers, use it with any database (MongoDB or PostgreSQL), and all the code is yours. It is open source (MIT License) with an optional Payload Cloud for managed hosting.

Why Payload CMS?

Key advantages of Payload

  1. TypeScript-first - Full types, autocomplete, type safety
  2. Code-based config - Version control in Git, code review, CI/CD
  3. Native Next.js integration - One application, one deployment
  4. Local API - Direct data access in Server Components
  5. Self-hosted - Full control, no vendor lock-in
  6. Flexible databases - MongoDB or PostgreSQL
  7. Powerful access control - Granular permissions at the field level
  8. Extensible - Hooks, plugins, custom endpoints

Payload vs other CMSs

FeaturePayloadStrapiContentfulSanity
TypeSelf-hostedSelf-hostedSaaSSaaS
ConfigCode-firstVisual + CodeVisualCode + Visual
TypeScriptNativePartialSDKSDK
Next.jsNative embedSeparateSeparateSeparate
DatabaseMongoDB/PostgreSQLSQLite/PostgreSQL/MySQLN/AN/A
Price (self-hosted)Free (MIT)Free (MIT)N/AN/A
Admin UIReact (customizable)ReactSaaSReact
Local API✅ Yes❌ No❌ No❌ No
Real-timeWebsocketsWebhooksWebhooksNative

When to choose Payload?

Payload is ideal when:

  • You work with TypeScript and Next.js
  • You prefer code-first configuration
  • You want to version your CMS config in Git
  • You need a Local API in Server Components
  • You value self-hosting with full control
  • You are building a fullstack application (frontend + CMS + API)

Consider alternatives when:

  • You need real-time collaboration → Sanity
  • A non-technical team manages the content model → Strapi, Contentful
  • You need a managed CDN without DevOps → Contentful
  • Your budget is zero and you prefer zero DevOps → Sanity (generous free tier)

Installation and setup

Creating a new project

Code
Bash
npx create-payload-app@latest my-project

# Options during installation:
# ✓ Select a project template: blank (or blog, website, ecommerce)
# ✓ Database: MongoDB or PostgreSQL
# ✓ Package manager: npm, yarn, or pnpm

Project structure

Code
TEXT
my-project/
├── app/                          # Next.js App Router
│   ├── (frontend)/              # Public pages
│   │   ├── page.tsx
│   │   └── [slug]/
│   ├── (payload)/               # Admin panel (auto-generated)
│   │   └── admin/
│   │       └── [[...segments]]/
│   │           └── page.tsx
│   └── api/                     # API routes
│       └── [...payload]/
│           └── route.ts         # Payload REST API
├── collections/                  # Collection configs
│   ├── Users.ts
│   ├── Pages.ts
│   ├── Posts.ts
│   └── Media.ts
├── globals/                      # Global configs
│   ├── Settings.ts
│   └── Navigation.ts
├── blocks/                       # Reusable content blocks
│   ├── Hero.ts
│   ├── Content.ts
│   └── CallToAction.ts
├── fields/                       # Custom field configs
│   └── slug.ts
├── hooks/                        # Lifecycle hooks
│   └── populatePublishedDate.ts
├── access/                       # Access control functions
│   ├── isAdmin.ts
│   └── isOwner.ts
├── payload.config.ts             # Main Payload config
├── payload-types.ts              # Auto-generated types
└── .env

Payload config

TSpayload.config.ts
TypeScript
// payload.config.ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
// or
// import { postgresAdapter } from '@payloadcms/db-postgres'
import { slateEditor } from '@payloadcms/richtext-slate'
// or
// import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
import path from 'path'
import sharp from 'sharp'

// Collections
import { Users } from './collections/Users'
import { Pages } from './collections/Pages'
import { Posts } from './collections/Posts'
import { Media } from './collections/Media'
import { Categories } from './collections/Categories'

// Globals
import { Settings } from './globals/Settings'
import { Navigation } from './globals/Navigation'

export default buildConfig({
  admin: {
    user: Users.slug,
    meta: {
      titleSuffix: '- My CMS',
      favicon: '/favicon.ico',
      ogImage: '/og-image.png',
    },
    components: {
      beforeDashboard: ['@/components/admin/DashboardIntro'],
    },
  },

  collections: [Users, Pages, Posts, Media, Categories],

  globals: [Settings, Navigation],

  editor: slateEditor({}),
  // editor: lexicalEditor({}),

  db: mongooseAdapter({
    url: process.env.DATABASE_URI!,
  }),
  // db: postgresAdapter({
  //   pool: {
  //     connectionString: process.env.DATABASE_URI!,
  //   },
  // }),

  upload: {
    limits: {
      fileSize: 10000000, // 10MB
    },
  },

  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },

  graphQL: {
    schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
  },

  sharp,

  plugins: [
    // uploadthingStorage({
    //   collections: {
    //     media: true,
    //   },
    //   options: {
    //     token: process.env.UPLOADTHING_TOKEN,
    //   },
    // }),
  ],

  cors: [process.env.NEXT_PUBLIC_SITE_URL || ''].filter(Boolean),
  csrf: [process.env.NEXT_PUBLIC_SITE_URL || ''].filter(Boolean),
})

Environment variables

Code
ENV
# .env
DATABASE_URI=mongodb://localhost:27017/payload
# or
# DATABASE_URI=postgresql://user:password@localhost:5432/payload

PAYLOAD_SECRET=your-super-secret-key-min-32-chars
NEXT_PUBLIC_SITE_URL=http://localhost:3000

Collections

Collections are groups of documents (similar to tables in a database).

Basic collection

TScollections/Posts.ts
TypeScript
// collections/Posts.ts
import { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  labels: {
    singular: 'Post',
    plural: 'Posts',
  },
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'publishedDate'],
    group: 'Content',
    description: 'Blog posts and articles',
  },
  access: {
    read: () => true,
    create: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user } }) => Boolean(user),
    delete: ({ req: { user } }) => user?.role === 'admin',
  },
  versions: {
    drafts: {
      autosave: {
        interval: 300, // 5 minutes
      },
    },
    maxPerDoc: 10,
  },
  hooks: {
    beforeChange: [
      ({ data, operation }) => {
        if (operation === 'create' && !data.publishedDate) {
          data.publishedDate = new Date().toISOString()
        }
        return data
      },
    ],
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      minLength: 3,
      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: 'excerpt',
      type: 'textarea',
      maxLength: 300,
    },
    {
      name: 'content',
      type: 'richText',
      required: true,
    },
    {
      name: 'featuredImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
      required: true,
      defaultValue: ({ user }) => user?.id,
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'categories',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: true,
    },
    {
      name: 'tags',
      type: 'array',
      fields: [
        {
          name: 'tag',
          type: 'text',
          required: true,
        },
      ],
    },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
        { label: 'Archived', value: 'archived' },
      ],
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'publishedDate',
      type: 'date',
      admin: {
        position: 'sidebar',
        date: {
          pickerAppearance: 'dayAndTime',
        },
      },
    },
    {
      name: 'seo',
      type: 'group',
      fields: [
        {
          name: 'metaTitle',
          type: 'text',
          maxLength: 60,
        },
        {
          name: 'metaDescription',
          type: 'textarea',
          maxLength: 160,
        },
        {
          name: 'ogImage',
          type: 'upload',
          relationTo: 'media',
        },
      ],
    },
  ],
}

Field types

Code
TypeScript
const fields = [
  // Text fields
  { name: 'title', type: 'text' },
  { name: 'description', type: 'textarea' },
  { name: 'content', type: 'richText' },
  { name: 'code', type: 'code', admin: { language: 'typescript' } },
  { name: 'email', type: 'email' },

  // Number fields
  { name: 'price', type: 'number', min: 0, max: 10000 },

  // Date fields
  { name: 'publishedAt', type: 'date' },

  // Boolean
  { name: 'featured', type: 'checkbox' },

  // Select
  {
    name: 'status',
    type: 'select',
    options: [
      { label: 'Draft', value: 'draft' },
      { label: 'Published', value: 'published' },
    ],
  },
  {
    name: 'roles',
    type: 'select',
    hasMany: true,
    options: ['admin', 'editor', 'viewer'],
  },

  // Radio
  {
    name: 'priority',
    type: 'radio',
    options: [
      { label: 'Low', value: 'low' },
      { label: 'High', value: 'high' },
    ],
  },

  // Relationship (foreign key)
  {
    name: 'author',
    type: 'relationship',
    relationTo: 'users',
  },
  {
    name: 'categories',
    type: 'relationship',
    relationTo: 'categories',
    hasMany: true,
  },
  {
    name: 'relatedContent',
    type: 'relationship',
    relationTo: ['posts', 'pages'], // Polymorphic
    hasMany: true,
  },

  // Upload (media)
  {
    name: 'image',
    type: 'upload',
    relationTo: 'media',
  },

  // Array (repeatable nested)
  {
    name: 'socialLinks',
    type: 'array',
    fields: [
      { name: 'platform', type: 'text' },
      { name: 'url', type: 'text' },
    ],
  },

  // Group (nested object)
  {
    name: 'address',
    type: 'group',
    fields: [
      { name: 'street', type: 'text' },
      { name: 'city', type: 'text' },
      { name: 'zip', type: 'text' },
    ],
  },

  // Blocks (dynamic content)
  {
    name: 'layout',
    type: 'blocks',
    blocks: [HeroBlock, ContentBlock, CTABlock],
  },

  // Tabs (UI organization)
  {
    type: 'tabs',
    tabs: [
      {
        label: 'Content',
        fields: [
          { name: 'title', type: 'text' },
          { name: 'body', type: 'richText' },
        ],
      },
      {
        label: 'SEO',
        fields: [
          { name: 'metaTitle', type: 'text' },
          { name: 'metaDescription', type: 'textarea' },
        ],
      },
    ],
  },

  // Row (horizontal layout)
  {
    type: 'row',
    fields: [
      { name: 'firstName', type: 'text' },
      { name: 'lastName', type: 'text' },
    ],
  },

  // Collapsible
  {
    type: 'collapsible',
    label: 'Advanced Options',
    fields: [
      { name: 'customCSS', type: 'code', admin: { language: 'css' } },
    ],
  },

  // Point (geo coordinates)
  {
    name: 'location',
    type: 'point',
  },

  // JSON
  {
    name: 'metadata',
    type: 'json',
  },

  // UI (display only, no data)
  {
    type: 'ui',
    name: 'divider',
    admin: {
      components: {
        Field: () => <hr />,
      },
    },
  },
]

Media collection

TScollections/Media.ts
TypeScript
// collections/Media.ts
import { CollectionConfig } from 'payload'

export const Media: CollectionConfig = {
  slug: 'media',
  labels: {
    singular: 'Media',
    plural: 'Media',
  },
  admin: {
    useAsTitle: 'filename',
    group: 'Media',
  },
  access: {
    read: () => true,
  },
  upload: {
    staticDir: 'media',
    staticURL: '/media',
    imageSizes: [
      {
        name: 'thumbnail',
        width: 400,
        height: 300,
        position: 'centre',
      },
      {
        name: 'card',
        width: 768,
        height: 1024,
        position: 'centre',
      },
      {
        name: 'feature',
        width: 1920,
        height: undefined,
        position: 'centre',
      },
    ],
    adminThumbnail: 'thumbnail',
    mimeTypes: ['image/*', 'application/pdf'],
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      required: true,
    },
    {
      name: 'caption',
      type: 'text',
    },
  ],
}

Blocks (composable content)

TSblocks/Hero.ts
TypeScript
// blocks/Hero.ts
import { Block } from 'payload'

export const HeroBlock: Block = {
  slug: 'hero',
  labels: {
    singular: 'Hero Section',
    plural: 'Hero Sections',
  },
  imageURL: '/blocks/hero.png',
  fields: [
    {
      name: 'heading',
      type: 'text',
      required: true,
    },
    {
      name: 'subheading',
      type: 'textarea',
    },
    {
      name: 'backgroundImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'cta',
      type: 'group',
      fields: [
        { name: 'label', type: 'text' },
        { name: 'link', type: 'text' },
      ],
    },
    {
      name: 'alignment',
      type: 'select',
      defaultValue: 'center',
      options: [
        { label: 'Left', value: 'left' },
        { label: 'Center', value: 'center' },
        { label: 'Right', value: 'right' },
      ],
    },
  ],
}

// blocks/Content.ts
export const ContentBlock: Block = {
  slug: 'content',
  labels: {
    singular: 'Content Block',
    plural: 'Content Blocks',
  },
  fields: [
    {
      name: 'content',
      type: 'richText',
      required: true,
    },
  ],
}

// collections/Pages.ts (using blocks)
export const Pages: CollectionConfig = {
  slug: 'pages',
  admin: {
    useAsTitle: 'title',
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'layout',
      type: 'blocks',
      blocks: [HeroBlock, ContentBlock, CTABlock, FeatureGridBlock],
      required: true,
    },
  ],
}

Access control

Payload offers granular access control at the collection, document, and field level.

TSaccess/isAdmin.ts
TypeScript
// access/isAdmin.ts
import { Access } from 'payload'

export const isAdmin: Access = ({ req: { user } }) => {
  return user?.role === 'admin'
}

// access/isAdminOrSelf.ts
export const isAdminOrSelf: Access = ({ req: { user } }) => {
  if (!user) return false
  if (user.role === 'admin') return true

  return {
    id: {
      equals: user.id,
    },
  }
}

// access/isOwner.ts
export const isOwner: Access = ({ req: { user } }) => {
  if (!user) return false
  if (user.role === 'admin') return true

  return {
    author: {
      equals: user.id,
    },
  }
}

// access/publishedOrAdmin.ts
export const publishedOrAdmin: Access = ({ req: { user } }) => {
  if (user?.role === 'admin') return true

  return {
    status: {
      equals: 'published',
    },
  }
}

Field-level access

Code
TypeScript
{
  name: 'internalNotes',
  type: 'textarea',
  access: {
    read: ({ req: { user } }) => user?.role === 'admin',
    update: ({ req: { user } }) => user?.role === 'admin',
  },
}

Collection access

Code
TypeScript
export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    // Who can read
    read: publishedOrAdmin,

    // Who can create
    create: ({ req: { user } }) => Boolean(user),

    // Who can update (with query constraint)
    update: isOwner,

    // Who can delete
    delete: isAdmin,

    // Admin UI access
    admin: ({ req: { user } }) => Boolean(user),
  },
  // ...
}

Local API

A key advantage of Payload - direct data access without HTTP.

TSapp/(frontend)/blog/page.tsx
TypeScript
// app/(frontend)/blog/page.tsx
import { getPayload } from 'payload'
import configPromise from '@payload-config'

export default async function BlogPage() {
  const payload = await getPayload({
    config: configPromise,
  })

  const { docs: posts, totalDocs } = await payload.find({
    collection: 'posts',
    where: {
      status: {
        equals: 'published',
      },
    },
    sort: '-publishedDate',
    limit: 10,
    depth: 2,
  })

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

// app/(frontend)/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const payload = await getPayload({ config: configPromise })

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

  const post = docs[0]

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <RichText content={post.content} />
    </article>
  )
}

Local API operations

Code
TypeScript
import { getPayload } from 'payload'
import configPromise from '@payload-config'

const payload = await getPayload({ config: configPromise })

// FIND (query)
const { docs, totalDocs, page, totalPages, hasNextPage } = await payload.find({
  collection: 'posts',
  where: {
    status: { equals: 'published' },
    categories: { contains: categoryId },
    publishedDate: { greater_than: '2024-01-01' },
  },
  sort: '-publishedDate',
  page: 1,
  limit: 10,
  depth: 2,
  locale: 'pl',
  fallbackLocale: 'en',
})

// FIND BY ID
const post = await payload.findByID({
  collection: 'posts',
  id: 'post-id',
  depth: 2,
})

// CREATE
const newPost = await payload.create({
  collection: 'posts',
  data: {
    title: 'New Post',
    slug: 'new-post',
    content: richTextContent,
    status: 'draft',
    author: userId,
  },
})

// UPDATE
const updatedPost = await payload.update({
  collection: 'posts',
  id: 'post-id',
  data: {
    title: 'Updated Title',
    status: 'published',
  },
})

// UPDATE MANY
const { docs: updated } = await payload.update({
  collection: 'posts',
  where: {
    status: { equals: 'draft' },
  },
  data: {
    status: 'archived',
  },
})

// DELETE
await payload.delete({
  collection: 'posts',
  id: 'post-id',
})

// DELETE MANY
await payload.delete({
  collection: 'posts',
  where: {
    status: { equals: 'archived' },
  },
})

// COUNT
const count = await payload.count({
  collection: 'posts',
  where: {
    status: { equals: 'published' },
  },
})

// GLOBALS
const settings = await payload.findGlobal({
  slug: 'settings',
})

await payload.updateGlobal({
  slug: 'settings',
  data: {
    siteName: 'Updated Site Name',
  },
})

Query operators

Code
TypeScript
const { docs } = await payload.find({
  collection: 'posts',
  where: {
    // Equality
    status: { equals: 'published' },
    featured: { equals: true },

    // Not equal
    status: { not_equals: 'draft' },

    // Comparison (numbers, dates)
    views: { greater_than: 100 },
    views: { greater_than_equal: 100 },
    views: { less_than: 1000 },
    views: { less_than_equal: 1000 },
    publishedDate: { greater_than: '2024-01-01' },

    // Text search
    title: { contains: 'JavaScript' },
    title: { like: '%JavaScript%' }, // SQL-like

    // Existence
    featuredImage: { exists: true },

    // Array operations
    tags: { in: ['javascript', 'typescript'] },
    tags: { not_in: ['deprecated'] },
    categories: { contains: 'category-id' },
    categories: { all: ['cat1', 'cat2'] }, // All must match

    // Logical operators
    or: [
      { status: { equals: 'published' } },
      { author: { equals: currentUserId } },
    ],

    and: [
      { status: { equals: 'published' } },
      { publishedDate: { less_than: new Date().toISOString() } },
    ],
  },
})

REST API

Payload automatically generates a REST API.

Code
TypeScript
// Endpoints for the "posts" collection:
GET    /api/posts           // Find all
GET    /api/posts/:id       // Find by ID
POST   /api/posts           // Create
PATCH  /api/posts/:id       // Update
DELETE /api/posts/:id       // Delete

// Query parameters
GET /api/posts?where[status][equals]=published
GET /api/posts?sort=-publishedDate
GET /api/posts?limit=10&page=2
GET /api/posts?depth=2

// Globals
GET  /api/globals/settings
POST /api/globals/settings

// Auth
POST /api/users/login
POST /api/users/logout
POST /api/users/me
POST /api/users/forgot-password
POST /api/users/reset-password

Fetching from the REST API

Code
TypeScript
const response = await fetch('/api/posts?where[status][equals]=published&limit=10', {
  headers: {
    'Content-Type': 'application/json',
    // Auth (if required)
    'Authorization': `JWT ${token}`,
  },
})

const { docs, totalDocs, page } = await response.json()

// Create
const newPost = await fetch('/api/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `JWT ${token}`,
  },
  body: JSON.stringify({
    title: 'New Post',
    slug: 'new-post',
    status: 'draft',
  }),
})

Hooks

Lifecycle hooks allow you to add custom logic.

TShooks/populateSlug.ts
TypeScript
// hooks/populateSlug.ts
import { FieldHook } from 'payload'

export const populateSlug: FieldHook = ({ value, data }) => {
  if (!value && data?.title) {
    return data.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')
  }
  return value
}

// hooks/sendNotification.ts
import { CollectionAfterChangeHook } from 'payload'

export const sendNotification: CollectionAfterChangeHook = async ({
  doc,
  operation,
  req,
}) => {
  if (operation === 'create') {
    await fetch('https://hooks.example.com/new-post', {
      method: 'POST',
      body: JSON.stringify({
        title: doc.title,
        author: doc.author,
      }),
    })
  }

  return doc
}

// Collection with hooks
export const Posts: CollectionConfig = {
  slug: 'posts',
  hooks: {
    // Before operations
    beforeValidate: [validateData],
    beforeChange: [populatePublishedDate],
    beforeDelete: [archiveBeforeDelete],
    beforeRead: [restrictDrafts],

    // After operations
    afterChange: [sendNotification, revalidateCache],
    afterDelete: [cleanupRelated],
    afterRead: [transformData],

    // Auth hooks (Users collection)
    afterLogin: [logLogin],
    afterLogout: [logLogout],
    afterForgotPassword: [sendResetEmail],
  },
  fields: [
    {
      name: 'slug',
      type: 'text',
      hooks: {
        beforeValidate: [populateSlug],
      },
    },
  ],
}

Globals

Single documents (not collections).

TSglobals/Settings.ts
TypeScript
// globals/Settings.ts
import { GlobalConfig } from 'payload'

export const Settings: GlobalConfig = {
  slug: 'settings',
  label: 'Site Settings',
  admin: {
    group: 'Config',
  },
  access: {
    read: () => true,
    update: ({ req: { user } }) => user?.role === 'admin',
  },
  fields: [
    {
      name: 'siteName',
      type: 'text',
      required: true,
    },
    {
      name: 'siteDescription',
      type: 'textarea',
    },
    {
      name: 'logo',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'socialLinks',
      type: 'array',
      fields: [
        {
          name: 'platform',
          type: 'select',
          options: ['twitter', 'facebook', 'instagram', 'linkedin'],
        },
        {
          name: 'url',
          type: 'text',
        },
      ],
    },
    {
      name: 'footer',
      type: 'group',
      fields: [
        { name: 'copyright', type: 'text' },
        { name: 'showSocialLinks', type: 'checkbox' },
      ],
    },
  ],
}

// Usage
const settings = await payload.findGlobal({ slug: 'settings' })
console.log(settings.siteName)

Rich text rendering

TScomponents/RichText.tsx
TypeScript
// components/RichText.tsx
import React from 'react'
import { SerializedEditorState } from 'lexical'
// or for Slate:
// import { Descendant } from 'slate'

interface RichTextProps {
  content: SerializedEditorState | null
}

// For Lexical (default in Payload 3.0)
export function RichText({ content }: RichTextProps) {
  if (!content) return null

  return (
    <div className="prose prose-lg max-w-none">
      <LexicalContent content={content} />
    </div>
  )
}

// Alternatively - custom rendering
import {
  JSXConvertersFunction,
  RichText as PayloadRichText,
} from '@payloadcms/richtext-lexical/react'

const converters: JSXConvertersFunction = ({ defaultConverters }) => ({
  ...defaultConverters,
  blocks: {
    ...defaultConverters.blocks,
    codeBlock: ({ node }) => (
      <pre className="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto">
        <code>{node.fields.code}</code>
      </pre>
    ),
  },
})

export function RichTextContent({ content }: RichTextProps) {
  return (
    <PayloadRichText
      data={content}
      converters={converters}
    />
  )
}

Deployment

Self-hosted with Docker

Code
DOCKERFILE
# Dockerfile
FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/media ./media

EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml
YAML
# docker-compose.yml
version: '3'

services:
  app:
    build: .
    ports:
      - '3000:3000'
    environment:
      DATABASE_URI: mongodb://mongo:27017/payload
      PAYLOAD_SECRET: your-secret-key
      NEXT_PUBLIC_SITE_URL: http://localhost:3000
    depends_on:
      - mongo
    volumes:
      - ./media:/app/media

  mongo:
    image: mongo:7
    volumes:
      - mongo_data:/data/db

volumes:
  mongo_data:

Payload Cloud

Code
Bash
npx payload cloud:login

npx payload cloud:deploy

Vercel

Code
Bash
npm i -g vercel

vercel

Keep in mind:

  • MongoDB Atlas or Neon (PostgreSQL) as an external database
  • Blob storage for media (Vercel Blob, S3, Cloudinary)

Pricing

Self-hosted (open source)

PlanPriceFeatures
MIT LicenseFreeFull CMS, all features, no limitations

Payload Cloud

PlanPriceFeatures
Starter$30/mo1 project, 10GB storage, basic support
Pro$99/mo3 projects, 50GB storage, priority support
EnterpriseCustomUnlimited, SLA, dedicated support

FAQ - frequently asked questions

Payload vs Strapi - which one to choose?

Payload is code-first with native TypeScript and Next.js integration. Strapi is visual-first with an admin UI for content modeling. Choose Payload when you value type safety and want everything in code. Choose Strapi when non-technical users need to manage the content model.

Does Payload require a separate server?

No! Since version 2.0, Payload can run as part of a Next.js application - the admin panel and API are embedded in the same app.

How to migrate from another CMS to Payload?

Use the Payload Migration API or write a script that imports data via the Local API. For large migrations, consider a staging environment and thorough testing.

Does Payload support i18n?

Yes, Payload has built-in localization support. You define locales in the config and can translate every field.

How to cache data from Payload in Next.js?

Use Next.js caching with unstable_cache or fetch with revalidate. You can also use webhooks from Payload for on-demand revalidation.

Summary

Payload CMS is the ideal choice for TypeScript developers building applications with Next.js:

  • Code-first config - everything versioned in Git
  • TypeScript native - full types, autocomplete
  • Next.js integration - one application, one deployment
  • Local API - direct access in Server Components
  • Self-hosted - full control, zero vendor lock-in
  • Powerful access control - granular permissions

If you value developer experience, type safety, and full control over your code, Payload is the CMS built for you.