Skip to main content

Next.js Integration — A Deep Dive on Co-location

Goals for this chapter

  • Understand why co-location is the architectural shift, in contrast to 2.x and before
  • Get the file-level layout of app/(payload)/ into your head
  • Be able to write the canonical pattern: call getPayload() to get the Local API from a Server Component
  • Have an operating-level grasp of the Next.js cache (revalidateTag) loop, Lexical content rendering, Live Preview, and Draft Preview

This chapter is heavier than the others (12 sections). Reading it through should leave you able to picture "I install Payload into my existing Next.js app and run it."

6.1 Why co-location — what changed against 2.x

PayloadCMS through 1.x was an Express-based standalone CMS server, talking to the frontend over HTTP. The 2.x line introduced Lexical / Postgres / Live Preview, and 3.0 (2024-11-19) completed the Next.js-native architecture.

What you gain from co-location:

  • One deploy — frontend and CMS build and host together
  • One set of environment variablesPAYLOAD_SECRET and DATABASE_URL sit alongside your frontend env
  • Shared typespayload-types.ts is importable directly from the frontend
  • No HTTP hop for data accesspayload.find() from a Server Component instead of fetch

The trade-offs are equally clear:

  • You inherit Next.js hosting constraints (no Edge runtime, Node required)
  • The bundle grows
  • The separation of frontend and backend concerns now rests on team discipline

If you'd rather keep them physically split, you can — but this series assumes co-location.

6.2 Directory layout, file by file

According to the official docs (Installation), Payload files live under the app/(payload)/ route group, while the frontend lives in a separate group like app/(frontend)/.

my-nextjs-app/
├── app/
│ ├── (payload)/ ← Payload route group (do not edit — keep the Blank Template layout)
│ │ ├── admin/ ← Admin UI routes (catch-alls etc.)
│ │ ├── api/ ← REST / GraphQL endpoints
│ │ ├── custom.scss ← CSS override for the Admin UI
│ │ └── layout.tsx ← Layout for Payload
│ ├── (frontend)/ ← Public site route group
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── ...
├── instrumentation.ts ← Bootstraps Payload via Next.js register()
├── next.config.mjs ← Wrapped in withPayload()
├── payload.config.ts ← The Payload config itself
├── payload-types.ts ← Generated types (re-generate with `yarn generate:types`)
├── tsconfig.json
└── package.json

The official docs state that "files under app/(payload)/ are fixed and need no editing." For the actual file names and subdirectory layout, the canonical reference is the Blank Template generated by create-payload-app. The frontend lives in (frontend)/ (any name you like); the route-group parentheses make the segment invisible in the URL, so both halves stay cleanly split.

6.3 payload.config.ts — role and a minimal example (co-located edition)

import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

export default buildConfig({
secret: process.env.PAYLOAD_SECRET!,
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL! },
}),
editor: lexicalEditor({}),
collections: [
{
slug: 'users',
auth: true,
fields: [],
},
{
slug: 'posts',
versions: { drafts: true },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'body', type: 'richText' },
],
},
{
slug: 'media',
upload: { staticDir: path.resolve(dirname, '../media') },
fields: [{ name: 'alt', type: 'text' }],
},
],
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

secret / db are required, editor activates Lexical, and typescript.outputFile tells Payload where to emit the generated types. That's the canonical shape for the co-located setup.

6.4 The Local API via getPayload() (the Server Component pattern)

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

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

const { docs } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-createdAt',
limit: 20,
depth: 2,
})

return (
<main>
{docs.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</main>
)
}

Key points (following the official website template pattern):

  • import configPromise from '@payload-config' — set the path alias in tsconfig.json ("paths": { "@payload-config": ["./payload.config.ts"] })
  • await getPayload({ config: configPromise }) — call inside a Server Component or Server Action. No HTTP hop
  • The return type is inferred from payload-types, so docs is effectively Post[]

Server Actions follow the exact same pattern with payload.create() / payload.update() (see below).

Why this example returns only published: the where clause

What narrows this to published data is the hand-written _status filter in where — not the access functions from chapter 04. The Local API bypasses Access Control by default (details in chapter 05). To delegate the narrowing to your access functions, pass overrideAccess: false explicitly — the implementation example at the end of this chapter does exactly that.

6.5 Environment variables and runtime

Minimum .env.local:

PAYLOAD_SECRET=a long random string
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb

