Headless CMSをお探しですか?Headless CMSであれば日本国内でいえばmicroCMSやKuroco, Newt、グローバルであればContentful、オープンソースであればStrapiを思い浮かべる人も多いかと思います。本文書では2023年10月にバージョン2がリリースされたHeadless CMSのPayloadとはどんなものか実際にローカルのMacOS環境にインストールを行い動かしてみました。

2024年11月19日にバージョン3がリリースされました。

Headless CMSとは

Headless CMSは、WordPressのような従来のCMS(Content Management System)とは異なり、コンテンツの管理と表示を分離したコンテンツ管理システムです。WordPressのような従来型のCMSではコンテンツの作成・管理機能とどのようにページを表示するのかという表示(フロントエンド)の部分も一体化しています。一方、Headless CMSは表示の部分の機能を持っておらずコンテンツの管理部分のみを提供し表示部分はAPI(通常はRESTまたはGraphQL)を通して別のシステムやフレームワーク(例えば、Next.jsやAstro, Nuxtなど)で行います。コンテンツの作成・管理を行う「頭(Head)」の部分だけを持っているので、「Headless(頭がない)」と呼ばれます。

Payloadとは

PayloadはNode.js上で動作する、TypeScriptで書かれたヘッドレスCMSおよびアプリケーションフレームワークです。バックエンドはNode.js、管理画面はReactを利用して構築されています。Payloadが提供する直感的なAdminパネルを利用することで、専門知識がなくても簡単にデータ管理が可能です。ただし、バージョン3以降ではNext.jsとの統合も強化され、柔軟なデプロイや高度なフロントエンド開発をサポートしています。登録したデータは、特別な設定を行わなくてもREST APIまたはGraphQLを通じて外部からアクセス可能です。さらに、きめ細かなアクセスコントロール機能により、データの安全性を確保しながら、Collection(データベースのモデル/テーブルに相当)ごとに認証機能を設定することもできます。

Payloadはオープンソースプロジェクトとして提供されているため、オンプレミス(自社のサーバー)や様々なクラウド環境に自由にデプロイできます。公式のクラウドサービスである”Payload Cloud”利用すれば、インフラ管理を意識することなく簡単にPayloadを運用することが可能です。このクラウドサービスでは、ホスティングや自動スケーリング、バックアップ機能などが提供され、インフラ管理の手間を省き、より開発に集中できます。ただし、Payload Cloudは商用サービスのため、利用には料金が発生します。

Payloadは、コードベース全体がTypeScriptで記述されているため、型安全性を重視する開発チームにとって大きな利点となります。これまでサポートしていたデータベースはMongoDBのみでしたが、バージョン2へのアップデートでORMのDrizzleを利用することでPostgreSQLも利用可能となりました。さらにビルドにはWebpackだけではなくViteが新たにサポートされました。

またリッチテキストエディタについても更新が行われており、SlateだけではなくMetaが開発した次世代エディタフレームワークであるLexicalをサポートしたりと積極的に開発が進められている注目度の高いHeadless CMSの一つです。

MongoDBにはMongooseを利用しています。今後はDrizzleを利用してSQLiteやMySQLもサポートする予定です。
fukidashi

Payloadプロジェクトの作成

Payloadプロジェクトを作成するために”npx create-payload-app@latest”コマンドを実行します。実行するとプロジェクト名やテンプレート、接続するデータベースなどを選択、入力する必要があります。プロジェクト名は任意の名前をつけることができるのでここでは”payload_app”としています。テンプレートはBlank, データベースにはMongoDBを選択します。betaですがPostrgesも選択可能です。MongoDBを選択すると接続文字列を設定する必要がありますがデフォルトのままで進めます。


 % npx create-payload-app@latest
Need to install the following packages:
create-payload-app@1.0.0
Ok to proceed? (y) y

  Welcome to Payload. Let's create a project! 

✔ Project name? … payload_app
✔ Choose project template › blank
✔ Select a database › MongoDB
✔ Enter MongoDB connection string … mongodb://127.0.0.1/payload-app

  Creating project in /Users/mac/Desktop/payload-app

✔ Dependencies installed
✔ .env file created
✔ Payload project successfully created

  ★ Launch Application:

    - cd ./payload-app
    - yarn dev or follow directions in README.md: file:///Users/mac/Desktop/payload-app/README.md

  ★ Documentation:

    - Getting Started: https://payloadcms.com/docs/getting-started/what-is-payload
    - Configuration: https://payloadcms.com/docs/configuration/overview

