メインコンテンツまでスキップ

Next.js 統合 — 同居構成の深掘り

この章のゴール

  • なぜ同居なのか」を 2.x までとの対比で理解する
  • app/(payload)/ route group の ファイルレベルの構成を頭に入れる
  • getPayload() で Local API を取って Server Components から呼ぶ標準パターンを書ける
  • Next.js cache (revalidateTag) との連動、Lexical コンテンツの描画、Live Preview、Draft Preview を運用できる粒度で把握する

本章は他章より厚い構成です (12 節)。読み終えると「自分の Next.js プロジェクトに Payload を後付け install して動かす」が想像できる状態になります。

6.1 なぜ「同居」なのか — 2.x までとの違い

PayloadCMS 1.x までは Express ベースの独立 CMS サーバで、フロントエンドとは HTTP で会話していました。2.x で Lexical / Postgres / Live Preview などが導入され、3.0 (2024-11-19) で Next.js native architecture が完成しました。

3.x で「同居」が成立したことで得られるもの:

  • deploy が 1 回で済む (フロントと CMS が同じ build / 同じホスト)
  • 環境変数が 1 セット (PAYLOAD_SECRETDATABASE_URL もフロントの env と同じ場所)
  • 型が共有 (payload-types.ts をフロントから直接 import できる)
  • Local API で HTTP を介さずデータ取得 (Server Components で fetch でなく payload.find())

トレードオフも明確です:

  • Next.js のホスティング制約を被る (Edge runtime は不可、Node runtime 必須)
  • bundle が肥大化する
  • フロントとバックの責務分離が口頭規約に依存しがち (規律で守るしかない)

「フロントとバックを物理分離したい」プロジェクトには別 deploy のままにする選択肢もありますが、本シリーズは Next.js 同居前提で進めます。

6.2 ディレクトリ構成 (ファイルレベル)

公式 doc (Installation) によれば、Payload ファイルは app/(payload)/ という route group に置き、フロントエンドは app/(frontend)/ のような別 group に分離します。

my-nextjs-app/
├── app/
│ ├── (payload)/ ← Payload route group (編集不要、Blank Template の構造をそのまま使う)
│ │ ├── admin/ ← Admin UI 用 route 群 (catch-all 等)
│ │ ├── api/ ← REST / GraphQL endpoint 群
│ │ ├── custom.scss ← Admin UI の CSS オーバーライド
│ │ └── layout.tsx ← Payload 用 layout
│ ├── (frontend)/ ← 公開サイト用 route group
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── ...
├── instrumentation.ts ← Next.js register() で Payload bootstrap
├── next.config.mjs ← withPayload() でラップ
├── payload.config.ts ← Payload の本体設定
├── payload-types.ts ← 自動生成型 (`yarn generate:types` で再生成)
├── tsconfig.json
└── package.json

公式 doc は「app/(payload)/ 配下の Payload ファイルは固定で編集不要」と明言しています。実体のファイル名・サブディレクトリ構成は create-payload-app で生成される Blank Template をそのまま参照するのが正です。フロント側のレイアウトやページは (frontend)/ (好きな名前) に置けば、route group の括弧表記により URL には現れず両者がきれいに分離されます。

6.3 payload.config.ts の役割と最小例 (Next.js 同居版)

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 が必須、editor で Lexical を有効化、typescript.outputFile で型出力先を指定するのが Next.js 同居の典型形です。

6.4 getPayload() で Local API を取る (Server Components の標準パターン)

// 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>
)
}

ポイント (公式 website template のパターンに準拠):

  • import configPromise from '@payload-config'tsconfig.json で path alias を設定 ("paths": { "@payload-config": ["./payload.config.ts"] })
  • await getPayload({ config: configPromise })Server Component / Server Action 内で直接呼ぶ。HTTP は介さない
  • 戻り値は payload-types から自動推論されるため、docsPost[] 相当

Server Action からも全く同じパターンで payload.create() / payload.update() を呼べます (後述)。

この例で published だけが返る理由は where 句

公開データに絞れているのは where_status を手書きフィルタしているからで、04 章 の access 関数が働いた結果ではありません。Local API はデフォルトで Access Control をバイパスします (05 章の詳説)。access 関数に絞り込みを任せたい場合は overrideAccess: false を明示します — 章末の実装例がそのパターンです。

6.5 環境変数とランタイム

.env.local の最小セット:

PAYLOAD_SECRET=長いランダム文字列
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb

ランタイムは:

  • Node.js 20.9.0+ が必須 (Installation)
  • Edge runtime では動かない (Cloudflare Pages の Workers 等にはそのまま乗せられない)
  • Server Component / API route ともデフォルトの Node runtime で OK、export const runtime = 'edge' は付けないこと

middleware.ts を入れる場合は、Admin UI (/admin) と API (/api) を matcher の除外に入れて、middleware が Payload 経路に干渉しないようにするのが慣用です (公式 doc には specific な推奨例なし、自プロジェクトの要件に合わせて調整してください):

