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
- TypeScript-first - Pełne typy, autocomplete, bezpieczeństwo typów
- Code-based config - Wersjonowanie w Git, review, CI/CD
- Native Next.js integration - Jedna aplikacja, jeden deployment
- Local API - Bezpośredni dostęp do danych w Server Components
- Self-hosted - Pełna kontrola, bez vendor lock-in
- Flexible databases - MongoDB lub PostgreSQL
- Powerful access control - Granularne uprawnienia na poziomie pól
- Extensible - Hooks, plugins, custom endpoints
Payload vs Inne CMS
| Cecha | Payload | Strapi | Contentful | Sanity |
|---|---|---|---|---|
| Typ | Self-hosted | Self-hosted | SaaS | SaaS |
| Config | Code-first | Visual + Code | Visual | Code + Visual |
| TypeScript | Native | Partial | SDK | SDK |
| Next.js | Native embed | Separate | Separate | Separate |
| Database | MongoDB/PostgreSQL | SQLite/PostgreSQL/MySQL | N/A | N/A |
| Cena (self-hosted) | Darmowy (MIT) | Darmowy (MIT) | N/A | N/A |
| Admin UI | React (customizable) | React | SaaS | React |
| Local API | ✅ Tak | ❌ Nie | ❌ Nie | ❌ Nie |
| Real-time | Websockets | Webhooks | Webhooks | Native |
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
# 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 pnpmStruktura projektu
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
└── .envPayload Config
// 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
# .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:3000Collections
Collections to kolekcje dokumentów (jak tabele w bazie danych).
Podstawowa Collection
// 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
// 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
// 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)
// 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.
// 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
{
name: 'internalNotes',
type: 'textarea',
access: {
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
}Collection access
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.
// 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
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
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.
// 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-passwordFetch z REST API
// 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ę.
// 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).
// 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
// 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
# 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 /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/media ./media
EXPOSE 3000
CMD ["node", "server.js"]# 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
# Login
npx payload cloud:login
# Deploy
npx payload cloud:deployVercel
# Install Vercel CLI
npm i -g vercel
# Deploy
vercelPamię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)
| Plan | Cena | Funkcje |
|---|---|---|
| MIT License | Darmowy | Pełny CMS, wszystkie features, bez ograniczeń |
Payload Cloud
| Plan | Cena | Funkcje |
|---|---|---|
| Starter | $30/mo | 1 projekt, 10GB storage, basic support |
| Pro | $99/mo | 3 projekty, 50GB storage, priority support |
| Enterprise | Custom | Unlimited, 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
- TypeScript-first - Full types, autocomplete, type safety
- Code-based config - Version control in Git, code review, CI/CD
- Native Next.js integration - One application, one deployment
- Local API - Direct data access in Server Components
- Self-hosted - Full control, no vendor lock-in
- Flexible databases - MongoDB or PostgreSQL
- Powerful access control - Granular permissions at the field level
- Extensible - Hooks, plugins, custom endpoints
Payload vs other CMSs
| Feature | Payload | Strapi | Contentful | Sanity |
|---|---|---|---|---|
| Type | Self-hosted | Self-hosted | SaaS | SaaS |
| Config | Code-first | Visual + Code | Visual | Code + Visual |
| TypeScript | Native | Partial | SDK | SDK |
| Next.js | Native embed | Separate | Separate | Separate |
| Database | MongoDB/PostgreSQL | SQLite/PostgreSQL/MySQL | N/A | N/A |
| Price (self-hosted) | Free (MIT) | Free (MIT) | N/A | N/A |
| Admin UI | React (customizable) | React | SaaS | React |
| Local API | ✅ Yes | ❌ No | ❌ No | ❌ No |
| Real-time | Websockets | Webhooks | Webhooks | Native |
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
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 pnpmProject structure
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
└── .envPayload config
// 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
# .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:3000Collections
Collections are groups of documents (similar to tables in a database).
Basic collection
// 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
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
// 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)
// 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.
// 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
{
name: 'internalNotes',
type: 'textarea',
access: {
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
}Collection access
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.
// 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
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
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.
// 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-passwordFetching from the REST API
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.
// 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).
// 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
// 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
# 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 /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/media ./media
EXPOSE 3000
CMD ["node", "server.js"]# 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
npx payload cloud:login
npx payload cloud:deployVercel
npm i -g vercel
vercelKeep in mind:
- MongoDB Atlas or Neon (PostgreSQL) as an external database
- Blob storage for media (Vercel Blob, S3, Cloudinary)
Pricing
Self-hosted (open source)
| Plan | Price | Features |
|---|---|---|
| MIT License | Free | Full CMS, all features, no limitations |
Payload Cloud
| Plan | Price | Features |
|---|---|---|
| Starter | $30/mo | 1 project, 10GB storage, basic support |
| Pro | $99/mo | 3 projects, 50GB storage, priority support |
| Enterprise | Custom | Unlimited, 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.