1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【CMS】Payloadについてと実装例

1
Last updated at Posted at 2026-03-20

はじめに

  • Payloadというフレームワークを使用し、ブログ記事のCMS(コンテンツ管理システム)を実装しました。
  • 加えてLangChainを用いて記事の自動生成を実装しました。
  • DataBaseに関してはPostgreSQLを使用して実装しました。

1.PayLloadについて

1-1.Payloadとは

  • 完全オープンソースで、アプリフレームワークとヘッドレスCMSの両方の機能を兼ね備えています。
  • スキーマをコードで定義するだけで、完全なTypeScriptバックエンドと管理パネルを即座に利用できます。

1-2.特徴

  • Reactサーバー/クライアントコンポーネントを使用した完全な管理パネル。データ構造に合わせて設計され、独自のReactコンポーネントで完全に拡張可能です。
  • 自動データベーススキーマ、直接データベースアクセスと所有権、マイグレーション、トランザクション、適切なインデックス作成など
  • APIを利用してデータベースに直接アクセスできる。
  • 独自のアプリで使用できる認証
  • 高度にカスタマイズ可能なアクセス制御パターン
  • ファイルストレージや画像管理ツール(トリミング/焦点選択など)
  • ライブプレビュー - 更新時にフロントエンドのレンダリングコンテンツの変更をリアルタイムで確認できます

1-3.ソフトウェア要件

バージョン
JSパッケージマネージャ pnpm
npm
yarn
Node.js 20.9.0 以降
Next.js 15.2.9-15.2.x
15.3.9-15.3.x
15.4.11-15.4.x

※2026/03/20現在

1-4.DataBaseについて

  • Payloadは公式に以下のデータベースが使用できます
    • MongoDB
    • Postgres
    • SQLite

2.PostgreSQLでDB作成

2-1.DB作成

## ログイン
psql -U postgres

## データベース作成
CREATE DATABASE nextpay_db ENCODING 'UTF8';

## 新しいユーザー(ロール)の作成
CREATE USER sample_user WITH PASSWORD 'sample_pw';

## 権限の付与
GRANT ALL PRIVILEGES ON DATABASE nextpay_db TO sample_user;

## 【PostgreSQL15以降】スキーマへの権限付与も必要
ALTER DATABASE nextpay_db OWNER TO sample_user;

## ログアウト
\q

## 再度ログインできるか確認
psql -U sample_user -d nextpay_db

2-2.PostgreSQLのコマンドについて

コマンド 何ができるか
\l データベースの一覧を表示する
\c DB名 指定したデータベースに**接続(切り替え)する
\dt 現在のDBにあるテーブル一覧**を表示する
\d テーブル名 テーブルの**列構成(型や制約)**を確認する
\du ユーザー(ロール)一覧を表示する
\q psqlを**終了(ログアウト)**する

3.Payload導入

3-1.プロジェクト生成

npm create payload-app@latest

> npx
> create-payload-app
┌   create-payload-app
◇  Project name?
│  nextjs-payload
◇  Choose project template
│  blank
◇  Select a database
│  PostgreSQL
◆  Enter PostgreSQL connection string
│  postgres://sample_user:sample_pw@127.0.0.1:{設定したポート番号}/nextpay_db

4.TailsindCSSの導入

4-1.TailsindCSS関連のモジュールをインストール

  • 下記のコマンドで関連のモジュールをインストール
npm install tailwindcss @tailwindcss/postcss postcss
  • 【新規作成】postcss.config.mjs
// postcss.config.mjs
export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
};

4-2.globals.cssなどにTailwindを読み込ませる

  • src\app\ (frontend) \globals.css

4-3.shadcn/ui の導入

  • 下記のコマンドで関連のモジュールをインストール
## ui.shadcn
## https://ui.shadcn.com/
npx shadcn@latest init -d
npx shadcn@latest add  button card badge separator

5.Payload Collections の作成

5-1.Users コレクション(認証用)の作成

  • src\collections\Users.ts
import type { CollectionConfig } from 'payload'

export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  admin: {
    useAsTitle: 'email',
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
      label: '名前',
    },
  ],
}