開発サーバの起動

コマンドが完了するとプロジェクト名で入力したディレクトリが作成されるのでそのディレクトリに移動してyarn devコマンドを実行します。ローカルでMongoDBが動作してない場合には接続ができないためエラーとなり開発サーバは起動できません。


 % yarn dev
yarn run v1.22.18
warning ../package.json: No license field
$ cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts
[nodemon] starting `ts-node src/server.ts -- -I`
[05:08:04] ERROR (payload): Error: cannot connect to MongoDB. Details: connect ECONNREFUSED 127.0.0.1:27017
[nodemon] app crashed - waiting for file changes before starting...

本文書ではMongoDBはFreeプランのあるクラウドサービスのAtlasを利用します。アカウントの作成からデータベースの作成/接続文字列の取得までの一連の流れはすでに別の記事で公開しているので参考にしてください。

Atlasを利用して取得した接続文字列を.envファイルに設定してデフォルトの値を上書きします。下記のままでは接続できないので各自が取得した値を設定してください。


DATABASE_URI='mongodb+srv://johodoe10:L2eBNHqgr99cvOWJ@cluster0.ya8yt7x.mongodb.net/?retryWrites=true&w=majority'
PAYLOAD_SECRET=667330ea6fe3fb94352dffca

.envファイルを更新後に再度yarn devコマンドを実行します。MongoDBへの接続とWebpackのコンパイルに成功していることが起動メッセージから確認できます。


 % yarn dev
yarn run v1.22.18
warning ../package.json: No license field
$ cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts
[nodemon] starting `ts-node src/server.ts -- -I`
[04:56:23] INFO (payload): Connected to MongoDB server successfully!
[04:56:23] INFO (payload): Starting Payload...
[04:56:24] INFO (payload): Payload Admin URL: /admin
webpack built 0e5163ae0b0963f73401 in 11664ms
webpack compiled successfully

ユーザの作成

ブラウザからhttp://localhost:3000/adminにアクセスするとWelcomeの文字列とユーザの作成の入力フォームが表示されます。最初にユーザの作成を行う必要があります。入力フォームにメールアドレスとパスワードを入力して”Create”ボタンをクリックします。

ユーザ作成画面
ユーザ作成画面

ユーザの作成が完了するとCollections画面が表示されます。

Collectionsの表示
Collectionsの表示

Usersをクリックします。ユーザ一覧画面が表示され作成したユーザが表示されます。

ユーザ一覧画面の表示
ユーザ一覧画面の表示

さらにメールアドレスをクリックするとAPIエンドポイントのURLを確認することができます。右側のAPIをクリックしてください。

APIのURLとその戻り値
APIのURLとその戻り値

MongoDB Atlas上のデータベースにも作成したユーザが登録されていることが確認できます。MongoDBはNO SQLデータベースなのでテーブルのことをCollectionと呼びますがusersの他にpayloads-migrationsとpayload-preferencesを確認することができます。どちらのCollectionにもユーザを作成しただけではデータはありません。

MongoDB Atlas上のCollectionsの確認
MongoDB Atlas上のCollectionsの確認

起動の流れ

yarn devコマンドを実行するとどのようなファイルを読み込みながら起動しているのか確認するためにpackage.jsonファイルのscriptsを確認します。


{
  "name": "payload_app",
  "description": "A blank template to get started with Payload",
  "version": "1.0.0",
  "main": "dist/server.js",
  "license": "MIT",
  "scripts": {
    "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
    "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
    "build:server": "tsc",
    "build": "yarn copyfiles && yarn build:payload && yarn build:server",
    "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
    "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
    "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
    "generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
    "payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload"
  },
  "dependencies": {
    "@payloadcms/bundler-webpack": "^1.0.0",
    "@payloadcms/db-mongodb": "^1.0.0",
    "@payloadcms/plugin-cloud": "^2.0.0",
    "@payloadcms/richtext-slate": "^1.0.0",
    "cross-env": "^7.0.3",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "payload": "^2.0.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.9",
    "copyfiles": "^2.4.1",
    "nodemon": "^2.0.6",
    "ts-node": "^9.1.1",
    "typescript": "^4.8.4"
  },
  "resolutions": {
    "jackspeak": "2.1.1"
  }
}

“dev”ではPAYLOAD_CONFIG_PATHでsrc/payload.config.tsファイルが指定されているので中身を確認します。


