Payload CMS実践 - ヘッドレスCMSを自前運用する

中級 | 15分 で読める | 2026.04.24

公式ドキュメント

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を指定すると、フィールドが各言語ごとに保存されます。

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リソース

  • 公式ドキュメント - Payload CMS実践 - ヘッドレスCMSを自前運用する を確認するための一次情報

関連記事

← 一覧に戻る
PR
PR
PR
PR