5-2.Posts コレクション(ブログ記事)の作成

  • src\collections\Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'publishedAt', 'author'],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      label: 'タイトル',
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      label: 'スラッグ(URL用)',
      admin: {
        description: '半角英数字とハイフンのみ使用可能(例: my-first-post)',
      },
    },
    {
      name: 'excerpt',
      type: 'textarea',
      label: '概要(一覧に表示)',
    },
    {
      name: 'content',
      type: 'richText',
      required: true,
      label: '本文',
    },
    {
      name: 'thumbnail',
      type: 'upload',
      relationTo: 'media',
      label: 'サムネイル画像',
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
      required: true,
      label: '著者',
    },
    {
      name: 'categories',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: true,
      label: 'カテゴリ',
    },
    {
      name: 'status',
      type: 'select',
      options: [
        { label: '下書き', value: 'draft' },
        { label: '公開', value: 'published' },
      ],
      defaultValue: 'draft',
      required: true,
      label: '公開ステータス',
    },
    {
      name: 'publishedAt',
      type: 'date',
      label: '公開日時',
      admin: {
        date: {
          pickerAppearance: 'dayAndTime',
        },
      },
    },
  ],
}

5-3.Categories コレクションの作成

  • src\collections\Categories.ts
import type { CollectionConfig } from 'payload'

export const Categories: CollectionConfig = {
  slug: 'categories',
  admin: {
    useAsTitle: 'name',
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
      label: 'カテゴリ名',
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      label: 'スラッグ',
    },
  ],
}

5-4.Media コレクション(画像アップロード)の作成

  • src\collections\Media.ts
import type { CollectionConfig } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

export const Media: CollectionConfig = {
  slug: 'media',
  access: {
    read: () => true,
  },
  upload: {
    staticDir: path.resolve(dirname, '../../public/media'),
    imageSizes: [
      {
        name: 'thumbnail',
        width: 400,
        height: 300,
        position: 'centre',
      },
      {
        name: 'card',
        width: 768,
        height: 512,
        position: 'centre',
      },
    ],
    adminThumbnail: 'thumbnail',
    mimeTypes: ['image/*'],
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      label: 'Alt テキスト',
    },
  ],
}

6.Payload 設定ファイルの更新

6-1.設定概要

  • 日本語設定を有効化
  • メール機能を無効化
  • Collectionsのマイグレーション設定

6-2.必要なモジュールをインストール

npm install @payloadcms/translations
npm add @payloadcms/email-nodemailer nodemailer
npm i --save-dev @types/nodemailer

6-3.設定ファイルの修正

  • src\payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import nodemailer from 'nodemailer'
import path from 'path'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
// 以下:作成したColectionsを読み込ませる
import { Users } from './collections/Users'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { Media } from './collections/Media'
// 以下:英語と日本語を使える様にモジュール読み込ませる
import { en } from '@payloadcms/translations/languages/en'
import { ja } from '@payloadcms/translations/languages/ja'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

export default buildConfig({
  admin: {
    user: Users.slug,
    importMap: {
      baseDir: path.resolve(dirname),
    },
  },
  collections: [Users, Posts, Categories, Media],
  editor: lexicalEditor(),
  secret: process.env.PAYLOAD_SECRET || '',
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URL || '',
    },
  }),
  sharp,
  plugins: [],
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
  i18n: {
    supportedLanguages: { en,ja },
  },
  email: nodemailerAdapter({
    defaultFromAddress: 'noreply@example.com',
    defaultFromName: 'No Reply',
    transport: nodemailer.createTransport({ jsonTransport: true }),
  }),
})

6-4.型の自動生成

  • Payload CMSのコレクションやグローバル設定(payload.config.ts)を基に、対応するTypeScriptのインターフェース(.ts型定義ファイル)を自動生成するコマンドです。
npx payload generate:types

7.フロント画面の実装

7-1.Payloadクライアントユーティリティ作成

  • src\lib\payload.ts
import configPromise from '@payload-config'
import { getPayload } from 'payload'

export async function getPayloadClient() {
  const payload = await getPayload({ config: configPromise })
  return payload
}

7-2.RichTextレンダリング用コンポーネントを作成

  • src\components\parts\richText.tsx
import type { DefaultNodeTypes } from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import {type JSXConvertersFunction,RichText as RichTextConverter,} from '@payloadcms/richtext-lexical/react'
import { JSX } from 'react'