Runtime:

  • Node.js 20.9.0+ required (Installation)
  • Does not run on Edge runtime (it won't drop onto Cloudflare Pages Workers as-is)
  • Default Node runtime for Server Components and API routes is fine — do not add export const runtime = 'edge'

If you ship middleware.ts, the conventional advice is to exclude the Admin UI (/admin) and APIs (/api) from the matcher so middleware doesn't interfere with Payload (the official docs don't ship a canonical pattern — adjust to your project's needs):

// middleware.ts (idiomatic — tune to your project)
export const config = {
matcher: ['/((?!admin|api|_next).*)'],
}

6.6 Adding Payload to an existing Next.js project

Per the official docs:

  1. Install packages: pnpm i payload @payloadcms/next @payloadcms/db-postgres @payloadcms/richtext-lexical
  2. Place Payload files under app/(payload)/ (create-payload-app can do this for you)
  3. Wrap next.config.mjs in withPayload()
  4. Create payload.config.ts
  5. (Optional) Add instrumentation.ts to bring up Payload at startup — not included in the official Blank Template; only useful if you want to avoid cold starts
  6. Visit http://localhost:3000/admin and create the first user

next.config.mjs before / after:

// Before
export default {
// existing Next.js config
}

// After
import { withPayload } from '@payloadcms/next/withPayload'

export default withPayload({
// existing Next.js config
})

The official docs explicitly state "Payload is a fully ESM project"next.config must use the .mjs extension, or package.json must have "type": "module".

6.7 What instrumentation.ts is for (optional)

Next.js's instrumentation.ts exports a register() function that runs exactly once when a new server instance starts up (Next.js: File-system conventions: instrumentation.js). It's guaranteed to complete before the server takes requests.

Important: the official PayloadCMS templates (Blank / Website / with-postgres, …) do not include instrumentation.ts. Payload is designed to lazy initialize on the first request.

So this section is "an optional optimization to avoid cold starts" — a community-circulated pattern:

// instrumentation.ts (optional — not in the official templates)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { getPayload } = await import('payload')
const configPromise = (await import('@payload-config')).default
await getPayload({ config: configPromise })
}
}

The NEXT_RUNTIME === 'nodejs' guard keeps it from running on Edge runtime (Payload needs Node). This avoids the "cold initialize" on the first request. You can skip this entirely if cold starts aren't a concern for your project.

6.8 Connecting cache to data changes

Server Components follow Next.js's default cache behavior. After updating data, you have to invalidate the matching cache or the post you publish in the Admin UI won't appear on the public site.

Invalidating from a Server Action

// app/(frontend)/posts/actions.ts
'use server'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { revalidateTag } from 'next/cache'

export async function createPost(data: { title: string; body: string }) {
const payload = await getPayload({ config: configPromise })
await payload.create({ collection: 'posts', data })
revalidateTag('posts')
}

Invalidating from a Hook (when editors write in the Admin UI)

Reprising the pattern from chapter 04 — Hooks. The moment an editor clicks "Publish" in the Admin UI, the cache invalidates, and the next Server Component request will see fresh data:

// collections/Posts.ts
import { revalidateTag } from 'next/cache'

export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
afterChange: [
({ doc }) => {
revalidateTag('posts')
revalidateTag(`post-${doc.slug}`)
},
],
},
// ...
}

Pairing with unstable_cache

import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import configPromise from '@payload-config'

const getPublishedPosts = unstable_cache(
async () => {
const payload = await getPayload({ config: configPromise })
return payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
})
},
['published-posts'],
{ tags: ['posts'], revalidate: 3600 },
)

Now revalidateTag('posts') busts this cache. For more, see Next.js Caching.

6.9 Sharing TypeScript types

As covered in chapter 05 — APIs, Payload emits payload-types.ts:

yarn payload generate:types

With co-location, point the output anywhere in the project and set an alias, and you can import from the frontend directly:

// Component on the public site
import type { Post } from '@/payload-types'

export function PostCard({ post }: { post: Post }) {
return <h2>{post.title}</h2>
}

Automatic regeneration in dev/watch mode isn't documented officially, so the default flow is change the config → run generate:types by hand.

6.10 Rendering Lexical content on the frontend

As covered in chapter 03, a richText field stores Lexical's JSON structure (SerializedEditorState). On the frontend, hand it to the official RichText component (@payloadcms/richtext-lexical/react) and you get React elements (Converting JSX (official docs)):

import { RichText } from '@payloadcms/richtext-lexical/react'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'

export function PostBody({ data }: { data: SerializedEditorState }) {
return <RichText data={data} />
}

Callers pass the richText field value straight in, as <PostBody data={post.body} />. RichText doesn't require a Client Component — it renders fine inside Server Components.

