Payload CMS v3完全ガイド - Next.js統合とTypeScript完全対応
Payload CMS v3とは
Payload CMSは、TypeScriptで構築されたオープンソースのヘッドレスCMSです。v3では、Next.js App Routerとの完全統合、パフォーマンスの大幅改善、開発者体験の向上が実現されました。
v3の主な新機能
- Next.js App Router完全統合: 管理画面がNext.jsアプリ内に統合
- ローカルAPI: データベースへの直接アクセスで高速化
- Drizzle ORMサポート: PostgreSQL、SQLiteに対応
- 型の自動生成: コレクション定義から型を自動生成
- Lexical RichTextエディタ: モダンなWYSIWYGエディタ
- バージョン管理: ドラフト、公開、履歴管理
- 国際化対応: 多言語コンテンツの管理
インストールとセットアップ
新規プロジェクト作成
# Create Next.js + Payload プロジェクト
npx create-payload-app@latest my-cms
# 対話形式で設定
# - Next.js App Router を選択
# - データベース: PostgreSQL or MongoDB
# - テンプレート: Blank または Blog Template
既存Next.jsプロジェクトに追加
# 依存関係をインストール
npm install payload @payloadcms/db-postgres @payloadcms/richtext-lexical @payloadcms/next
# または
pnpm add payload @payloadcms/db-postgres @payloadcms/richtext-lexical @payloadcms/next
Payload設定ファイル
// payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
export default buildConfig({
// 管理画面のルート
admin: {
user: 'users',
// Next.jsと統合
importMap: {
baseDir: path.resolve(import.meta.dirname),
},
},
// コレクション定義
collections: [
// 後述
],
// データベース設定
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
}),
// リッチテキストエディタ
editor: lexicalEditor(),
// TypeScript型生成
typescript: {
outputFile: path.resolve(import.meta.dirname, 'payload-types.ts'),
},
// アップロード先
upload: {
limits: {
fileSize: 5000000, // 5MB
},
},
})
Next.js統合
// app/(payload)/layout.tsx
import './custom.scss'
import { RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap'
import config from '@payload-config'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<RootLayout config={config} importMap={importMap}>
{children}
</RootLayout>
)
}
// app/(payload)/admin/[[...segments]]/page.tsx
import { AdminView } from '@payloadcms/next/views'
import { Metadata } from 'next'
import config from '@payload-config'
export const generateMetadata = (): Metadata => {
return {
title: 'Payload Admin',
}
}
export default AdminView({ config })
コレクション定義
Payloadの核となるのがコレクション定義です。
基本的なコレクション
// collections/Posts.ts
import { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'createdAt'],
},
access: {
read: () => true,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
},
{
name: 'content',
type: 'richText',
required: true,
},
{
name: 'excerpt',
type: 'textarea',
maxLength: 200,
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
},
{
name: 'tags',
type: 'text',
hasMany: true,
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
position: 'sidebar',
},
},
],
versions: {
drafts: true,
},
}
メディアコレクション
// collections/Media.ts
import { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'media',
imageSizes: [
{
name: 'thumbnail',
width: 400,
height: 300,
position: 'centre',
},
{
name: 'card',
width: 768,
height: 1024,
position: 'centre',
},
{
name: 'tablet',
width: 1024,
height: undefined,
position: 'centre',
},
],
adminThumbnail: 'thumbnail',
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'textarea',
},
],
}
カテゴリーコレクション
// collections/Categories.ts
import { CollectionConfig } from 'payload'
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,
},
{
name: 'description',
type: 'textarea',
},
{
name: 'parent',
type: 'relationship',
relationTo: 'categories',
admin: {
description: '親カテゴリーを選択(空の場合はトップレベル)',
},
},
],
}
TypeScript型生成
Payload v3では、コレクション定義から自動的にTypeScript型を生成します。
# 型を生成
payload generate:types
生成された型を使用:
// app/blog/[slug]/page.tsx
import { Post, Media } from '@/payload-types'
interface PageProps {
params: { slug: string }
}
async function getPost(slug: string): Promise<Post | null> {
const payload = await getPayload({ config })
const result = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
},
limit: 1,
})
return result.docs[0] || null
}
export default async function BlogPostPage({ params }: PageProps) {
const post = await getPost(params.slug)
if (!post) {
notFound()
}
const featuredImage = post.featuredImage as Media
return (
<article>
<h1>{post.title}</h1>
{featuredImage && (
<img src={featuredImage.url} alt={featuredImage.alt} />
)}
<div>{post.content}</div>
</article>
)
}
ローカルAPIの使用
v3の最大の特徴の一つがローカルAPIです。REST APIを介さず、直接データベースにアクセスできます。
// app/blog/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
export const revalidate = 60 // 60秒キャッシュ
async function getPosts() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: {
status: { equals: 'published' },
},
sort: '-publishedAt',
limit: 10,
})
return posts.docs
}
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
<h1>Blog</h1>
<div className="grid gap-6">
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<a href={`/blog/${post.slug}`}>Read more</a>
</article>
))}
</div>
</div>
)
}
高度なクエリ
// 複雑な条件でのフィルタリング
const posts = await payload.find({
collection: 'posts',
where: {
and: [
{
status: { equals: 'published' },
},
{
or: [
{
'categories.slug': { equals: 'technology' },
},
{
tags: { contains: 'javascript' },
},
],
},
{
publishedAt: {
greater_than: new Date('2024-01-01'),
},
},
],
},
depth: 2, // リレーション深度
limit: 20,
page: 1,
})
// 集計
const count = await payload.count({
collection: 'posts',
where: {
status: { equals: 'published' },
},
})
// 作成
const newPost = await payload.create({
collection: 'posts',
data: {
title: 'New Post',
slug: 'new-post',
content: 'Content here...',
status: 'draft',
author: userId,
},
})
// 更新
await payload.update({
collection: 'posts',
id: postId,
data: {
status: 'published',
publishedAt: new Date(),
},
})
// 削除
await payload.delete({
collection: 'posts',
id: postId,
})
Lexicalリッチテキストエディタ
v3では、新しいLexicalエディタがデフォルトです。
カスタムブロック
// collections/Posts.ts
import {
BlocksFeature,
BoldTextFeature,
ItalicTextFeature,
LinkFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
export const Posts: CollectionConfig = {
// ...
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BoldTextFeature(),
ItalicTextFeature(),
LinkFeature(),
BlocksFeature({
blocks: [
{
slug: 'callout',
fields: [
{
name: 'type',
type: 'select',
options: ['info', 'warning', 'error', 'success'],
defaultValue: 'info',
},
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
{
slug: 'codeBlock',
fields: [
{
name: 'language',
type: 'select',
options: ['javascript', 'typescript', 'python', 'html', 'css'],
},
{
name: 'code',
type: 'textarea',
required: true,
},
],
},
],
}),
],
}),
},
],
}
リッチテキストのレンダリング
// components/RichText.tsx
import React from 'react'
import { serializeLexical } from '@payloadcms/richtext-lexical/react'
interface RichTextProps {
content: any
}
export function RichText({ content }: RichTextProps) {
const html = serializeLexical({ nodes: content.root.children })
return (
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
アクセス制御
細かいアクセス制御が可能です。
// collections/Posts.ts
export const Posts: CollectionConfig = {
// ...
access: {
// 公開済み記事は誰でも読める
read: ({ req: { user } }) => {
if (user) return true
return {
status: { equals: 'published' },
}
},
// ログインユーザーのみ作成可能
create: ({ req: { user } }) => !!user,
// 作成者または管理者のみ更新可能
update: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return {
author: { equals: user.id },
}
},
// 管理者のみ削除可能
delete: ({ req: { user } }) => {
return user?.role === 'admin'
},
},
// フィールドレベルのアクセス制御
fields: [
{
name: 'internalNotes',
type: 'textarea',
access: {
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
},
],
}
Hooks(ライフサイクルイベント)
// collections/Posts.ts
export const Posts: CollectionConfig = {
// ...
hooks: {
// 作成前
beforeChange: [
async ({ data, req, operation }) => {
if (operation === 'create') {
// 作成者を自動設定
data.author = req.user.id
// スラッグが未設定なら生成
if (!data.slug && data.title) {
data.slug = slugify(data.title)
}
}
return data
},
],
// 作成後
afterChange: [
async ({ doc, req, operation }) => {
if (operation === 'create' || operation === 'update') {
// 検索インデックスを更新
await updateSearchIndex(doc)
// 通知を送信
if (doc.status === 'published') {
await sendNotification({
title: `New post: ${doc.title}`,
url: `/blog/${doc.slug}`,
})
}
}
},
],
// 削除後
afterDelete: [
async ({ doc }) => {
// 関連データをクリーンアップ
await cleanupRelatedData(doc.id)
},
],
},
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
グローバル設定
サイト全体の設定を管理できます。
// globals/Settings.ts
import { GlobalConfig } from 'payload'
export const Settings: GlobalConfig = {
slug: 'settings',
access: {
read: () => true,
update: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{
name: 'siteName',
type: 'text',
required: true,
},
{
name: 'siteDescription',
type: 'textarea',
},
{
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: 'metaTitle', type: 'text' },
{ name: 'metaDescription', type: 'textarea' },
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
],
},
],
}
使用例:
// app/layout.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
async function getSettings() {
const payload = await getPayload({ config })
return await payload.findGlobal({ slug: 'settings' })
}
export default async function RootLayout({ children }) {
const settings = await getSettings()
return (
<html>
<head>
<title>{settings.siteName}</title>
<meta name="description" content={settings.siteDescription} />
</head>
<body>{children}</body>
</html>
)
}
まとめ
Payload CMS v3は、Next.js App Routerとの完全統合により、これまで以上に強力で使いやすいヘッドレスCMSになりました。以下のような特徴があります:
- TypeScript完全対応: 型安全なCMS開発
- Next.js統合: サーバーコンポーネントで直接データ取得
- 柔軟なデータモデル: コレクション、グローバル、リレーション
- 強力なアクセス制御: きめ細かい権限管理
- バージョン管理: ドラフト、公開、履歴
- 拡張性: Hooks、カスタムフィールド、プラグイン
従来のCMSと比較して、開発者体験が圧倒的に優れており、TypeScriptの型安全性を活かした開発が可能です。ぜひPayload CMS v3を使って、モダンなコンテンツ管理システムを構築してみてください!