Payload CMS Integration with Next.js
Payload 2.x is designed to integrate seamlessly with Next.js App Router. The monolithic approach — both systems in a single process — eliminates network requests between CMS and frontend during server-side rendering. This is the key advantage over other headless CMS solutions.
Monolithic Architecture
npx create-payload-app@latest --template website
Structure of a Next.js + Payload monolith:
my-app/
├── app/
│ ├── (frontend)/ # Public website
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── [slug]/page.tsx
│ └── (payload)/ # Admin panel
│ └── admin/[[...segments]]/page.tsx
├── collections/
├── globals/
├── payload.config.ts
└── next.config.js
// next.config.js
const { withPayload } = require('@payloadcms/next/withPayload')
module.exports = withPayload({
// your Next.js settings
images: {
remotePatterns: [{ hostname: 'your-cdn.com' }],
},
})
Direct Requests Without HTTP
In Server Components, you can call Payload directly — without HTTP:
// app/(frontend)/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function HomePage() {
const payload = await getPayload({ config })
// Direct call — no network request
const [homepage, posts, settings] = await Promise.all([
payload.findGlobal({ slug: 'homepage', depth: 2 }),
payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 6,
depth: 1,
}),
payload.findGlobal({ slug: 'settings' }),
])
return (
<>
<Hero data={homepage.hero} />
<FeaturedPosts posts={posts.docs} />
<Footer settings={settings} />
</>
)
}
ISR — Incremental Static Regeneration
// app/(frontend)/posts/[slug]/page.tsx
import { unstable_cache } from 'next/cache'
// Cache request with tag for invalidation
const getCachedPost = unstable_cache(
async (slug: string) => {
const payload = await getPayload({ config })
const result = await payload.find({
collection: 'posts',
where: { slug: { equals: slug }, _status: { equals: 'published' } },
})
return result.docs[0] || null
},
['post'],
{ tags: ['posts'], revalidate: 3600 }
)
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getCachedPost(params.slug)
if (!post) notFound()
return <Article post={post} />
}
On-demand Revalidation via Payload Hooks
// collections/Posts.ts — invalidation on change
hooks: {
afterChange: [
async ({ doc, operation }) => {
if (doc._status === 'published') {
// Invalidate cache by tag
await revalidateTag('posts')
// Invalidate specific page
await revalidatePath(`/posts/${doc.slug}`)
}
},
],
}
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { path, tag } = await req.json()
if (tag) revalidateTag(tag)
if (path) revalidatePath(path)
return NextResponse.json({ revalidated: true })
}
Client-side Operations
Authentication and token-requiring operations are handled via fetch in Client Components:
// app/(frontend)/components/ContactForm.tsx
'use client'
import { useState } from 'react'
export const ContactForm = () => {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setStatus('loading')
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(formData)),
})
setStatus(res.ok ? 'success' : 'error')
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={status === 'loading'}>
{status === 'loading' ? 'Sending...' : 'Submit'}
</button>
{status === 'success' && <p>Request submitted!</p>}
</form>
)
}
TypeScript: Auto-generated Types
After updating collections, regenerate types:
npm run generate:types
// Using types
import type { Post, Page, Media, Settings } from '@/payload-types'
// Full API response typing
const posts: Post[] = result.docs
const settings: Settings = await payload.findGlobal({ slug: 'settings' })
Timeline
Integrating Payload with Next.js App Router, setting up ISR, and configuring types takes 2–3 days with existing collections.







