Payload CMS v3: Headless CMS That Actually Integrates with Next.js
I ran Strapi v4 for two years on a content site with 5K articles. The JSON-based config was a nightmare to version control. Every time a teammate changed a content type in the admin UI, we had to manually sync it to git. Payload CMS v3 takes the opposite approach: code-first, TypeScript config, and it runs inside your Next.js app. Not alongside it — inside it. Here's how the migration went.
Why Payload v3 Over Strapi
Side-by-side comparison from my actual migration:
| Feature | Strapi v4 | Payload v3 |
|---|---|---|
| Config format | JSON (admin UI) | TypeScript (code) |
| Version control | Hard — JSON diffs | Easy — TS files in git |
| Next.js integration | Separate server | Runs inside Next.js |
| Custom endpoints | Plugin system | Express routes |
| Rich text editor | Basic WYSIWYG | Lexical (block-based) |
| Auth | Built-in, limited | Built-in, extensible |
Code-First Config That Makes Sense
Every content type is a TypeScript file. Here's a blog post collection:
// src/collections/Posts.ts
import { CollectionConfig } from "payload"
export const Posts: CollectionConfig = {
slug: "posts",
labels: { singular: "Post", plural: "Posts" },
admin: {
defaultColumns: ["title", "status", "publishedAt"],
useAsTitle: "title",
},
access: {
read: () => true, // Public read
create: ({ req: { user } }) => user?.role === "editor" || user?.role === "admin",
update: ({ req: { user }, data }) => {
// Editors can only update their own drafts; admins can update anything
if (user?.role === "admin") return true
return data.author === user.id && data.status === "draft"
},
},
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", unique: true, index: true },
{
name: "content",
type: "richText",
editor: "lexical", // Block-based editor
},
{
name: "featuredImage",
type: "upload",
relationTo: "media",
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
},
{
name: "publishedAt",
type: "date",
admin: { condition: (data) => data.status === "published" },
},
{
name: "tags",
type: "relationship",
relationTo: "tags",
hasMany: true,
},
],
hooks: {
beforeChange: [
({ data }) => {
// Auto-set publishedAt when status changes to published
if (data.status === "published" && !data.publishedAt) {
data.publishedAt = new Date().toISOString()
}
return data
},
],
},
}
This is just a TypeScript file in my project. It's version-controlled, type-checked, and I can refactor it like any other code. In Strapi, this would be a JSON blob edited through a web UI.
Problem
Upgrading from Payload v2 to v3. All relationship fields returned null after migration. The data was in the database but the API responses were empty. This affected every collection that had type: "relationship".
What I Tried
Tried running the migration script again. Tried re-saving each collection through the admin UI. Checked the database directly — the foreign key values were there.
Actual Fix
Payload v3 changed how relationships are stored. In v2, relationships used a relationTo field alongside the ID. In v3, they use a unified relationship format. I had to run the migration script with the --rebuild flag:
# Run the v2-to-v3 migration with rebuild
npx payload migrate:fresh --rebuild
# If that doesn't work, manually update relationship format
# v2 stored: { value: "abc123", relationTo: "users" }
# v3 stores: "abc123" (with relationTo defined in config)
# For large databases, use the batch migration:
npx payload migrate:relationships --batch-size 1000
Building a Blog with Next.js App Router
Payload v3 runs inside Next.js. No separate server. Here's the setup:
# Create Payload + Next.js project
npx create-payload-app@latest my-blog --template blog
// src/app/(frontend)/blog/[slug]/page.tsx
import { getPayload } from "payload"
import config from "@payload-config"
import { notFound } from "next/navigation"
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const payload = await getPayload({ config })
const posts = await payload.find({
collection: "posts",
where: { slug: { equals: slug }, status: { equals: "published" } },
depth: 2, // Populate relationships (author, tags)
limit: 1,
})
const post = posts.docs[0]
if (!post) notFound()
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-500 mb-8">
By {(post.author as any).name} • {new Date(post.publishedAt).toLocaleDateString()}
</div>
<div className="prose max-w-none">
{/* Lexical content rendered as React components */}
<RichText content={post.content} />
</div>
</article>
)
}
// Generate static pages at build time
export async function generateStaticParams() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: "posts",
where: { status: { equals: "published" } },
limit: 1000,
})
return posts.docs.map((post) => ({ slug: post.slug }))
}
Custom Access Control Rules
Payload's access control is per-field and per-operation. Here's a real example — a multi-tenant CMS where each organization can only see its own content:
// src/collections/Posts.ts — access control
import { Access } from "payload"
const canReadPublishedPosts: Access = ({ req: { user } }) => {
// Public users can read published posts
if (!user) return { status: { equals: "published" } }
// Admins can read everything
if (user.role === "admin") return true
// Editors can read their org's posts (any status)
return { "organization.id": { equals: user.organization.id } }
}
const canUpdatePosts: Access = ({ req: { user }, data, id }) => {
if (!user) return false
if (user.role === "admin") return true
// Editors can only edit their own posts in draft status
return {
and: [
{ author: { equals: user.id } },
{ status: { equals: "draft" } },
{ "organization.id": { equals: user.organization.id } },
],
}
}
export const Posts: CollectionConfig = {
slug: "posts",
access: {
read: canReadPublishedPosts,
update: canUpdatePosts,
create: ({ req: { user } }) => !!user && ["editor", "admin"].includes(user.role),
delete: ({ req: { user } }) => user?.role === "admin",
},
fields: [
// ... fields
{
name: "organization",
type: "relationship",
relationTo: "organizations",
access: {
// Only admins can change the organization
update: ({ req: { user } }) => user?.role === "admin",
},
},
],
}
Problem
Created custom Lexical blocks for code snippets and callouts in the admin editor. They showed up fine in the admin UI but rendered as empty <div> elements on the frontend. The JSON data was there — the frontend just didn't know how to render the custom block types.
Actual Fix
You need to register the same custom blocks in your frontend RichText component. The admin and frontend are separate rendering contexts:
// src/components/RichText.tsx
import { RichText as BaseRichText } from "@payloadcms/richtext-lexical/react"
// Define your custom block components
const customBlocks = {
codeBlock: ({ fields }) => (
<div className="bg-gray-900 text-green-400 p-4 rounded-lg my-4">
<code>{fields.code}</code>
</div>
),
callout: ({ fields }) => (
<div className={`p-4 rounded-lg border-l-4 my-4 ${
fields.type === "warning" ? "bg-yellow-50 border-yellow-400" : "bg-blue-50 border-blue-400"
}`}>
<p>{fields.message}</p>
</div>
),
}
export function RichText({ content }) {
return <BaseRichText elements={customBlocks} data={content} />
}
What I Learned
- Code-first config is the right call. TypeScript collections in git > JSON config edited in a browser. Period.
- Payload running inside Next.js eliminates the two-server problem. One deployment, one domain, one set of env vars.
- The Lexical editor is powerful but needs frontend setup. Custom blocks need to be registered in both admin and frontend.
- Access control per field is underrated. Being able to restrict individual fields, not just whole collections, enables real multi-tenant setups.
- Migration from v2 needs planning. Budget a full day for the v2-to-v3 migration on a production database.
Wrapping Up
Payload CMS v3 is what I wish Strapi had been. The code-first approach means my CMS config lives in git alongside my code. The Next.js integration means one deployment instead of two. And the access control system is flexible enough for real multi-tenant content management. If you're building a content site or blog with Next.js in 2026, Payload v3 is the best CMS option.