# Nextjs Integration

> 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 15/16 App Router frontend.

- Skill: `agents-store/nextjs-integration` (Agent Skill)
- Install (CLI): `npx skillmds@latest add agents-store/nextjs-integration`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agents-store/nextjs-integration/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: agents-store (https://skillmd.com/u/agents-store)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agents-store/nextjs-integration

---


# 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`)

```txt
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:

```tsx
// 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:
```bash
pnpm generate:types
```

Import generated types in app code:
```ts
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

```tsx
// 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:

```tsx
// 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`:

```ts
// 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:
```ts
await payload.update({ collection: 'posts', where: {/*…*/}, data: {/*…*/}, context: { disableRevalidate: true }, req })
```

## Draft Mode

Lets editors preview unpublished content from the live frontend.

1. Add a preview URL on the collection:
```ts
admin: {
  preview: (doc) =>
    `${process.env.NEXT_PUBLIC_SITE_URL}/api/preview?slug=${doc.slug}&secret=${process.env.PREVIEW_SECRET}&path=/posts/${doc.slug}`,
}
```

2. Wire a Next.js route handler to enable draft mode:
```ts
// 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)
}
```

3. Exit:
```ts
// 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('/')
}
```

4. Read drafts in your page via `draftMode()` + `draft: true` (see Single Document above).

## Live Preview (real-time)

```ts
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:
```tsx
// 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:

```tsx
// 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.cache` or `unstable_cache`:
  ```ts
  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/`:
```ts
// 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

```ts
// 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

- **Next.js versions** — current `@payloadcms/next` supports Next `>=15.2.9` (within tested minor ranges) and Next 16 (`>=16.2.6 <17`). Next 14 is **not** supported by current v3 releases.
- **Vercel** — works out of the box. Use `@payloadcms/db-vercel-postgres` and `@payloadcms/storage-vercel-blob`.
- **Self-host (Node)** — `pnpm build && pnpm start`. Static assets from `public/` 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 `setup` skill — initial project creation.
- The `queries` skill — Local API method reference.
- The `hooks` skill — afterChange / afterDelete patterns.
- The `lexical-editor` skill — rendering `richText` in server components.
- The `jobs-queue` skill — scheduling jobs via Vercel cron.

