Payload CMS完全ガイド:Next.jsと統合するヘッドレスCMS
Payload CMS完全ガイド:Next.jsと統合するヘッドレスCMS
ヘッドレスCMSは、コンテンツ管理とフロントエンドを分離し、柔軟なWebアプリケーション開発を可能にします。Payload CMSは、TypeScriptで構築された次世代のヘッドレスCMSで、開発者体験とカスタマイズ性に優れています。
この記事では、Payload CMSの基本からNext.jsとの統合、実践的な活用方法まで詳しく解説します。
Payload CMSとは
Payload CMSは、オープンソースのヘッドレスCMSで、TypeScriptとReactで構築されています。コード優先のアプローチを採用し、開発者が完全な制御を持ちながらも、強力な管理画面を提供します。
主な特徴
- TypeScriptファースト: 完全な型安全性
- コードベース設定: GUIではなくコードで設定
- ローカルAPI: データベースを直接操作可能
- 認証・認可: 組み込みのユーザー管理とアクセス制御
- ファイルアップロード: 画像最適化とクラウドストレージ対応
- GraphQL & REST API: 両方をサポート
- バージョン管理: コンテンツの履歴管理とドラフト機能
- フック: カスタムロジックを追加可能
- 完全カスタマイズ可能: 管理画面もカスタマイズ可能
競合との比較
Payload vs Strapi
- Payloadは型安全性が高い(TypeScript)
- Strapiは大規模なプラグインエコシステム
- Payloadはコード優先、Strapiはより多くのGUI設定
Payload vs Contentful
- Contentfulはホスト型、Payloadはセルフホスト
- Payloadは無料でフル機能、Contentfulは従量課金
- Payloadはデータベースを完全制御可能
Payload vs Sanity
- Sanityはリアルタイム編集に強い
- Payloadはより伝統的なCMS体験
- Payloadはバックエンドロジックの統合が容易
セットアップ
インストール
新しいPayloadプロジェクトを作成します。
npx create-payload-app@latest my-payload-app
対話式のセットアップが始まります。
? Choose database: PostgreSQL
? Choose template: Blank
? Use TypeScript?: Yes
既存のNext.jsプロジェクトに追加する場合:
npm install payload @payloadcms/db-postgres @payloadcms/richtext-slate
プロジェクト構造
my-payload-app/
├── src/
│ ├── payload.config.ts # Payload設定
│ ├── collections/ # コレクション定義
│ ├── globals/ # グローバル設定
│ ├── access/ # アクセス制御
│ └── server.ts # サーバーエントリポイント
├── media/ # アップロードファイル
└── .env
基本設定
// src/payload.config.ts
import { buildConfig } from 'payload/config';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { slateEditor } from '@payloadcms/richtext-slate';
import path from 'path';
export default buildConfig({
// 管理画面の設定
admin: {
user: 'users',
meta: {
titleSuffix: '- My CMS',
favicon: '/favicon.ico',
},
},
// データベース設定
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
}),
// リッチテキストエディタ
editor: slateEditor({}),
// コレクション
collections: [],
// グローバル設定
globals: [],
// TypeScript設定
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
// 静的ファイル
upload: {
limits: {
fileSize: 5000000, // 5MB
},
},
// CORS設定
cors: [
'http://localhost:3000',
'https://yourdomain.com',
],
// CSRF保護
csrf: [
'http://localhost:3000',
'https://yourdomain.com',
],
});
環境変数
DATABASE_URL=postgresql://user:password@localhost:5432/payload
PAYLOAD_SECRET=your-secret-key-min-32-characters
コレクション定義
コレクションは、データベースのテーブルに相当します。
基本的なコレクション
// src/collections/Posts.ts
import { CollectionConfig } from 'payload/types';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
position: 'sidebar',
},
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
},
{
name: 'publishedDate',
type: 'date',
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
position: 'sidebar',
},
},
{
name: 'content',
type: 'richText',
required: true,
},
{
name: 'excerpt',
type: 'textarea',
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
},
],
};
カスタムフィールドタイプ
Payloadは豊富なフィールドタイプを提供します。
// src/collections/Products.ts
import { CollectionConfig } from 'payload/types';
export const Products: CollectionConfig = {
slug: 'products',
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'price',
type: 'number',
required: true,
min: 0,
},
{
name: 'currency',
type: 'select',
options: ['USD', 'EUR', 'JPY'],
defaultValue: 'USD',
},
{
name: 'inStock',
type: 'checkbox',
defaultValue: true,
},
{
name: 'variants',
type: 'array',
fields: [
{
name: 'size',
type: 'select',
options: ['S', 'M', 'L', 'XL'],
},
{
name: 'color',
type: 'text',
},
{
name: 'sku',
type: 'text',
required: true,
},
{
name: 'stock',
type: 'number',
min: 0,
},
],
},
{
name: 'specifications',
type: 'group',
fields: [
{
name: 'weight',
type: 'number',
},
{
name: 'dimensions',
type: 'group',
fields: [
{ name: 'width', type: 'number' },
{ name: 'height', type: 'number' },
{ name: 'depth', type: 'number' },
],
},
{
name: 'material',
type: 'text',
},
],
},
{
name: 'gallery',
type: 'array',
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'alt',
type: 'text',
},
],
},
{
name: 'seo',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'keywords',
type: 'text',
},
],
},
],
};
メディアコレクション
// src/collections/Media.ts
import { CollectionConfig } from 'payload/types';
import path from 'path';
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: path.resolve(__dirname, '../../media'),
imageSizes: [
{
name: 'thumbnail',
width: 400,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 1024,
position: 'centre',
},
{
name: 'tablet',
width: 1024,
height: undefined,
position: 'centre',
},
],
adminThumbnail: 'thumbnail',
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'text',
},
],
};
アクセス制御
Payloadは柔軟なアクセス制御システムを提供します。
基本的なアクセス制御
// src/access/isAdmin.ts
import { Access } from 'payload/types';
export const isAdmin: Access = ({ req: { user } }) => {
return Boolean(user?.role === 'admin');
};
// src/access/isAdminOrPublished.ts
import { Access } from 'payload/types';
export const isAdminOrPublished: Access = ({ req: { user } }) => {
if (user?.role === 'admin') {
return true;
}
return {
status: {
equals: 'published',
},
};
};
フィールドレベルのアクセス制御
// src/collections/Posts.ts
import { CollectionConfig } from 'payload/types';
import { isAdmin } from '../access/isAdmin';
import { isAdminOrPublished } from '../access/isAdminOrPublished';
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: isAdminOrPublished,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'internalNotes',
type: 'textarea',
access: {
read: isAdmin,
update: isAdmin,
},
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
defaultValue: 'draft',
access: {
create: isAdmin,
update: isAdmin,
},
},
],
};
行レベルのアクセス制御
// src/collections/Comments.ts
import { CollectionConfig } from 'payload/types';
export const Comments: CollectionConfig = {
slug: 'comments',
access: {
read: () => true,
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => {
if (user?.role === 'admin') return true;
return {
author: {
equals: user?.id,
},
};
},
delete: ({ req: { user } }) => {
if (user?.role === 'admin') return true;
return {
author: {
equals: user?.id,
},
};
},
},
fields: [
{
name: 'content',
type: 'textarea',
required: true,
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
required: true,
},
],
};
フック
フックを使用して、データのライフサイクルにカスタムロジックを追加できます。
beforeChange フック
// src/collections/Posts.ts
import { CollectionConfig } from 'payload/types';
import { slugify } from '../utils/slugify';
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
({ data, operation }) => {
if (operation === 'create' || operation === 'update') {
if (data.title && !data.slug) {
data.slug = slugify(data.title);
}
}
return data;
},
],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
unique: true,
index: true,
},
],
};
afterChange フック
// src/collections/Posts.ts
import { CollectionConfig } from 'payload/types';
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
afterChange: [
async ({ doc, previousDoc, operation }) => {
// 公開ステータスが変更されたら通知
if (
operation === 'update' &&
doc.status === 'published' &&
previousDoc.status === 'draft'
) {
await sendNotification({
to: doc.author.email,
subject: 'Your post has been published!',
message: `Your post "${doc.title}" is now live.`,
});
}
return doc;
},
],
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
},
],
};
beforeValidate フック
// src/collections/Users.ts
import { CollectionConfig } from 'payload/types';
import bcrypt from 'bcrypt';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
hooks: {
beforeValidate: [
async ({ data, operation }) => {
if (operation === 'create' || operation === 'update') {
if (data.password) {
data.password = await bcrypt.hash(data.password, 10);
}
}
return data;
},
],
},
fields: [
{
name: 'email',
type: 'email',
required: true,
unique: true,
},
{
name: 'password',
type: 'text',
required: true,
},
],
};
グローバル設定
グローバル設定は、サイト全体で使用する単一のドキュメントです。
// src/globals/SiteSettings.ts
import { GlobalConfig } from 'payload/types';
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: {
read: () => true,
},
fields: [
{
name: 'siteName',
type: 'text',
required: true,
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
},
{
name: 'footer',
type: 'group',
fields: [
{
name: 'copyright',
type: 'text',
},
{
name: 'socialLinks',
type: 'array',
fields: [
{
name: 'platform',
type: 'select',
options: ['twitter', 'facebook', 'instagram', 'linkedin'],
},
{
name: 'url',
type: 'text',
},
],
},
],
},
{
name: 'navigation',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'type',
type: 'radio',
options: [
{ label: 'Internal Link', value: 'internal' },
{ label: 'External Link', value: 'external' },
],
defaultValue: 'internal',
},
{
name: 'page',
type: 'relationship',
relationTo: 'pages',
admin: {
condition: (data) => data.type === 'internal',
},
},
{
name: 'url',
type: 'text',
admin: {
condition: (data) => data.type === 'external',
},
},
],
},
],
};
設定に追加:
// src/payload.config.ts
import { SiteSettings } from './globals/SiteSettings';
export default buildConfig({
// ...
globals: [SiteSettings],
});
Next.jsとの統合
Payloadをサブパスで実行
// src/payload.config.ts
export default buildConfig({
admin: {
user: 'users',
// 管理画面を /admin で提供
meta: {
titleSuffix: '- My CMS',
},
},
// ...
});
Next.js App Routerで統合
// app/api/[...slug]/route.ts
import { getPayloadClient } from '@/lib/payload';
import { NextRequest, NextResponse } from 'next/server';
const payload = await getPayloadClient();
export async function GET(request: NextRequest) {
return payload.handler(request);
}
export async function POST(request: NextRequest) {
return payload.handler(request);
}
データ取得
// app/blog/page.tsx
import { getPayloadClient } from '@/lib/payload';
export default async function BlogPage() {
const payload = await getPayloadClient();
const posts = await payload.find({
collection: 'posts',
where: {
status: {
equals: 'published',
},
},
sort: '-publishedDate',
limit: 10,
});
return (
<div>
<h1>Blog</h1>
{posts.docs.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<a href={`/blog/${post.slug}`}>Read more</a>
</article>
))}
</div>
);
}
動的ルート
// app/blog/[slug]/page.tsx
import { getPayloadClient } from '@/lib/payload';
import { notFound } from 'next/navigation';
interface Props {
params: {
slug: string;
};
}
export async function generateStaticParams() {
const payload = await getPayloadClient();
const posts = await payload.find({
collection: 'posts',
limit: 1000,
});
return posts.docs.map((post) => ({
slug: post.slug,
}));
}
export default async function PostPage({ params }: Props) {
const payload = await getPayloadClient();
const posts = await payload.find({
collection: 'posts',
where: {
slug: {
equals: params.slug,
},
},
limit: 1,
});
const post = posts.docs[0];
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.publishedDate).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
ローカルAPI
ローカルAPIを使用すると、HTTP経由ではなく、データベースを直接操作できます。
// lib/payload.ts
import payload from 'payload';
let cached = (global as any).payload;
if (!cached) {
cached = (global as any).payload = { client: null, promise: null };
}
export async function getPayloadClient() {
if (cached.client) {
return cached.client;
}
if (!cached.promise) {
cached.promise = payload.init({
secret: process.env.PAYLOAD_SECRET!,
local: true,
});
}
try {
cached.client = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.client;
}
CRUD操作
const payload = await getPayloadClient();
// Create
const newPost = await payload.create({
collection: 'posts',
data: {
title: 'My New Post',
slug: 'my-new-post',
content: 'Hello, world!',
author: userId,
status: 'draft',
},
});
// Read
const post = await payload.findByID({
collection: 'posts',
id: postId,
});
// Update
const updatedPost = await payload.update({
collection: 'posts',
id: postId,
data: {
status: 'published',
},
});
// Delete
await payload.delete({
collection: 'posts',
id: postId,
});
// Find with query
const publishedPosts = await payload.find({
collection: 'posts',
where: {
status: {
equals: 'published',
},
},
sort: '-createdAt',
limit: 10,
page: 1,
});
セルフホスティング
Dockerで実行
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: payload
POSTGRES_USER: payload
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
payload:
build: .
environment:
DATABASE_URL: postgresql://payload:password@postgres:5432/payload
PAYLOAD_SECRET: your-secret-key-min-32-characters
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
- ./media:/app/media
volumes:
postgres_data:
Vercelデプロイ
// vercel.json
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"env": {
"DATABASE_URL": "@database-url",
"PAYLOAD_SECRET": "@payload-secret"
}
}
まとめ
Payload CMSは、開発者体験とカスタマイズ性に優れたヘッドレスCMSです。主な利点は以下の通りです。
- 型安全性: TypeScriptファーストな設計
- 柔軟性: コードベースの設定で完全な制御
- Next.js統合: シームレスな統合とローカルAPI
- セルフホスト: データを完全に制御
コンテンツ管理システムを構築する際、Payload CMSは強力な選択肢です。Next.jsとの組み合わせで、高速で柔軟なWebアプリケーションを構築できます。