Payload CMS 的美麗與哀愁

May 03, 2025

前言

觀望了好一陣子,需要有 headless CMS,來代替繁瑣的後台 CRUD,之前使用 keystonejs來建構 Blog的後台與提供API,其實已經很滿意了,它僅提供 GraphQL,有些場景下 REST API 更為合適,因此開始實際使用,並將之前的點名系統重構,告別 Laravel Nova。

Stack

Payload CMS

backend and frontend

採用了熱門的 Next.js,雖然一開始有些 bad smell (程式碼異味),同時支援 NoSQL(RDBMS)關聯式資料庫,使用了 Node.js ORM 的新星 Drizzle,之前有用過,不是那麼好用。

css

未使用 Tailwind CSS,大量的 BEM 命名風格,樣式替換可能較為困難。

admin panel

keystonejs 類似,採用基於配置文件的設計,從主要入口點 payload.config.ts 開始。

payload.config.ts

// storage-adapter-import-placeholder import { sqliteAdapter } from '@payloadcms/db-sqlite' import { payloadCloudPlugin } from '@payloadcms/payload-cloud' import { lexicalEditor } from '@payloadcms/richtext-lexical' import path from 'node:path' import { buildConfig } from 'payload' import { fileURLToPath } from 'node:url' import sharp from 'sharp' import { Admin } from './collections/Admin' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfig({ admin: { user: Admin.slug, importMap: { baseDir: path.resolve(dirname), }, routes: { // login: '/login', }, }, localization: { defaultLocale: 'zh-TW', locales: ['zh-TW'], }, collections: [Admin], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET || '', typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, db: sqliteAdapter({ client: { url: process.env.DATABASE_URI || '', authToken: process.env.DATABASE_AUTH_TOKEN, }, }), sharp, plugins: [ payloadCloudPlugin(), // storage-adapter-placeholder ], })
  • collections
    後面會補上什麼是 collection 的詳細敘述,簡單來說,它能一次性設定後台介面、資料庫欄位與資料表,自動生成。

  • db
    這次順便使用了 SQLite,但特別的是它是透過 HTTP,而非一般 local,使用的是 Turso,被認為是未來的潛力新星。

collection

import type { CollectionConfig } from 'payload' export enum RoleEnum { ADMIN = 'admin', RECEPTIONIST = 'receptionist', } export const Admin: CollectionConfig = { slug: 'admins', admin: { useAsTitle: 'email', }, auth: true, fields: [ // Email added by default { name: 'name', type: 'text', required: true, }, { name: 'role', type: 'select', saveToJWT: true, label: '角色', options: [ { label: 'Admin', value: RoleEnum.ADMIN }, { label: 'Receptionist', value: RoleEnum.RECEPTIONIST }, ], defaultValue: 'receptionist', required: true, }, ], }
  • slug
    同時為 API 的名稱,參考,也是資料表名稱」。

  • admin
    useAsTitle 用於指定代表名稱,後台顯示資料時以此為準。

  • auth
    顧名思義,它作為登入帳號的資料表,會自動產生 auth 需要的欄位。

  • fields
    用於定義 UI 與資料表欄位,是最核心的部分,參考

endpoint

如前所述,它會自動生成REST API,若預設的 API 功能不足或需擴充,可在此進行客製化,注意:這邊會覆寫原有 API ,因為整個系統後台都是共用API,因此,修改行為時應調整路徑,以免影響後台功能。

一樣在 collection 加入 endpoints,如下方範例

import type { CollectionConfig } from 'payload' import { listMeetings } from './endpoints/meetings' export const Meeting: CollectionConfig = { slug: 'meetings', admin: { useAsTitle: 'name', }, endpoints: [ { path: '/c', method: 'get', handler: listMeetings, }, ], // .....忽略 }

我們可以把 handler 抽出獨立編寫,以避免程式碼過長

import type { PayloadHandler } from 'payload' type Query = { day_of_week?: number } export const listMeetings: PayloadHandler = async (req) => { const { day_of_week } = req.query as Query const meetings = await req.payload.find({ collection: 'meetings', select: { id: true, name: true, }, where: { day_of_week: { equals: day_of_week, }, }, }) return Response.json({ ...meetings, }) }

讀到此處可能會好奇,什麼是 req.payload

Local API

官方 稱為 Local API,基於 Drizzle 封裝,對接口做了一些處理,可以直接呼叫資料庫。

雷點

  1. Local API 沒有批量插入 在寫 seed 產生假資料的時候...發現 Local API 只能逐筆插入,沒有批量插入,後來找到方法直接呼叫 Drizzle ORM,幸好僅在本地端執行,效能影響較小,以下為生成百家姓的範例,供後續注音索引使用,範例如下:
import path from 'node:path' import fs from 'node:fs' import { payload } from '.' type SeedData = { name: string bopomofo: string } export const surnameSeed = async () => { const filePath = path.join(__dirname, './files/surnames.json') const data = fs.readFileSync(filePath, 'utf8') const seedData = JSON.parse(data) as SeedData[] const promises = seedData.map(async ({ name, bopomofo }) => { await payload.create({ collection: 'surnames', data: { name, bopomofo, }, }) }) await Promise.all(promises) }
  1. 不完善的多對多關聯
    起初難以置信,直到看了 官網,與資料表的結果。 Screenshot 2025-05-03 at 10.06.19 PM.png
    parentId 指的是 attendances
    users_id 就是跟 attendances 多對多的 table
    Screenshot 2025-05-03 at 10.08.37 PM.jpg
    詭異的是,當你在 user collection 設定多對多,結果他產生了自己的多對多,並非共用中間表。

  2. Local API 在多對多新增時的異常行為
    messageImage_1746274852428.jpg
    本來想說可以直接放 user_id,但是會報錯,可能是為了相容 NoSQL 導致的 bug,一定要用 user 物件才能過,第二個問題接著發生,用 update的API,代表如果已經有多筆資料要更新,需要把既有的都撈出來,放進去陣列,才能做到保留本來的資料,另外 array push一筆新的物件進去。
    後來找到可以呼叫 Drizzle 的方式才解決,參考 Access to Drizzle

import type { PayloadHandler } from 'payload' import { eq, and } from '@payloadcms/db-sqlite/drizzle' import { attendances_rels } from '@/payload-generated-schema' export const makeAttendanceByUser: PayloadHandler = async (req) => { const { attendee_id, user_id } = req.routeParams as MakeAttendanceByUser const existingRelation = await req.payload.db.drizzle .select() .from(attendances_rels) .where(and(eq(attendances_rels.parent, attendee_id), eq(attendances_rels.usersID, user_id))) if (existingRelation.length > 0) { return Response.json({ message: 'Relation already exists' }, { status: 400 }) } await req.payload.db.drizzle.insert(attendances_rels).values({ parent: attendee_id, usersID: user_id, path: 'users', }) return Response.json({ message: 'Attendance created successfully', }) }

結語

雖然 Payload CMS 真的是一個蠻不錯的 headless CMS,但最基本的 多對多關聯驚然如此掉渣,以及 Local API 竟然少了多筆寫入新增多對多資料,對我來說都是非常致命的,最後還是回到 keystonejs,雖然不夠華麗,但其基本功能都已完善。

Ekman Hsieh

文字工作者,寫作時間常常在人類與電腦之間拉鋸,相信閱讀,相信文字與思想所構築的美麗境界