最終更新:

Payload CMS実践ガイド: TypeScriptネイティブのヘッドレスCMS構築


Payload CMSは、TypeScriptネイティブのヘッドレスCMSとして急速に注目を集めています。既存記事では基本的な統合方法を紹介しましたが、本記事ではプロダクション環境で使うための実践的なテクニックに焦点を当てます。

プロダクションレベルのコレクション設計

高度なフィールドバリデーション

Payload CMSの真価は、コードベースのバリデーションにあります。

// collections/Articles.ts
import { CollectionConfig } from 'payload/types';
import { slugify } from '../utils/slugify';

export const Articles: CollectionConfig = {
  slug: 'articles',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'publishedAt'],
    group: 'Content',
  },
  versions: {
    drafts: true,
    maxPerDoc: 50,
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      minLength: 10,
      maxLength: 100,
      validate: async (val, { operation, data }) => {
        if (operation === 'create' || operation === 'update') {
          // 重複チェック
          const existing = await payload.find({
            collection: 'articles',
            where: {
              title: { equals: val },
              id: { not_equals: data?.id },
            },
            limit: 1,
          });

          if (existing.docs.length > 0) {
            return '同じタイトルの記事が既に存在します';
          }
        }
        return true;
      },
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
      admin: {
        position: 'sidebar',
        description: 'URL用のスラッグ(自動生成されます)',
      },
      hooks: {
        beforeValidate: [
          ({ data, operation, value }) => {
            if (operation === 'create' && !value && data?.title) {
              return slugify(data.title);
            }
            return value;
          },
        ],
      },
      validate: (val) => {
        const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
        if (!slugRegex.test(val)) {
          return 'スラッグは小文字英数字とハイフンのみ使用できます';
        }
        return true;
      },
    },
    {
      name: 'content',
      type: 'richText',
      required: true,
      admin: {
        elements: ['h2', 'h3', 'h4', 'link', 'ol', 'ul', 'upload'],
        leaves: ['bold', 'italic', 'code'],
      },
      validate: (val) => {
        const textContent = JSON.stringify(val);
        const wordCount = textContent.split(/\s+/).length;

        if (wordCount < 300) {
          return '記事は最低300文字以上必要です';
        }
        if (wordCount > 10000) {
          return '記事は10000文字以内にしてください';
        }
        return true;
      },
    },
    {
      name: 'excerpt',
      type: 'textarea',
      maxLength: 300,
      admin: {
        description: '検索結果やOGPに表示される要約文',
      },
      hooks: {
        beforeChange: [
          ({ data, value }) => {
            // excerptが空の場合、contentから自動生成
            if (!value && data?.content) {
              const plainText = JSON.stringify(data.content)
                .replace(/<[^>]*>/g, '')
                .substring(0, 300);
              return plainText + '...';
            }
            return value;
          },
        ],
      },
    },
    {
      name: 'status',
      type: 'select',
      required: true,
      options: [
        { label: '下書き', value: 'draft' },
        { label: 'レビュー待ち', value: 'review' },
        { label: '公開', value: 'published' },
        { label: 'アーカイブ', value: 'archived' },
      ],
      defaultValue: 'draft',
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'publishedAt',
      type: 'date',
      admin: {
        position: 'sidebar',
        date: {
          pickerAppearance: 'dayAndTime',
        },
      },
      hooks: {
        beforeChange: [
          ({ data, value, operation }) => {
            // 公開ステータスに変更された場合、自動で公開日を設定
            if (operation === 'update' &&
                data?.status === 'published' &&
                !value) {
              return new Date().toISOString();
            }
            return value;
          },
        ],
      },
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
      required: true,
      hasMany: false,
      admin: {
        position: 'sidebar',
      },
      hooks: {
        beforeChange: [
          ({ req, value, operation }) => {
            // 作成時に自動で現在のユーザーを設定
            if (operation === 'create' && !value) {
              return req.user?.id;
            }
            return value;
          },
        ],
      },
    },
    {
      name: 'categories',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: true,
      minRows: 1,
      maxRows: 5,
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'tags',
      type: 'relationship',
      relationTo: 'tags',
      hasMany: true,
      maxRows: 10,
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'featuredImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
      filterOptions: {
        mimeType: { contains: 'image' },
      },
    },
    {
      name: 'seo',
      type: 'group',
      fields: [
        {
          name: 'metaTitle',
          type: 'text',
          maxLength: 60,
          hooks: {
            beforeChange: [
              ({ data, value }) => {
                return value || data?.title;
              },
            ],
          },
        },
        {
          name: 'metaDescription',
          type: 'textarea',
          maxLength: 160,
          hooks: {
            beforeChange: [
              ({ data, value }) => {
                return value || data?.excerpt;
              },
            ],
          },
        },
        {
          name: 'keywords',
          type: 'text',
        },
        {
          name: 'ogImage',
          type: 'upload',
          relationTo: 'media',
          hooks: {
            beforeChange: [
              ({ data, value }) => {
                return value || data?.featuredImage;
              },
            ],
          },
        },
      ],
    },
    {
      name: 'readTime',
      type: 'number',
      admin: {
        readOnly: true,
        position: 'sidebar',
        description: '推定読了時間(分)',
      },
      hooks: {
        beforeChange: [
          ({ data }) => {
            if (data?.content) {
              const wordCount = JSON.stringify(data.content).split(/\s+/).length;
              return Math.ceil(wordCount / 200); // 1分200文字で計算
            }
            return 0;
          },
        ],
      },
    },
  ],
  hooks: {
    beforeChange: [
      async ({ data, req, operation }) => {
        // 管理者以外はレビュー待ちステータスにしかできない
        if (operation === 'create' && req.user?.role !== 'admin') {
          if (data.status === 'published') {
            data.status = 'review';
          }
        }
        return data;
      },
    ],
    afterChange: [
      async ({ doc, req, operation, previousDoc }) => {
        // ステータスが公開に変更されたら通知
        if (operation === 'update' &&
            doc.status === 'published' &&
            previousDoc.status !== 'published') {
          // 通知ロジック(例: メール送信)
          await sendPublishNotification(doc, req.user);
        }
      },
    ],
  },
  access: {
    read: ({ req }) => {
      // 管理者は全て閲覧可能
      if (req.user?.role === 'admin') return true;

      // ログインユーザーは自分の記事と公開記事を閲覧可能
      if (req.user) {
        return {
          or: [
            { status: { equals: 'published' } },
            { author: { equals: req.user.id } },
          ],
        };
      }

      // 未認証ユーザーは公開記事のみ
      return { status: { equals: 'published' } };
    },
    create: ({ req }) => Boolean(req.user),
    update: ({ req }) => {
      if (req.user?.role === 'admin') return true;
      return { author: { equals: req.user?.id } };
    },
    delete: ({ req }) => req.user?.role === 'admin',
  },
};