// middleware.ts (慣用例 — 自プロジェクトに合わせて調整)
export const config = {
matcher: ['/((?!admin|api|_next).*)'],
}

6.6 既存 Next.js プロジェクトへの後付け install

公式 doc が示す追加手順:

  1. 必要パッケージを install: pnpm i payload @payloadcms/next @payloadcms/db-postgres @payloadcms/richtext-lexical
  2. Payload ファイル群を app/(payload)/ に配置 (create-payload-app で自動配置可能)
  3. next.config.mjswithPayload() でラップ
  4. payload.config.ts を作成
  5. (任意) instrumentation.ts を追加して getPayload() を起動時に走らせる (公式 Blank Template には含まれず、cold start を避けたい場合のみ)
  6. http://localhost:3000/admin にアクセスして初回 user 作成

next.config.mjs の before / after:

// Before
export default {
// 既存の Next.js 設定
}

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

export default withPayload({
// 既存の Next.js 設定
})

公式 doc は **「Payload は完全 ESM プロジェクト」**と明言しており、next.config.mjs 拡張子か、package.json"type": "module" のいずれかが必須です。

6.7 instrumentation.ts の役割 (任意)

Next.js の instrumentation.ts ファイルは register() 関数を export し、新しい Next.js サーバインスタンス起動時に 1 回だけ呼ばれる (Next.js: File-system conventions: instrumentation.js)。サーバが request を受け始める前に register が完了する保証があります。

重要: 公式 PayloadCMS テンプレート (Blank / Website / with-postgres など) には instrumentation.ts は含まれていません。Payload は 最初の request 時に lazy initialize される設計です。

そのため、本セクションは「cold start を避けたい場合の任意の最適化パターン」です (コミュニティで紹介されている形):

// instrumentation.ts (任意 — 公式テンプレートには含まれない)
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 })
}
}

NEXT_RUNTIME === 'nodejs' チェックは Edge runtime での実行を防ぎます (Payload は Node 必須なので)。これにより、初回 request 時の "cold initialize" を避けられます。導入の必要がないプロジェクトでは付けなくて問題ありません。

6.8 Cache とデータ更新の連動

Server Component は Next.js のデフォルト cache 制御に従います。データを更新したら 対応する cache を失効 させないと、Admin UI で公開した投稿が公開サイトに反映されません。

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')
}

Hook から失効 (Admin UI で書いたとき)

04 章 Hooks で見たパターンを再掲します。Admin UI から投稿を「Publish」した瞬間に cache が失効するので、Server Component が次回 request 時に新データを掴みます:

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

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

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 },
)

これで revalidateTag('posts') がこの cache を無効化します。詳細は Next.js Caching を参照してください。

6.9 TypeScript 型共有

05 章 APIs で見たとおり、Payload は payload-types.ts を生成します:

yarn payload generate:types

Next.js 同居なら、出力先をプロジェクト内のどこかにして alias 設定するだけで、フロントから直接 import できます。

// 公開サイト側のコンポーネント
import type { Post } from '@/payload-types'

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

dev/watch 時の自動再生成は公式に明記されていないので、config を変更したら手動で generate:types を実行するのが既定運用です。

6.10 Lexical コンテンツのフロント描画

03 章 で見たとおり、richText フィールドの中身は Lexical の JSON 構造 (SerializedEditorState) として保存されます。フロントでは公式の RichText コンポーネント (@payloadcms/richtext-lexical/react) に渡すだけで React 要素として描画できます (Converting JSX (公式 doc)):

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} />
}

使う側は <PostBody data={post.body} /> のように richText フィールドの値をそのまま渡します。RichText は Client Component を必要とせず、Server Component のまま使えます。

見出し・リスト・リンクなどの標準ノードは組み込みの converter が処理します。カスタマイズが必要になるのは主に BlocksFeature でエディタ内に埋め込んだ Block で、converters prop に「Block slug → JSX」のマッピングを追加します:

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

const jsxConverters = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
// key は Block の slug (03 章の Hero Block を描画する例)
Hero: ({ node }) => (
<section className="hero">
<h1>{node.fields.headline}</h1>
</section>
),
},
})

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

03 章の Blocks Field と同じく、「どの Block をどう描画するか」の対応表をフロント側に 1 対 1 で持つ構図です。slug が一致しない Block はデフォルトでは描画されないため、エディタに追加した Block と converter の対応漏れに注意してください。

6.11 Live Preview (書きながらフロントが即更新)

仕組み

公式 doc (Live Preview Overview) によれば、Admin UI が window.postMessage でフロントの iframe にドキュメントの変更を送ります。フロントが受信して再 render することで、編集者は「保存ボタンを押さずに見た目が即時更新される」体験を得られます。

Collection 側の設定