import path from 'path';

import { payloadCloud } from '@payloadcms/plugin-cloud';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

import Users from './collections/Users';

export default buildConfig({
  admin: {
    user: Users.slug,
    bundler: webpackBundler(),
  },
  editor: slateEditor({}),
  collections: [Users],
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  graphQL: {
    schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
  },
  plugins: [payloadCloud()],
  db: mongooseAdapter({
    url: process.env.DATABASE_URI,
  }),
});

データベースやeditorの選択がこの画面から行えることがわかります。scriptsを見るとnodemonが利用されており、プロジェクトディレクトリ直下にnodemon.jsonファイルがあるのでnodemon.jsonを元にnodemonが実行されていることがわかります。

nodemon.jsonのexecでsrcディレクトリのserver.tsファイルが指定されているのでyarn devコマンドでsrc/server.tsファイルが実行されることがわかります。


{
  "$schema": "https://json.schemastore.org/nodemon.json",
  "ext": "ts",
  "exec": "ts-node src/server.ts -- -I",
  "stdin": false
}

server.tsファイルの中身を下記の通りです。Expressサーバを利用してstart関数の中でPaylodが起動していることがわかります。


import express from 'express'
import payload from 'payload'

require('dotenv').config()
const app = express()

// Redirect root to Admin panel
app.get('/', (_, res) => {
  res.redirect('/admin')
})

const start = async () => {
  // Initialize Payload
  await payload.init({
    secret: process.env.PAYLOAD_SECRET,
    express: app,
    onInit: async () => {
      payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
    },
  })

  // Add your own express routes here

  app.listen(3000)
}

start()

“/”にアクセスすると”/admin”にリダイレクトされるものこのファイルから確認できます。さらにポート番号が3000であることもこのファイルからわかります。

Payloadの動作確認

Collectionの追加、追加したCollectionへのGETリクエスト、アクセスコントロール、APIによりログイン、ログアウトの確認、ファイルのアップロードなど基本的な動作確認を行います。

Collectionの追加

デフォルトではUser Collectionのみが作成されている状態なので新たに別のCollectionを追加してみましょう。

collectionsディレクトリにCustomers.tsファイルを作成します。


import { CollectionConfig } from 'payload/types';

const Customers: CollectionConfig = {
  slug: 'customers',
  auth: true,
  fields: [
    { name: 'firstName', type: 'text', required: true },
    { name: 'lastName', type: 'text', required: true },
  ],
};

export default Customers;

ファイルの中ではCollectionを構成するフィールドの設定を行います。slugはユニークな文字列でAPIのエンドポイントにも関連する値です。customersと設定すると/api/customersでアクセスすることができます。authの値をtrueに設定していますがこのCollectionに認証機能を持たせています認証機能がどのようなものか後ほど確認します。fieldsで指定しているfirstName, lastNameはCollectionを構成するデータ構造でcustomers CollectionにはfirstNameとlastNameに値を保存することになります。

Customers.tsファイルを作成後にpayload.config.tsファイルに作成したCustomers.tsファイルをimportしてcollectionsの配列にCustomersを追加します。


import path from 'path';

import { payloadCloud } from '@payloadcms/plugin-cloud';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

import Users from './collections/Users';
import Customers from './collections/Customers';

export default buildConfig({
  admin: {
    user: Users.slug,
    bundler: webpackBundler(),
  },
  editor: slateEditor({}),
  collections: [Users, Customers],
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  graphQL: {
    schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
  },
  plugins: [payloadCloud()],
  db: mongooseAdapter({
    url: process.env.DATABASE_URI,
  }),
});

ファイルを保存すると自動でCustomersのCollectionが追加され/adminにアクセスすると追加されたCustomersを確認することができます。この時点でMongoDBのAtlas上にもCollectionが追加されています。

Customers Collectionsの追加
Customers Collectionsの追加

クリックするとCustomers一覧が表示されます。

Customers一覧画面
Customers一覧画面

“Create new Customer”ボタンからCustomerを追加することができます。入力フォームも自動で作成されauthをtrueに設定しているのでEmailとパスワードの入力フォームも一緒に表示されます。フォームに入力して”Save”ボタンをクリックします。

Customer作成フォーム
Customer作成フォーム

“Save”ボタンをクリックすると入力した内容でユーザが作成されます。

登録されたユーザ情報
登録されたユーザ情報