const jsxConverters: JSXConvertersFunction<DefaultNodeTypes> = ({ defaultConverters }) => ({
  ...defaultConverters,

  // 見出し
  heading: ({ node, nodesToJSX }) => {
    const children = nodesToJSX({ nodes: node.children })

    const styles: Record<string, string> = {
      h1: 'text-4xl font-extrabold text-gray-900 mt-10 mb-4',
      h2: 'text-4xl font-bold text-gray-800 mt-8 mb-3 border-b-2 border-gray-200 pb-2',
      h3: 'text-2xl font-semibold text-gray-700 mt-6 mb-2',
      h4: 'text-xl font-semibold text-gray-600 mt-4 mb-2',
    }

    const Tag = node.tag as keyof JSX.IntrinsicElements
    return <Tag className={styles[node.tag] ?? ''}>{children}</Tag>
  },

  // 段落
  paragraph: ({ node, nodesToJSX }) => (
    <p className="text-base leading-8 text-gray-700 mb-4">
      {nodesToJSX({ nodes: node.children })}
    </p>
  ),

  // 引用
  quote: ({ node, nodesToJSX }) => (
    <blockquote className="border-l-4 border-blue-400 pl-4 italic text-gray-500 my-4">
      {nodesToJSX({ nodes: node.children })}
    </blockquote>
  ),
})

type Props = {
  data: SerializedEditorState
  className?: string
}

export function RichText({ data, className }: Props) {
  return (
    <RichTextConverter
      converters={jsxConverters}
      data={data}
      className={className}
    />
  )
}

7-3.ブログ一覧ページ

  • src\app\ (frontend) \blog\page.tsx
  • 【サンプル】

7-4.ブログ詳細ページ

  • src\app\ (frontend) \blog\ [slug] \page.tsx
  • 【サンプル】

7-5.フロントエンド用レイアウト

  • src\app\ (frontend) \layout.tsx
import React from 'react'
import Link from 'next/link'
import type { Metadata } from 'next'
import "./globals.css";

export const metadata: Metadata = {
  title: 'My Blog',
  description: 'Payload CMS で作るブログ',
}

export default async function RootLayout(props: { children: React.ReactNode }) {
  const { children } = props

  return (
    <html lang="ja">
      <body>
      <header className="border-b">
          <div className="container mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
            <Link href="/" className="text-xl font-bold">
              My Blog
            </Link>
            <nav>
              <Link href="/blog" className="text-sm hover:underline mx-2">
                記事一覧
              </Link>
              <Link href="/admin" className="text-sm hover:underline">
                管理画面
              </Link>
            </nav>
          </div>
        </header>
        <main>{children}</main>
        <footer className="border-t mt-16">
          <div className="container mx-auto max-w-5xl px-4 py-6 text-center text-sm text-muted-foreground">
            © 2025 My Blog. Powered by Payload CMS + Next.js
          </div>
        </footer>
      </body>
    </html>
  )
}

8.DBマイグレーション

8-1.初回マイグレーション実行

## 依存パッケージのインストール
npm install

## 新規マイグレーション作成
npm run payload migrate:create

## DB マイグレーションの実行
###まだ実行されていないすべてのマイグレーションを実行
npm run payload migrate

8-2.そのほかマイグレーション実行コマンド

  • 下記は必要に応じて実行してください
## マイグレーション状態
npm run payload migrate:status

## 前回の移行処理をロールバック
npm run payload migrate:down

## 既に実行済みの移行をすべてロールバックし、再度実行
npm run payload migrate:refresh

## データベースからすべてのエンティティを削除し、すべてのマイグレーションを最初から再実行
npm run payload migrate:fresh

9.動作確認

9-1.開発サーバー起動

## 開発サーバー起動
npm run dev

9-2.管理画面アクセス>初期ユーザー登録>日本語設定>記事登録

  1. ブラウザで http://localhost:3000/admin を開く
  2. 「Create your first user」画面で管理者ユーザーを登録
  3. ブラウザでhttp://localhost:3000/admin/accountを開く
  4. language > 日本語
  5. ログインおよび設定後、管理画面から記事・カテゴリを作成

9-3.記事登録確認

  1. ブラウザで http://localhost:3000/blog を開く
  2. 以下サンプル動画

サンプル動画1

10.【AI】記事自動生成

  • LangChainを使用して記事の自動生成を実装

10-1.関連のモジュールをインストール

npm install @langchain/google-genai @langchain/core langchain

10-2.記事生成用のAPIRouteの作成

  • src\app\api\generate-content\route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ChatPromptTemplate } from '@langchain/core/prompts'

export async function POST(req: NextRequest) {
  const { title } = await req.json()

  if (!title) {
    return NextResponse.json({ error: 'タイトルが必要です' }, { status: 400 })
  }

  const model = new ChatGoogleGenerativeAI({
    model: "gemini-2.5-flash",
    apiKey: process.env.GOOGLE_AI_ST_API,
    temperature: 0.7,
  });

  const prompt = ChatPromptTemplate.fromMessages([
    [
      'system',
      `あなたはブログ記事を書くプロのライターです。
       与えられたタイトルに基づいて、SEOを意識した読みやすいブログ記事を日本語で書いてください。
       構成:導入文 → 本文(見出し2〜3つ) → まとめ`,
    ],
    ['human', 'タイトル: {title}'],
  ])

  const chain = prompt.pipe(model)
  const response = await chain.invoke({ title })

  return NextResponse.json({
    content: response.content,
  })
}