リレーショナル設計のベストプラクティス

// collections/Categories.ts
export const Categories: CollectionConfig = {
  slug: 'categories',
  admin: {
    useAsTitle: 'name',
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
    },
    {
      name: 'description',
      type: 'textarea',
    },
    {
      name: 'parent',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: false,
      admin: {
        description: '親カテゴリー(階層構造を作成)',
      },
    },
    {
      name: 'order',
      type: 'number',
      defaultValue: 0,
      admin: {
        description: '表示順序',
      },
    },
  ],
};

// collections/Tags.ts
export const Tags: CollectionConfig = {
  slug: 'tags',
  admin: {
    useAsTitle: 'name',
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
    },
    {
      name: 'color',
      type: 'text',
      admin: {
        description: 'タグの色(Hex形式)',
      },
      validate: (val) => {
        if (val && !/^#[0-9A-F]{6}$/i.test(val)) {
          return '有効なHexカラーコードを入力してください';
        }
        return true;
      },
    },
  ],
};

カスタムプラグイン開発

画像最適化プラグイン

// plugins/imageOptimization.ts
import { Plugin } from 'payload/config';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs/promises';

export const imageOptimizationPlugin = (): Plugin => ({
  name: 'image-optimization',
  hooks: {
    afterChange: [
      async ({ doc, collection }) => {
        if (collection.slug !== 'media') return doc;

        const filePath = doc.filename;
        const mimeType = doc.mimeType;

        // 画像ファイルのみ処理
        if (!mimeType?.startsWith('image/')) return doc;

        const fullPath = path.join(process.cwd(), 'media', filePath);

        try {
          // WebP形式に変換
          const webpPath = fullPath.replace(/\.(jpg|jpeg|png)$/i, '.webp');
          await sharp(fullPath)
            .webp({ quality: 80 })
            .toFile(webpPath);

          // AVIF形式に変換(最新ブラウザ向け)
          const avifPath = fullPath.replace(/\.(jpg|jpeg|png)$/i, '.avif');
          await sharp(fullPath)
            .avif({ quality: 75 })
            .toFile(avifPath);

          console.log(`Optimized images created for ${filePath}`);
        } catch (error) {
          console.error(`Failed to optimize ${filePath}:`, error);
        }

        return doc;
      },
    ],
  },
});