アクセスコントロールによるデータ取得

Customers Collectionにデータ追加を行ったので/api/customersにGETリクエストを送信してユーザ情報が取得できるか確認を行います。REST APIでのURLについてはドキュメントに記述されてており、/api/{collection_slug}にGETメソッドでアクセスするとFindが実行されCollectionのデータを取得することができます。

REST API
REST API

Visual Studio Codeを利用している場合にはAPIリクエストを送信する際にExtensionsのREST ClientやThunder Clientを利用することができます。それ以外にはPostmanやcurlコマンドも利用できます。

ここではThunder Clientを利用してGETリクエストを送信します。リクエストを送信するとステータス403のForbiddenが戻されてアクセスすることができません。

403 Forbidden
403 Forbidden

アクセスできない原因はすべてのCollectionはデフォルトでログインしているユーザしかアクセスができない設定になっているためです。


const defaultPayloadAccess = ({ req: { user } }) => {
  // Return `true` if a user is found
  // and `false` if it is undefined or null
  return Boolean(user)
}

Collections, Fields, Globalsの単位でアクセスコントロールの設定を行うことができるのでドキュメントのCollection Access Controlを参考に設定を行います。

create, read, update, deleteで設定を行うことができますがGETリクエストによりデータの取得を行いたいのでreadの設定を行います。設定はcollections/Customer.tsファイルで行います。accessオプションを追加してreadプロパティに設定した関数の戻り値をtrueにしています。


import { CollectionConfig } from 'payload/types';

const Customers: CollectionConfig = {
  slug: 'customers',
  auth: true,
  admin: {
    useAsTitle: 'firstName',
  },
  fields: [
    { name: 'firstName', type: 'text', required: true },
    { name: 'lastName', type: 'text', required: true },
  ],
  access: {
    read: () => true,
  },
};

export default Customers;

設定後に再度/api/customersに対してGETリクエストを送信します。先程とは異なり、ステータスコード200でcustomersの情報が戻されていることが確認できます。

GETリクエストによるデータ取得
GETリクエストによるデータ取得

このようにCollections毎にアクセスコントロールを設定することができます。動作確認ができたのでaccessオプションの設定はコメントしておきます。


import { CollectionConfig } from 'payload/types';

const Customers: CollectionConfig = {
  slug: 'customers',
  auth: true,
  admin: {
    useAsTitle: 'firstName',
  },
  fields: [
    { name: 'firstName', type: 'text', required: true },
    { name: 'lastName', type: 'text', required: true },
  ],
  // access: {
  //   read: () => true,
  // },
};

export default Customers;

認証

すべてのCollectionはデフォルトでログインしているユーザしかアクセスができない設定になっていることを説明しました。ではどのようにログインを行うことができるのでしょうか。

ログイン

ログインを行うためのREST APIでのAPIエンドポイントはドキュメントのAuthenticationのLoginに記述されています。


http://localhost:3000/api/[collection-slug]/login
ログインの方法
ログインの方法

[collection-slug]にcustomersを設定します。

Customersで作成したユーザのemailとpasswordを設定してPOSTリクエストを送信します。ログインに成功するとTokenの情報と有効期限とメッセージとログインしたユーザの情報が戻されます。

ログインのためPOSTリクエストを送信
ログインのためPOSTリクエストを送信

PayloadではHTTP-only cookiesを利用しているのでCookiesを見るとpayload-cookieを確認することができます。

 Cookieの確認
Cookieの確認

Cookiesが設定されたまま/api/customersにGETリクエストを送信するとログインが完了しているのでデータを取得することができます。

ログアウト

ログアウトを行いたい場合には以下のURLにPOSTリクエストを送信することでログアウトすることができます。


http://localhost:3000/api/[collection-slug]/logout
ログアウト処理
ログアウト処理

ログインしている場合はログアウト処理が正常に行われると”You have been loggged out successfuly”と表示され、ログインしていない状態でPOSTリクエストを送信するとステータスコード400のBad Requestで”No User”のメッセージが戻されます。

Me

ログインしているユーザ情報を戻したい場合には以下のURLにGETリクエストを送信することで取得することができます。


http://localhost:3000/api/[collection-slug]/me

その他にもRefresh Tokenを取得するURLなどもあります。

ファイルのアップロード

ファイルをアップロードできるCollection Mediaを新たに追加します。

