この記事の要点
• Payload CMSでコードベースのコンテンツスキーマと管理画面を自動生成
• TypeScript完全対応で型安全なコンテンツ取得・更新が可能
• Hooks・Access Controlで複雑なビジネスロジックと権限管理を実装
Payload CMSとは
Payload CMSは、コードファーストのヘッドレスCMSです。スキーマをTypeScriptで定義し、管理画面を自動生成します。Next.js、Express、MongoDBまたはPostgreSQLと統合できます。
Payloadの特徴
| 機能 | 説明 |
|---|---|
| Code-First | スキーマをTypeScriptで定義 |
| 型安全 | 自動生成される型定義 |
| 管理画面 | 自動生成、カスタマイズ可能 |
| Access Control | フィールド・コレクション単位の権限管理 |
| Hooks | ライフサイクルイベントにフック |
| ローカライゼーション | 多言語コンテンツ対応 |
| バージョン管理 | ドキュメント履歴・下書き・公開管理 |
ポイント: Payloadは自前ホスティングが前提で、データの完全なコントロールが可能です。
プロジェクトセットアップ
# Payloadテンプレートから作成
npx create-payload-app@latest my-cms
# または既存Next.jsプロジェクトに追加
npm install payload @payloadcms/db-mongodb @payloadcms/richtext-slate
npm install -D @payloadcms/bundler-webpack
ディレクトリ構成
my-cms/
├── src/
│ ├── app/ # Next.js App Router
│ ├── collections/ # Payloadコレクション定義
│ │ ├── Posts.ts
│ │ ├── Users.ts
│ │ └── Media.ts
│ ├── globals/ # グローバル設定
│ │ └── Settings.ts
│ ├── payload.config.ts # Payload設定
│ └── server.ts # カスタムサーバー(任意)
├── public/
├── .env
└── package.json
環境変数
# .env
DATABASE_URI=mongodb://localhost:27017/my-cms
PAYLOAD_SECRET=your-secret-key-here
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
実践メモ: PAYLOAD_SECRETは、JWTトークンの署名に使われます。本番環境では安全なランダム文字列を設定してください。
Payload設定
// src/payload.config.ts
import { buildConfig } from 'payload/config';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import path from 'path';
import Users from './collections/Users';
import Posts from './collections/Posts';
import Media from './collections/Media';
import Categories from './collections/Categories';
import Settings from './globals/Settings';
export default buildConfig({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
admin: {
bundler: webpackBundler(),
meta: {
titleSuffix: ' - My CMS',
favicon: '/favicon.ico',
ogImage: '/og-image.jpg',
},
},
editor: slateEditor({}),
collections: [Users, Posts, Media, Categories],
globals: [Settings],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
url: process.env.DATABASE_URI!,
}),
cors: ['http://localhost:3000'],
csrf: ['http://localhost:3000'],
});
コレクション定義
Posts コレクション
// src/collections/Posts.ts
import { CollectionConfig } from 'payload/types';
const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'category', 'status', 'createdAt'],
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => user?.role === 'admin',
},
versions: {
drafts: true,
maxPerDoc: 50,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [
({ value, data }) => {
if (!value && data?.title) {
return data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
return value;
},
],
},
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
hasMany: false,
admin: {
position: 'sidebar',
},
},
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
hasMany: false,
admin: {
position: 'sidebar',
},
},
{
name: 'tags',
type: 'array',
fields: [
{
name: 'tag',
type: 'text',
},
],
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'content',
type: 'richText',
required: true,
localized: true,
},
{
name: 'excerpt',
type: 'textarea',
maxLength: 200,
localized: true,
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
{ label: 'Archived', value: 'archived' },
],
defaultValue: 'draft',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
},
},
{
name: 'seo',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
maxLength: 60,
},
{
name: 'description',
type: 'textarea',
maxLength: 160,
},
{
name: 'keywords',
type: 'text',
},
],
},
],
hooks: {
beforeChange: [
({ req, data, operation }) => {
if (operation === 'create') {
data.author = req.user.id;
}
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date();
}
return data;
},
],
},
};
export default Posts;
注意: slugフィールドにunique: trueを設定すると、データベースにユニーク制約が追加されます。
Users コレクション
// src/collections/Users.ts
import { CollectionConfig } from 'payload/types';
const Users: CollectionConfig = {
slug: 'users',
auth: {
tokenExpiration: 7200, // 2時間
verify: true,
maxLoginAttempts: 5,
lockTime: 600000, // 10分
},
admin: {
useAsTitle: 'email',
defaultColumns: ['email', 'role', 'createdAt'],
},
access: {
read: () => true,
create: () => true,
update: ({ req: { user }, id }) => {
if (user?.role === 'admin') return true;
return user?.id === id;
},
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'role',
type: 'select',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
{ label: 'Author', value: 'author' },
],
defaultValue: 'author',
required: true,
access: {
update: ({ req: { user } }) => user?.role === 'admin',
},
},
{
name: 'avatar',
type: 'upload',
relationTo: 'media',
},
{
name: 'bio',
type: 'textarea',
maxLength: 500,
},
],
};
export default Users;
Media コレクション
// src/collections/Media.ts
import { CollectionConfig } from 'payload/types';
import path from 'path';
const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: path.resolve(__dirname, '../../public/media'),
imageSizes: [
{
name: 'thumbnail',
width: 300,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 432,
position: 'centre',
},
{
name: 'hero',
width: 1920,
height: 1080,
position: 'centre',
},
],
adminThumbnail: 'thumbnail',
mimeTypes: ['image/*'],
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'text',
},
],
};
export default Media;
ポイント: imageSizesを定義すると、アップロード時に自動的に複数サイズの画像が生成されます。
グローバル設定
// src/globals/Settings.ts
import { GlobalConfig } from 'payload/types';
const Settings: GlobalConfig = {
slug: 'settings',
access: {
read: () => true,
update: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{
name: 'siteName',
type: 'text',
required: true,
defaultValue: 'My Blog',
},
{
name: 'siteDescription',
type: 'textarea',
maxLength: 200,
},
{
name: 'logo',
type: 'upload',
relationTo: 'media',
},
{
name: 'socialLinks',
type: 'group',
fields: [
{ name: 'twitter', type: 'text' },
{ name: 'github', type: 'text' },
{ name: 'linkedin', type: 'text' },
],
},
{
name: 'seo',
type: 'group',
fields: [
{ name: 'defaultTitle', type: 'text' },
{ name: 'defaultDescription', type: 'textarea' },
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
],
},
],
};
export default Settings;
Next.js統合
データ取得
// app/actions/posts.ts
'use server';
import { getPayloadClient } from '@/lib/payload';
export async function getPosts(limit = 10, page = 1) {
const payload = await getPayloadClient();
const result = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
},
sort: '-publishedAt',
limit,
page,
});
return result;
}
export async function getPostBySlug(slug: string) {
const payload = await getPayloadClient();
const result = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
status: { equals: 'published' },
},
limit: 1,
});
return result.docs[0];
}
// lib/payload.ts
import payload from 'payload';
import { InitOptions } from 'payload/config';
let cached = (global as any).payload;
if (!cached) {
cached = (global as any).payload = { client: null, promise: null };
}
export const getPayloadClient = async (): Promise<typeof payload> => {
if (cached.client) {
return cached.client;
}
if (!cached.promise) {
cached.promise = payload.init({
secret: process.env.PAYLOAD_SECRET!,
local: true,
} as InitOptions);
}
try {
cached.client = await cached.promise;
} catch (e: unknown) {
cached.promise = null;
throw e;
}
return cached.client;
};
ページ生成
// app/blog/[slug]/page.tsx
import { getPosts, getPostBySlug } from '@/app/actions/posts';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const { docs } = await getPosts(100);
return docs.map((post) => ({
slug: post.slug,
}));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
return {};
}
return {
title: post.seo?.title || post.title,
description: post.seo?.description || post.excerpt,
};
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<p>{new Date(post.publishedAt).toLocaleDateString()}</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
実践メモ: generateStaticParamsで静的ページを事前生成し、ビルド時にコンテンツを取得します。
Access Control(アクセス制御)
// collections/Posts.ts(詳細な権限管理)
const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: ({ req: { user } }) => {
// 公開済みは全員が閲覧可能
if (!user) {
return {
status: { equals: 'published' },
};
}
// 管理者は全て閲覧可能
if (user.role === 'admin') {
return true;
}
// 著者は自分の記事のみ
return {
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
};
},
create: ({ req: { user } }) => !!user,
update: ({ req: { user }, id }) => {
if (!user) return false;
if (user.role === 'admin') return true;
// 著者は自分の記事のみ更新可能
return {
author: { equals: user.id },
};
},
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{
name: 'internalNotes',
type: 'textarea',
access: {
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
},
],
};
注意: Access Controlはサーバー側で強制されます。フロントエンドでの非表示だけでは不十分です。
Hooks
// collections/Posts.ts
const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
beforeChange: [
async ({ req, data, operation }) => {
// 新規作成時、著者を自動設定
if (operation === 'create') {
data.author = req.user.id;
}
// 公開時のタイムスタンプ
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date();
}
return data;
},
],
afterChange: [
async ({ doc, req, operation }) => {
// 公開時にキャッシュをクリア
if (doc.status === 'published' && operation === 'update') {
await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/revalidate?secret=${process.env.REVALIDATE_SECRET}&path=/blog/${doc.slug}`);
}
},
],
beforeDelete: [
async ({ req, id }) => {
// 削除前にアップロードされた画像も削除
const payload = req.payload;
const post = await payload.findByID({ collection: 'posts', id });
if (post.featuredImage && typeof post.featuredImage !== 'string') {
await payload.delete({
collection: 'media',
id: post.featuredImage.id,
});
}
},
],
},
};
ローカライゼーション
// payload.config.ts
export default buildConfig({
localization: {
locales: ['en', 'ja', 'es'],
defaultLocale: 'en',
fallback: true,
},
collections: [Posts],
});
// collections/Posts.ts
const Posts: CollectionConfig = {
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true, // 多言語対応
},
{
name: 'content',
type: 'richText',
localized: true,
},
],
};
// データ取得時にロケールを指定
const payload = await getPayloadClient();
const post = await payload.findByID({
collection: 'posts',
id: 'abc123',
locale: 'ja', // 日本語で取得
});
ポイント: localized: trueを指定すると、フィールドが各言語ごとに保存されます。