name: nextjs-integration description: This skill should be used when the user asks about "Payload with Next.js", "getPayload in server component", "Payload App Router", "Payload route groups", "Payload live preview Next.js", "revalidate Payload page", "Payload server actions", "Payload draft mode", "Payload Next.js cache", or needs to wire PayloadCMS into a Next.js v14/v15 frontend.
PayloadCMS — Next.js Integration
PayloadCMS v3 is Next.js native. The admin panel ships as Next.js App Router routes, and your frontend lives in the same project. This skill covers data fetching in Server Components, cache invalidation, draft mode, live preview, server actions, and route groups.
Project Layout (created by create-payload-app)
src/
├── app/
│ ├── (frontend)/ # Your public site
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── posts/
│ │ │ ├── page.tsx
│ │ │ └── [slug]/page.tsx
│ │ └── api/ # Public API routes (optional)
│ │ └── webhook/route.ts
│ └── (payload)/ # Payload admin + REST + GraphQL
│ ├── admin/
│ │ └── [[...segments]]/page.tsx
│ ├── api/
│ │ └── [...slug]/route.ts
│ ├── api/graphql/route.ts
│ ├── layout.tsx
│ └── custom.scss
├── collections/
├── payload.config.ts
└── payload-types.ts # Generated by `pnpm generate:types`
Two route groups keep frontend and admin layouts separate. Don't move (payload) — Payload's import map and Tailwind config assume that exact path.
Reading Data in Server Components
The Local API is the recommended path — zero network overhead, full type safety:
// app/(frontend)/posts/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
export default async function PostsList() {
const payload = await getPayload({ config })
const { docs: posts } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 20,
depth: 1,
})
return (
<ul>
{posts.map((p) => (
<li key={p.id}>
<Link href={`/posts/${p.slug}`}>{p.title}</Link>
</li>
))}
</ul>
)
}
@payload-config is the path alias the scaffolder adds to tsconfig.json (compilerOptions.paths). It resolves to src/payload.config.ts.
Generated Types
After every collection / global change:
pnpm generate:types
Import generated types in app code:
import type { Post, User, Page } from '@/payload-types'
This makes the Local API responses fully typed: payload.find({ collection: 'posts' }) returns PaginatedDocs<Post>.
Single Document by Slug
// app/(frontend)/posts/[slug]/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { notFound } from 'next/navigation'
import { draftMode } from 'next/headers'
export default async function PostPage({ params }: { params: { slug: string } }) {
const { isEnabled: isDraft } = await draftMode()
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
where: { slug: { equals: params.slug } },
draft: isDraft,
limit: 1,
depth: 2,
})
const post = docs[0]
if (!post) return notFound()
return (
<article>
<h1>{post.title}</h1>
{/* render content via RichText (see lexical-editor skill) */}
</article>
)
}
Static Generation + ISR
For mostly-static pages, statically generate at build and revalidate via hooks:
// app/(frontend)/posts/[slug]/page.tsx
export const revalidate = 3600 // 1 hour
export async function generateStaticParams() {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
limit: 1000,
select: { slug: true },
})
return docs.map((p) => ({ slug: p.slug }))
}
Revalidation on Save
Wire Payload's afterChange and afterDelete hooks to revalidatePath / revalidateTag:
// src/collections/Posts.ts
import { revalidatePath, revalidateTag } from 'next/cache'
export const Posts: CollectionConfig = {
slug: 'posts',
// …
hooks: {
afterChange: [
({ doc, previousDoc, req: { context } }) => {
if (context?.disableRevalidate) return doc
if (doc._status === 'published') {
revalidatePath(`/posts/${doc.slug}`)
revalidateTag('posts')
}
// If slug changed, also kill the old path
if (previousDoc?.slug && previousDoc.slug !== doc.slug) {
revalidatePath(`/posts/${previousDoc.slug}`)
}
return doc
},
],
afterDelete: [
({ doc }) => {
revalidatePath(`/posts/${doc.slug}`)
revalidateTag('posts')
},
],
},
}
To bulk-write without firing revalidation:
await payload.update({ collection: 'posts', where: {/*…*/}, data: {/*…*/}, context: { disableRevalidate: true }, req })
Draft Mode
Lets editors preview unpublished content from the live frontend.
- Add a preview URL on the collection:
admin: {
preview: (doc) =>
`${process.env.NEXT_PUBLIC_SITE_URL}/api/preview?slug=${doc.slug}&secret=${process.env.PREVIEW_SECRET}&path=/posts/${doc.slug}`,
}
- Wire a Next.js route handler to enable draft mode:
// app/(frontend)/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const secret = searchParams.get('secret')
const path = searchParams.get('path') || '/'
if (secret !== process.env.PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
const dm = await draftMode()
dm.enable()
redirect(path)
}
- Exit:
// app/(frontend)/api/exit-preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
;(await draftMode()).disable()
redirect('/')
}
- Read drafts in your page via
draftMode()+draft: true(see Single Document above).
Live Preview (real-time)
admin: {
livePreview: {
url: ({ data }) =>
`${process.env.NEXT_PUBLIC_SITE_URL}/posts/${data.slug}?live-preview=true`,
breakpoints: [
{ name: 'mobile', width: 375, height: 667, label: 'Mobile' },
{ name: 'desktop', width: 1440, height: 900, label: 'Desktop' },
],
},
}
On the frontend, use Payload's hook to receive live-edit messages from the iframe parent:
// app/(frontend)/posts/[slug]/page-live-preview.client.tsx
'use client'
import { RefreshRouteOnSave } from '@payloadcms/live-preview-react'
export function LivePreview() {
return <RefreshRouteOnSave />
}
Add it to the page only when in live-preview mode.
Server Actions
Server actions can call the Local API directly:
// app/(frontend)/comments/new/page.tsx
'use server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function submitComment(formData: FormData) {
const payload = await getPayload({ config })
await payload.create({
collection: 'comments',
data: {
author: formData.get('author') as string,
body: formData.get('body') as string,
},
})
revalidatePath('/comments')
redirect('/comments/thanks')
}
For server actions handling authenticated user requests, propagate auth via payload.auth({ headers: request.headers }) and use overrideAccess: false.
Caching Strategy
- Local API does NOT auto-cache. Wrap fetches with
next.cacheorunstable_cache:import { unstable_cache } from 'next/cache' export const getPublishedPosts = unstable_cache( async () => { const payload = await getPayload({ config }) return payload.find({ collection: 'posts', where: { _status: { equals: 'published' } } }) }, ['posts', 'published'], { tags: ['posts'], revalidate: 60 }, ) - Invalidate cached fetches with
revalidateTag('posts')from Payload hooks. - Don't wrap admin / REST endpoints — they manage their own caching.
Custom Endpoints
Add a custom server-side endpoint to the Payload api/:
// payload.config.ts
endpoints: [
{
path: '/health',
method: 'get',
handler: () => Response.json({ ok: true, ts: new Date().toISOString() }),
},
],
Accessible at /api/health. See the collections skill for collection-scoped endpoints.
Auth in API Routes
// app/(frontend)/api/me/route.ts
import { getPayload } from 'payload'
import config from '@payload-config'
export async function GET(req: Request) {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user) return new Response('Unauthorized', { status: 401 })
return Response.json({ user })
}
Deployment Notes
- Vercel — works out of the box. Use
@payloadcms/db-vercel-postgresand@payloadcms/storage-vercel-blob. - Self-host (Node) —
pnpm build && pnpm start. Static assets frompublic/go behind your reverse proxy (Caddy / nginx). - Payload Cloud — managed hosting from the Payload team. One-click deploy from GitHub.
- Edge runtime — admin and Payload routes require Node runtime. Don't set
export const runtime = 'edge'on(payload)routes.
See Also
- The
setupskill — initial project creation. - The
queriesskill — Local API method reference. - The
hooksskill — afterChange / afterDelete patterns. - The
lexical-editorskill — renderingrichTextin server components. - The
jobs-queueskill — scheduling jobs via Vercel cron.