全文検索プラグイン

// plugins/fullTextSearch.ts
import { Plugin } from 'payload/config';
import MeiliSearch from 'meilisearch';

export const fullTextSearchPlugin = (options: {
  host: string;
  apiKey: string;
  collections: string[];
}): Plugin => {
  const client = new MeiliSearch({
    host: options.host,
    apiKey: options.apiKey,
  });

  return {
    name: 'full-text-search',
    hooks: {
      afterChange: [
        async ({ doc, collection, operation }) => {
          if (!options.collections.includes(collection.slug)) {
            return doc;
          }

          const indexName = collection.slug;
          const index = client.index(indexName);

          try {
            if (operation === 'delete') {
              await index.deleteDocument(doc.id);
            } else {
              // ドキュメントをインデックスに追加/更新
              await index.addDocuments([
                {
                  id: doc.id,
                  title: doc.title,
                  content: doc.content,
                  excerpt: doc.excerpt,
                  slug: doc.slug,
                  createdAt: doc.createdAt,
                },
              ]);
            }
          } catch (error) {
            console.error('MeiliSearch indexing error:', error);
          }

          return doc;
        },
      ],
    },
    endpoints: [
      {
        path: '/search',
        method: 'get',
        handler: async (req, res) => {
          const { q, collection } = req.query;

          if (!q || !collection) {
            return res.status(400).json({ error: 'Query and collection required' });
          }

          try {
            const index = client.index(collection as string);
            const results = await index.search(q as string, {
              limit: 20,
            });

            return res.json(results);
          } catch (error) {
            return res.status(500).json({ error: 'Search failed' });
          }
        },
      },
    ],
  };
};

使用方法

// payload.config.ts
import { buildConfig } from 'payload/config';
import { imageOptimizationPlugin } from './plugins/imageOptimization';
import { fullTextSearchPlugin } from './plugins/fullTextSearch';

export default buildConfig({
  // ...
  plugins: [
    imageOptimizationPlugin(),
    fullTextSearchPlugin({
      host: process.env.MEILISEARCH_HOST,
      apiKey: process.env.MEILISEARCH_API_KEY,
      collections: ['articles', 'products'],
    }),
  ],
});

パフォーマンス最適化

データベースインデックス戦略

// collections/Articles.ts(インデックス最適化版)
export const Articles: CollectionConfig = {
  slug: 'articles',
  fields: [
    {
      name: 'slug',
      type: 'text',
      unique: true,
      index: true, // ルックアップクエリ用
    },
    {
      name: 'status',
      type: 'select',
      index: true, // フィルタリング用
    },
    {
      name: 'publishedAt',
      type: 'date',
      index: true, // ソート用
    },
  ],
  // 複合インデックス(PostgreSQL/MongoDB)
  indexes: [
    {
      fields: {
        status: 1,
        publishedAt: -1,
      },
    },
  ],
};

クエリの最適化

// lib/queries.ts
import payload from 'payload';

// 悪い例:N+1問題
export async function getArticlesBad() {
  const articles = await payload.find({
    collection: 'articles',
    limit: 10,
  });

  // 各記事ごとにauthor情報を取得(N+1問題)
  for (const article of articles.docs) {
    const author = await payload.findByID({
      collection: 'users',
      id: article.author,
    });
    article.authorData = author;
  }

  return articles;
}

// 良い例:depth指定で一度に取得
export async function getArticlesGood() {
  const articles = await payload.find({
    collection: 'articles',
    limit: 10,
    depth: 2, // author情報も一緒に取得
  });

  return articles;
}

