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_SECRETもDATABASE_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 から自動推論されるため、
docsはPost[]相当
Server Action からも全く同じパターンで payload.create() / payload.update() を呼べます (後述)。
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 が示す追加手順:
- 必要パッケージを install:
pnpm i payload @payloadcms/next @payloadcms/db-postgres @payloadcms/richtext-lexical - Payload ファイル群を
app/(payload)/に配置 (create-payload-appで自動配置可能) next.config.mjsをwithPayload()でラップpayload.config.tsを作成- (任意)
instrumentation.tsを追加してgetPayload()を起動時に走らせる (公式 Blank Template には含まれず、cold start を避けたい場合のみ) 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>
)
}
initialData は Server 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 は path と previewSecret、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 template の app/(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 = 600で ISR (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 で組まれます。
章末まとめ + よくある誤解
「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.mjsをwithPayload()で wrap してある -
app/(payload)/route group が編集されていない (catch-all を上書きしていない) - (cold start 対策を採用した場合のみ)
instrumentation.tsでgetPayload()を呼んでいる -
tsconfig.jsonのpathsに@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 の概観までを扱い、シリーズを締めます。