collectionsディレクトリにMedia.tsファイルを作成します。ファイルをアップロードする場合はCollectionにuploadオプションを設定します。staticDirでファイルを保存するディレクトリを指定しています。


import { slateEditor } from '@payloadcms/richtext-slate';
import path from 'path';
import type { CollectionConfig } from 'payload/types';

const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    staticDir: path.resolve(__dirname, '../../../media'),
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      required: true,
    },
    {
      name: 'caption',
      type: 'richText',
      editor: slateEditor({
        admin: {
          elements: [],
        },
      }),
    },
  ],
};

export default Media;

フィールドにはaltとcaptionを追加していますがcaptionではtypeをrichTextとしてslateEditorを指定しています。adminのelementsの配列にオプションを設定することでEditorをカスタマイズすることができます。何も設定しない状態で設定します。

Media.tsファイルが作成できたらpayload.config.tsファイルに作成したMedia Collectionを追加します。


import path from 'path';

import { payloadCloud } from '@payloadcms/plugin-cloud';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { webpackBundler } from '@payloadcms/bundler-webpack';
import { slateEditor } from '@payloadcms/richtext-slate';
import { buildConfig } from 'payload/config';

import Users from './collections/Users';
import Customers from './collections/Customers';
import Media from './collections/Media';

export default buildConfig({
  admin: {
    user: Users.slug,
    bundler: webpackBundler(),
  },
  editor: slateEditor({}),
  collections: [Users, Customers, Media],
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  graphQL: {
    schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
  },
  plugins: [payloadCloud()],
  db: mongooseAdapter({
    url: process.env.DATABASE_URI,
  }),
});

/adminにアクセスするとMedia Collectionが表示されます。

追加したMedia Collectionの確認
追加したMedia Collectionの確認

Mediaをクリックすると一覧画面が表示され、Mediaを追加するために”Create new Media”ボタンをクリックします。

Media一覧画面
Media一覧画面

Media作成画面ではファイルのアップロードはDrag&Dropでも行うことができます。

Media作成画面
Media作成画面

ファイルを選択してAlt, Captionに文字列を入力しています。CaptionではSlate Editorにより文字の太さなどを変更することができます。入力が完了したら”Save”ボタンをクリックします。

Mediaのフォームに入力後の画面
Mediaのフォームに入力後の画面

“Save”ボタンをクリックするとプロジェクトディレクトリ直下にmediaディレクトリが作成されアップロードしたファイルが保存されます。

Media一覧画面にもアップロードしたファイルの情報が表示されます。

アップロードしたファイルの情報
アップロードしたファイルの情報

次にSlateEditorのカスタマイズを行うためにelementsの配列にデフォルトで利用可能なh1とlinkを設定します。


{
  name: 'caption',
  type: 'richText',
  editor: slateEditor({
    admin: {
      elements: ['h1', 'link'],
    },
  }),
},

h1タグとリンクのアイコンが追加されていることが確認できます。

カスタマイズしたCaptionエリア
カスタマイズしたCaptionエリア

Customer Collectionで作成したユーザを利用して/api/mediaに対してGETリクエストを送信するとアップロードしたファイルの情報を確認することができます。 Rich Textで入力した値については下記のように保存され、ファイル名やmimeTypeなどURL以外の情報も確認できます。


{
  "docs": [
    {
      "id": "6571da6af24a28c5b6a980a3",
      "alt": "Payload Hero Image",
      "caption": [
        {
          "children": [
            {
              "text": "Payload",
              "bold": true
            },
            {
              "text": "の"
            },
            {
              "text": "Hero Image",
              "italic": true
            },
            {
              "text": "です。"
            }
          ]
        }
      ],
      "filename": "payload_cms.png",
      "mimeType": "image/png",
      "filesize": 14916,
      "width": 1200,
      "height": 645,
      "createdAt": "2023-12-07T14:44:58.210Z",
      "updatedAt": "2023-12-07T14:44:58.210Z",
      "url": "/media/payload_cms.png"
    }
  ],
  "totalDocs": 1,
  "limit": 10,
  "totalPages": 1,
  "page": 1,
  "pagingCounter": 1,
  "hasPrevPage": false,
  "hasNextPage": false,
  "prevPage": null,
  "nextPage": null
}
ユーザのログインが行われていない場合にはエラーになります。
fukidashi

Collectionのフィールドの設定やAccess Controlなどまだまださまざまな機能がありますがPayloadの基本的な機能を確認することができました。