// 更に最適化:必要なフィールドのみ取得
export async function getArticlesOptimized() {
  const articles = await payload.find({
    collection: 'articles',
    limit: 10,
    depth: 2,
    select: {
      title: true,
      slug: true,
      excerpt: true,
      publishedAt: true,
      'author.name': true,
      'author.avatar': true,
    },
  });

  return articles;
}

キャッシング戦略

// lib/cache.ts
import { unstable_cache } from 'next/cache';

export const getCachedArticles = unstable_cache(
  async (status: string, limit: number) => {
    const articles = await payload.find({
      collection: 'articles',
      where: { status: { equals: status } },
      limit,
      depth: 2,
    });

    return articles;
  },
  ['articles-list'],
  {
    revalidate: 60, // 60秒ごとに再検証
    tags: ['articles'],
  }
);

// キャッシュを無効化するフック
// collections/Articles.ts
export const Articles: CollectionConfig = {
  slug: 'articles',
  hooks: {
    afterChange: [
      async () => {
        // Next.jsのキャッシュを無効化
        revalidateTag('articles');
      },
    ],
  },
};

セキュリティのベストプラクティス

ファイルアップロードの制限

// collections/Media.ts
export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    staticDir: path.resolve(__dirname, '../../media'),
    mimeTypes: [
      'image/jpeg',
      'image/png',
      'image/webp',
      'image/gif',
      'application/pdf',
    ],
    imageSizes: [
      {
        name: 'thumbnail',
        width: 400,
        height: 300,
        position: 'centre',
      },
      {
        name: 'card',
        width: 768,
        height: 432,
        position: 'centre',
      },
    ],
    adminThumbnail: 'thumbnail',
    handlers: [
      async (req, file, fileData) => {
        // ファイルサイズチェック
        if (fileData.size > 10 * 1024 * 1024) { // 10MB
          throw new Error('ファイルサイズが大きすぎます(最大10MB)');
        }

        // ファイル名のサニタイズ
        const sanitized = fileData.filename
          .replace(/[^a-zA-Z0-9.-]/g, '_')
          .toLowerCase();

        fileData.filename = `${Date.now()}-${sanitized}`;

        return fileData;
      },
    ],
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      required: true,
    },
  ],
};

レート制限の実装

// server.ts
import rateLimit from 'express-rate-limit';

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 100, // 最大100リクエスト
  message: 'Too many requests from this IP',
});

app.use('/api/', apiLimiter);

本番環境へのデプロイ

Docker構成

# Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:18-alpine

WORKDIR /app

COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

EXPOSE 3000

CMD ["node", "build/server.js"]
# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: payload
      POSTGRES_USER: payload
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: always

  payload:
    build: .
    environment:
      DATABASE_URL: postgresql://payload:${DB_PASSWORD}@postgres:5432/payload
      PAYLOAD_SECRET: ${PAYLOAD_SECRET}
      NODE_ENV: production
    ports:
      - "3000:3000"
    depends_on:
      - postgres
    volumes:
      - ./media:/app/media
    restart: always

volumes:
  postgres_data:

環境変数管理

DATABASE_URL=postgresql://user:password@host:5432/dbname
PAYLOAD_SECRET=your-secret-key-min-32-characters
PAYLOAD_PUBLIC_SERVER_URL=https://yourdomain.com

# S3互換ストレージ(CloudflareR2など)
S3_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
S3_BUCKET=media
S3_ACCESS_KEY_ID=xxx
S3_SECRET_ACCESS_KEY=xxx

# メール送信(Resend)
RESEND_API_KEY=re_xxxxx

# 検索(MeiliSearch)
MEILISEARCH_HOST=https://search.yourdomain.com
MEILISEARCH_API_KEY=xxx

まとめ

Payload CMSは、TypeScriptネイティブのヘッドレスCMSとして以下の点で優れています:

  1. 型安全性 - コード補完とエラー検出
  2. 柔軟なバリデーション - ビジネスロジックに応じたカスタマイズ
  3. プラグインシステム - 機能拡張が容易
  4. パフォーマンス - 適切なインデックスとキャッシング戦略で高速化

プロダクション環境では、アクセス制御、パフォーマンス最適化、セキュリティ対策を適切に実装することで、スケーラブルなCMSシステムを構築できます。