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

中級 | 15分 で読める | 2026.04.24

公式ドキュメント

この記事の要点

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

関連記事

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

メールで無料相談する
← 一覧に戻る