Standard nodes (headings, lists, links, …) are handled by the built-in converters. Customization is mostly needed for Blocks embedded in the editor via BlocksFeature: add a "Block slug → JSX" mapping through the converters prop:

import { RichText } from '@payloadcms/richtext-lexical/react'

const jsxConverters = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
// key is the Block slug (rendering the Hero Block from chapter 03)
Hero: ({ node }) => (
<section className="hero">
<h1>{node.fields.headline}</h1>
</section>
),
},
})

export function PostBody({ data }: { data: SerializedEditorState }) {
return <RichText converters={jsxConverters} data={data} />
}

Just like the Blocks Field in chapter 03, the frontend keeps a one-to-one table of "which Block renders how." Blocks whose slug has no matching converter don't render by default, so watch for gaps between the Blocks you add to the editor and your converters.

6.11 Live Preview (the frontend updates as you write)

How it works

According to the official docs (Live Preview Overview), the Admin UI uses window.postMessage to push document changes into the frontend iframe. The frontend receives them and re-renders, so editors see their changes without hitting save.

Collection-side configuration

// payload.config.ts (inside admin, or on individual Collections)
admin: {
livePreview: {
url: ({ data }) => `http://localhost:3000/preview/posts/${data.slug}`,
collections: ['posts'],
breakpoints: [
{ label: 'Mobile', name: 'mobile', width: 375, height: 667 },
{ label: 'Tablet', name: 'tablet', width: 768, height: 1024 },
{ label: 'Desktop', name: 'desktop', width: 1440, height: 900 },
],
},
}

Frontend side A: staying in Server Components (RefreshRouteOnSave)

For the RSC-centered setup this guide uses, the official server-side Live Preview is the first choice (Server-side Live Preview). The page stays a Server Component — each change event calls router.refresh(), re-running the RSC to re-fetch the draft.

Drop one small Client Component into the frontend:

// app/(frontend)/components/RefreshRouteOnSave.tsx
'use client'
import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react'
import { useRouter } from 'next/navigation'

export function RefreshRouteOnSave() {
const router = useRouter()
return (
<PayloadLivePreview
refresh={() => router.refresh()}
serverURL={process.env.NEXT_PUBLIC_SERVER_URL!}
/>
)
}

Then place it on the preview page (an RSC):

// app/(frontend)/preview/posts/[slug]/page.tsx
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'

export default async function PreviewPage({ params }: { params: { slug: string } }) {
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: params.slug } },
draft: true, // fetch the draft version
limit: 1,
})
return (
<>
<RefreshRouteOnSave />
<article>{docs[0]?.title}</article>
</>
)
}

Each edit event triggers refresh, and the RSC re-fetches with draft: true and re-renders. Unlike the client-side variant below, the rendering code is shared with the public page.

Frontend side B: receiving in a Client Component (the useLivePreview hook)

If you'd rather keep state management inside a Client Component, the official React hook lives in @payloadcms/live-preview-react (Client-side Live Preview):

'use client'
import { useLivePreview } from '@payloadcms/live-preview-react'
import type { Post } from '@/payload-types'

export function PostPreview({ initialData }: { initialData: Post }) {
const { data, isLoading } = useLivePreview<Post>({
initialData,
serverURL: process.env.NEXT_PUBLIC_SERVER_URL!,
depth: 2,
})

if (isLoading) return null
return (
<article>
<h1>{data.title}</h1>
{/* body is Lexical JSON — render via a converter */}
</article>
)
}

The canonical pattern is to fetch initialData in a Server Component and pass it down as a prop:

// app/(frontend)/preview/posts/[slug]/page.tsx
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PostPreview } from './PostPreview'

export default async function PreviewPage({ params }: { params: { slug: string } }) {
const payload = await getPayload({ config: configPromise })
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: params.slug } },
draft: true,
limit: 1,
})
return <PostPreview initialData={docs[0]} />
}

A hybrid setup: the RSC fetches data and hands it to the Client Component hook.

6.12 Draft Preview and Next.js Draft Mode

How it works

Collections with versions: { drafts: true } get a _status: 'draft' | 'published' field. Draft Preview lets editors see a draft on a protected URL before publishing.

Wiring to Next.js Draft Mode

The official website template places the preview route at app/(frontend)/next/preview/route.ts, with search params path and previewSecret, and verifies the JWT via payload.auth(). A minimal example following this pattern:

// app/(frontend)/next/preview/route.ts
import type { PayloadRequest } from 'payload'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
import configPromise from '@payload-config'