// payload.config.ts (admin プロパティ内、または各 Collection)
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 },
],
},
}

フロント側 A: Server Components のまま使う (RefreshRouteOnSave)

RSC 主体の本ガイドの構成では、公式の server-side Live Preview が第一候補です (Server-side Live Preview)。ページを Client Component 化せず、変更イベントのたびに router.refresh() で RSC を再実行して draft を取り直します。

フロントに小さな Client Component を 1 つ置きます:

// 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!}
/>
)
}

プレビュー対象のページ (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, // draft 版を取得
limit: 1,
})
return (
<>
<RefreshRouteOnSave />
<article>{docs[0]?.title}</article>
</>
)
}

編集イベントを受けるたびに refresh が呼ばれ、RSC が draft: true で再取得して再描画します。次項の client-side 版と違い、描画コードを公開ページと共有できるのが利点です。

フロント側 B: Client Component で受ける (useLivePreview hook)

state 管理を Client Component 側で完結させたい場合は、公式提供の React hook が @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 は Lexical 出力なので別途 converter で render */}
</article>
)
}

initialDataServer Component から取得して props で渡すのが標準パターンです:

// 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]} />
}

RSC でデータを取って Client Component の hook に流す、というハイブリッド構成です。

6.12 Draft Preview と Next.js Draft Mode

仕組み

versions: { drafts: true } で draft 機能を有効にした Collection は、_status: 'draft' | 'published' のフィールドを持ちます。公開前のドラフトを限定 URL で確認させるのが Draft Preview です。

Next.js Draft Mode との連携

公式 website template は preview route を app/(frontend)/next/preview/route.ts に置き、search params は pathpreviewSecret、JWT は payload.auth() で verify する 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 })
}

// Admin UI から渡される JWT を verify
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)
}

公開ページ側で 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>
}

Admin UI で「Preview」ボタンを押すと、?path=...&previewSecret=... の URL に飛ばされ、上記 route が secret 検証 → JWT verify → draftMode().enable() で draft 表示に切り替える、という流れです。Collection 側で preview URL を生成する設定例 (公式 doc によれば preview 関数の 2 つ目の引数 options には locale / req / token 等が入ります):

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

詳細な API は Next.js draftMode() を参照してください。

実装例 — website template の Posts 一覧ページ (ISR + select + overrideAccess)

公式 website templateapp/(frontend)/posts/page.tsx から、本シリーズの読者が そのまま参考にできる Server Component を引用します。投稿一覧を取得し、ISR + ページネーションで描画する典型実装です。

// 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' }
}

公式の重要ポイント:

  • export const dynamic = 'force-static' + export const revalidate = 600ISR (10 分ごとに再生成)
  • overrideAccess: false で Access Control を 尊重した上で取得 (デフォルトは Local API なので true。「公開コンテンツのみ」を返したいとき false に)
  • select: { title: true, slug: true, ... } で必要なフィールドだけ取得 — 一覧表示で本文 (Lexical JSON) のような大きな field を引かない最適化
  • CollectionArchive / PageRange / Pagination は template 同梱の UI コンポーネント (フレームワーク非依存)

afterChange Hook の revalidateTag('posts') (6.8 節) と組み合わせると、編集者が Publish した瞬間に ISR キャッシュが失効して新しい一覧が次回 request で組まれます。

出典: website template posts/page.tsx

章末まとめ + よくある誤解

「Payload は Next.js プラグイン?」 → いいえ。独立した CMS / アプリケーションフレームワークで、Next.js に同居できるよう統合されています。

「Edge runtime で動く?」 → いいえ。Node runtime 必須です。

「フロントを別 framework に?」 → できますが、同居の旨味 (Local API / 型共有 / 1 deploy) が消えるため、同居前提で組まないなら 2.x 時代のような分離構成にもどることになります。

「Admin UI を独自実装したい?」 → Plugin / Custom Component で部分的にカスタマイズ可能ですが、自動生成される Admin UI を捨てる選択は通常不要です (07 章 Admin)。

同居構成チェックリスト

導入時の最小チェック:

  • Node.js 20.9.0+ で動かしている
  • next.config.mjswithPayload() で wrap してある
  • app/(payload)/ route group が編集されていない (catch-all を上書きしていない)
  • (cold start 対策を採用した場合のみ) instrumentation.tsgetPayload() を呼んでいる
  • tsconfig.jsonpaths@payload-config がある
  • middleware.ts がある場合、/admin/api を matcher から除外している
  • revalidateTag で cache 失効が走っている
  • Edge runtime を明示的に指定していない

次に読む

最終章 07 Admin / Media / Deploy — 運用層 では、Admin UI カスタマイズ、Plugin エコシステム、Media (Uploads + S3)、Postgres adapter / Migration、Vercel デプロイ、AI Auto-Embedding の概観までを扱い、シリーズを締めます。

参考