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:

FeatureStrapi v4Payload v3
Config formatJSON (admin UI)TypeScript (code)
Version controlHard — JSON diffsEasy — TS files in git
Next.js integrationSeparate serverRuns inside Next.js
Custom endpointsPlugin systemExpress routes
Rich text editorBasic WYSIWYGLexical (block-based)
AuthBuilt-in, limitedBuilt-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

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.

Related Articles