Quick answer: Scaffold with npx create-payload-app, choose Postgres, MongoDB or SQLite, then define your content in code: a collection is an object with a slug, an access block and a fields array, and Payload generates the database schema, the admin UI at /admin and the TypeScript types from it. Read content back with the Local API (getPayload plus payload.find) inside a React Server Component. You need Node.js 20.18.1+ and a supported Next.js version.
Part 2 of our Payload CMS series. New to the framework? Read what Payload CMS is first.
1. Check your versions before anything else
Payload is strict about its peer versions, and a mismatch produces confusing admin-panel errors rather than a clear failure.
| Requirement | Supported |
|---|---|
| Node.js | 20.18.1 or newer |
| Next.js | 15.2.9–15.2.x, 15.3.9–15.3.x, 15.4.11–15.4.x, or 16.2.6+ |
| Package manager | pnpm (preferred), npm, or yarn 2+ — yarn 1.x is not supported |
| Database | Postgres, MongoDB or SQLite |
node --version # v20.18.1 or newer
pnpm --version # optional but recommended
2. Scaffold the project
npx create-payload-app@latest my-cms
cd my-cms
The prompts ask for a project name, a database adapter, and whether to include a template such as the website starter. Choose Postgres for a relational schema you can query with SQL later, MongoDB if your documents are deeply nested, or SQLite for a local prototype that needs zero infrastructure. Then start it:
pnpm dev
Open http://localhost:3000/admin. On a fresh database Payload asks you to create the first admin user — that is your login, so do it before sharing the URL.
Adding Payload to an existing Next.js app
You can skip the scaffolder. Install the packages, then wire the three files:
pnpm i payload @payloadcms/next @payloadcms/richtext-lexical @payloadcms/db-postgres sharp graphql
// next.config.mjs — the Payload plugin is ESM, so use .mjs or "type": "module"
import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = { /* your existing config */ }
export default withPayload(nextConfig)
// tsconfig.json — Payload resolves the config through this path alias
{ "compilerOptions": { "paths": { "@payload-config": ["./payload.config.ts"] } } }
Then copy the (payload) route group from the official blank template into your app/ folder and move your own routes into a second group such as (frontend). Those (payload) files are boilerplate: they mount the admin panel and the REST/GraphQL routes, and you never edit them.
3. Your first collection
Create collections/Posts.ts. This single file defines the database table, the admin form, the API shape and the types.
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'updatedAt'],
},
access: {
// signed-in users see everything, visitors only published posts
read: ({ req: { user } }) =>
user ? true : { status: { equals: 'published' } },
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => user?.role === 'admin',
},
hooks: {
beforeChange: [
({ data }) => {
if (data?.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true, admin: { position: 'sidebar' } },
{ name: 'content', type: 'richText' },
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: ['draft', 'published'],
admin: { position: 'sidebar' },
},
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
A users collection is one line of extra config — auth: true turns it into a login system with sessions, password hashing and role fields:
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: { useAsTitle: 'email' },
fields: [
{ name: 'name', type: 'text' },
{
name: 'role',
type: 'select',
defaultValue: 'editor',
options: ['admin', 'editor'],
saveToJWT: true,
},
],
}
saveToJWT: true puts the role into the session token, which is what makes user?.role === 'admin' cheap in access functions.
4. Wire it into the config
// payload.config.ts
import { buildConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { postgresAdapter } from '@payloadcms/db-postgres'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Posts } from './collections/Posts'
export default buildConfig({
editor: lexicalEditor(),
collections: [Users, Posts],
secret: process.env.PAYLOAD_SECRET || '',
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
}),
sharp,
admin: { user: 'users' },
})
# .env
PAYLOAD_SECRET=a-long-random-unguessable-string
DATABASE_URL=postgres://user:password@localhost:5432/my_cms
In development the Postgres adapter pushes schema changes automatically, so a new field appears without a migration. That convenience is development-only — production needs real migrations:
npx payload migrate:create add_posts
npx payload migrate
Run migrate:create in CI or on deploy whenever the schema changes, or production will quietly lack the column your code expects. If you plan to query with Drizzle directly, also run npx payload generate:db-schema and import from the generated file.
5. Add a global for site-wide content
import type { GlobalConfig } from 'payload'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: { read: () => true, update: ({ req: { user } }) => Boolean(user) },
fields: [
{ name: 'siteName', type: 'text', required: true },
{ name: 'footerText', type: 'text' },
],
}
Add it to globals: [SiteSettings] in the config, then read it with await payload.findGlobal({ slug: 'site-settings' }).
6. Query the content from a React Server Component
// app/(frontend)/blog/page.tsx
import config from '@payload-config'
import { getPayload } from 'payload'
export default async function BlogIndex() {
const payload = await getPayload({ config })
const { docs, totalDocs } = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
sort: '-publishedAt',
limit: 10,
depth: 1, // populate one level of relationships
overrideAccess: true,
})
return (
<main>
<h1>Blog ({totalDocs})</h1>
<ul>
{docs.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
)
}
That await happens on the server, inside your app, against the database directly — no HTTP request to a CMS, no API key, no cold-start penalty on a third-party host. The same data is available over REST and GraphQL when a non-server client needs it:
curl "http://localhost:3000/api/posts?where[status][equals]=published&sort=-publishedAt&limit=10&depth=1"
# http://localhost:3000/api/graphql-playground
query {
Posts(where: { status: { equals: published } }, sort: "-publishedAt", limit: 10) {
docs { id title slug }
}
}
7. The errors you will actually hit
| Symptom | Cause and fix |
|---|---|
| Admin panel 404 | The (payload) route group is missing from app/, or your own routes were not moved into another group |
require is not defined / ESM error | withPayload imported from a CommonJS next.config.js — rename it next.config.mjs or set "type": "module" |
| Peer-dependency errors on install | Your Next.js version is outside the supported ranges — pin a supported minor |
| Admin loads but every page errors | PAYLOAD_SECRET is empty, or DATABASE_URL points at a database that does not exist |
| Field added, column missing in production | You pushed in development but never ran payload migrate in production |
| Visitors can read drafts | The read access rule returns true instead of a query constraint |
| Content invisible to a logged-in user | Access ran with the default overrideAccess: true in the Local API — pass false and a user |
Key takeaways and challenge
- Verify Node 20.18.1+ and a supported Next.js minor before installing; unsupported versions fail in confusing ways.
- One collection file gives you schema, admin UI, validation, API and types.
auth: trueon a users collection is a complete login system;saveToJWTmakes roles cheap to check.- Development pushes schema automatically; production requires
payload migrate. - Read content with the Local API in server components — no network hop, no API keys.
- Access control returns
true,falseor a query constraint. Use the query form for anything public.
Challenge: extend the Posts collection with a tags field of type array containing a label text field and a category relationship to a new Categories collection. Then add a beforeChange hook that stamps publishedAt only when status becomes published — and write down, before you code it, exactly which access rule stops publishedAt from being edited by an editor who is not an admin.
Want help modelling a real content schema, or moving a site onto Payload? Ampersand Academy offers one-to-one TypeScript and Next.js mentoring.
How do I install Payload CMS?
Run npx create-payload-app and answer the prompts, or add payload and @payloadcms/next plus a database adapter to an existing Next.js app, wrap your Next config with withPayload and create payload.config.ts.
What Node.js version does Payload need?
Node.js 20.18.1 or newer, as listed in its installation requirements. An older runtime may install successfully and then fail at runtime, so check your Node version before you scaffold.
Where is the Payload admin panel?
At the /admin route of your application, for example http://localhost:3000/admin in development. On an empty database the first visit asks you to create the initial admin user.
How do I run database migrations in Payload?
Development mode pushes schema changes automatically, but production does not. Generate a migration with npx payload migrate:create, then apply pending migrations with npx payload migrate during deployment.
Why can visitors see my draft posts?
Your read access rule is probably returning true for everyone. Return a query constraint instead, such as an object that requires status to equal published, so drafts never leave the database for anonymous requests.