10-3.AI生成ボタン用のコンポーネントを作成

  • src\components\admin\AIGenerateButton\index.tsx
'use client'

import { useField, useFormFields } from '@payloadcms/ui'
import { useState } from 'react'

export function AIGenerateButton() {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [generatedContent, setGeneratedContent] = useState<string | null>(null)
  const [copied, setCopied] = useState(false)

  const titleField = useFormFields(([fields]) => fields['title'])
  const title = titleField?.value as string

  const { setValue: setExcerpt } = useField({ path: 'excerpt' })
  
  const handleGenerate = async () => {
    if (!title) {
      setError('先にタイトルを入力してください')
      return
    }

    setLoading(true)
    setError(null)
    setGeneratedContent(null)

    try {
      const res = await fetch('/api/generate-content', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      })

      if (!res.ok) throw new Error('生成に失敗しました')
      
      const { content } = await res.json()
      const firstSentence = content.split('\n').find((s: string) => s.trim()) ?? ''
      setExcerpt(firstSentence)
      setGeneratedContent(content)
    } catch (e) {
      setError('記事の生成に失敗しました。もう一度お試しください。')
    } finally {
      setLoading(false)
    }
  }

  const handleCopy = async () => {
    if (!generatedContent) return
    await navigator.clipboard.writeText(generatedContent)
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
  }

  return (
    <div style={{ marginBottom: '1.5rem' }}>
      {/* 生成ボタン */}
      <button
        type="button"
        onClick={handleGenerate}
        disabled={loading || !title}
        style={{
          padding: '8px 16px',
          backgroundColor: loading || !title ? '#ccc' : '#6d5dfc',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: loading || !title ? 'not-allowed' : 'pointer',
          fontWeight: 'bold',
        }}
      >
        {loading ? '生成中...' : '✨ AIで記事を自動生成'}
      </button>

      {!title && (
        <p style={{ color: '#888', fontSize: '12px', marginTop: '4px' }}>
          タイトルを入力すると生成できます
        </p>
      )}

      {error && (
        <p style={{ color: 'red', fontSize: '12px', marginTop: '4px' }}>{error}</p>
      )}

      {/* 生成結果プレビューエリア */}
      {generatedContent && (
        <div style={{ marginTop: '1rem' }}>
          <div style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: '4px',
          }}>
            <p style={{ margin: 0, fontSize: '13px', fontWeight: 'bold' }}>
              生成結果(下のエディタにコピペしてください)
            </p>
            {/* コピーボタン */}
            <button
              type="button"
              onClick={handleCopy}
              style={{
                padding: '4px 12px',
                backgroundColor: copied ? '#22c55e' : '#e2e8f0',
                color: copied ? 'white' : '#333',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer',
                fontSize: '12px',
              }}
            >
              {copied ? '✓ コピーしました' : 'クリップボードにコピー'}
            </button>
          </div>

          {/* テキスト表示エリア */}
          <textarea
            readOnly
            value={generatedContent}
            rows={15}
            style={{
              width: '100%',
              padding: '12px',
              fontSize: '13px',
              lineHeight: '1.7',
              border: '1px solid #d1d5db',
              borderRadius: '4px',
              backgroundColor: '#f9fafb',
              resize: 'vertical',
              fontFamily: 'inherit',
              boxSizing: 'border-box',
            }}
          />
        </div>
      )}
    </div>
  )
}

10-4.Postsコレクションに「UIField」を追加

  • 【追加修正】
  • src\collections\Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'publishedAt', 'author'],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      label: 'タイトル',
    },
    {
      name: 'aiGenerate',
      type: 'ui',
      admin: {
        components: {
          Field: '@/components/admin/AIGenerateButton#AIGenerateButton',
        },
      },
    },
    {省略}
  ]
}

10-5.環境変数の追加

  • .env
  • 追加
GOOGLE_AI_ST_API={APIキーを入れる}

10-6.パスの修正

npx payload generate:importmap

10-6.開発サーバー実行

## 開発サーバー起動
npm run dev
  • 記事生成例

サンプル動画2

  • フロント画面例

サンプル動画3

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?