export async function GET(req: NextRequest): Promise<Response> {
const payload = await getPayload({ config: configPromise })

const { searchParams } = new URL(req.url)
const path = searchParams.get('path')
const previewSecret = searchParams.get('previewSecret')

if (previewSecret !== process.env.PREVIEW_SECRET) {
return new Response('You are not allowed to preview this page', { status: 403 })
}
if (!path || !path.startsWith('/')) {
return new Response('Invalid path', { status: 400 })
}

// Verify the JWT passed in from the Admin UI
const user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})

const draft = await draftMode()
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
draft.enable()
redirect(path)
}

On the public page, branch on draftMode().isEnabled:

// app/(frontend)/posts/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { getPayload } from 'payload'
import configPromise from '@payload-config'

export default async function PostPage({ params }: { params: { slug: string } }) {
const { isEnabled: isDraft } = await draftMode()
const payload = await getPayload({ config: configPromise })

const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: params.slug } },
draft: isDraft,
limit: 1,
})

return <article>{/* ... */}</article>
}

The Admin UI's "Preview" button takes you to ?path=...&previewSecret=..., which hits the route above; the route verifies the secret, verifies the JWT, then calls draftMode().enable() to switch into draft rendering. The Collection-side preview URL generator looks like this (per the official docs, the second argument options carries locale / req / token and more):

admin: {
preview: (doc) =>
`/next/preview?path=/posts/${doc.slug}&previewSecret=${process.env.PREVIEW_SECRET}`,
}

For the API, see Next.js draftMode().

Implementation example — the website template's Posts index (ISR + select + overrideAccess)

From the official website template, here's the app/(frontend)/posts/page.tsx — a Server Component readers of this series can adapt directly. It fetches a posts index and renders it with ISR plus pagination, in the canonical shape.

// app/(frontend)/posts/page.tsx
import type { Metadata } from 'next/types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'

import { CollectionArchive } from '@/components/CollectionArchive'
import { PageRange } from '@/components/PageRange'
import { Pagination } from '@/components/Pagination'

export const dynamic = 'force-static'
export const revalidate = 600

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

const posts = await payload.find({
collection: 'posts',
depth: 1,
limit: 12,
overrideAccess: false,
select: {
title: true,
slug: true,
categories: true,
meta: true,
},
})

return (
<div className="pt-24 pb-24">
<div className="container mb-16">
<h1>Posts</h1>
</div>

<div className="container mb-8">
<PageRange
collection="posts"
currentPage={posts.page}
limit={12}
totalDocs={posts.totalDocs}
/>
</div>

<CollectionArchive posts={posts.docs} />

<div className="container">
{posts.totalPages > 1 && posts.page && (
<Pagination page={posts.page} totalPages={posts.totalPages} />
)}
</div>
</div>
)
}

export function generateMetadata(): Metadata {
return { title: 'Payload Website Template Posts' }
}

The pieces that earn their keep:

  • export const dynamic = 'force-static' + export const revalidate = 600ISR (regenerate every 10 minutes)
  • overrideAccess: false — respect Access Control while fetching (the Local API defaults to true; flip to false when you only want publicly-visible content)
  • select: { title: true, slug: true, ... } — only pull the fields you need. An index page doesn't want the full Lexical JSON body
  • CollectionArchive / PageRange / Pagination are UI components shipped with the template

Pair it with revalidateTag('posts') from afterChange (section 6.8) and the ISR cache invalidates the moment an editor hits Publish.

Source: website template posts/page.tsx

Wrap-up and common misconceptions

"Is Payload a Next.js plugin?" → No. It's a standalone CMS / application framework that has been integrated for co-location with Next.js.

"Does it run on Edge runtime?" → No. Node runtime is required.

"Can I use a different frontend framework?" → Yes, but you lose the co-location benefits (Local API / shared types / single deploy), and you're back to a 2.x-style split setup.

"Can I replace the Admin UI?" → Plugins and Custom Components let you customize parts, but you usually don't want to throw away the generated Admin UI (see chapter 07 — Admin).

Co-location checklist

Quick checks when you bring this in:

  • Running on Node.js 20.9.0+
  • next.config.mjs is wrapped in withPayload()
  • The app/(payload)/ route group is unedited (no catch-all overrides)
  • (only if you adopted the cold-start optimization) instrumentation.ts calls getPayload()
  • tsconfig.json has the @payload-config path alias
  • If you have middleware.ts, it excludes /admin and /api
  • revalidateTag is wired for cache invalidation
  • No explicit Edge runtime is declared

Next

The final chapter, 07 Admin / Media / Deploy — the operations layer, covers Admin UI customization, the plugin ecosystem, Media (Uploads + S3), the Postgres adapter and migrations, Vercel deployment, and an overview of AI Auto-Embedding — closing the series.

References