# Plan 3: Payload CMS Integration — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement Payload CMS integration: `@repo/cms-core` (config, collections, hooks), `@repo/cms-client` (dual-mode local+HTTP client with generated types), and `apps/cms` (thin Next.js shell serving the Payload admin panel). **Architecture:** Payload config and collections live in `@repo/cms-core` — standalone, testable, framework-agnostic. `@repo/cms-client` provides a dual-mode client (Local API primary, HTTP fallback) that receives a Payload instance via injection. `apps/cms` is a thin Next.js 15 shell that imports config from cms-core and serves the admin panel. Type generation runs `payload generate:types` against cms-core's config. **Tech Stack:** Payload CMS 3.x, @payloadcms/db-postgres, @payloadcms/next, @payloadcms/richtext-lexical, Next.js 15, PostgreSQL 16, sharp --- ## File Map ### packages/cms-core | File | Responsibility | |---|---| | `packages/cms-core/package.json` | Dependencies: payload, @payloadcms/db-postgres, @payloadcms/richtext-lexical | | `packages/cms-core/src/payload.config.ts` | Root Payload config (db, editor, collections, globals) | | `packages/cms-core/src/collections/users/index.ts` | Users collection with auth enabled | | `packages/cms-core/src/collections/articles/index.ts` | Articles collection config | | `packages/cms-core/src/collections/articles/fields.ts` | Article field definitions | | `packages/cms-core/src/collections/articles/hooks/before-change.ts` | Slug auto-generation hook | | `packages/cms-core/src/collections/media/index.ts` | Media collection with uploads | | `packages/cms-core/src/globals/site-settings.ts` | Site settings global | | `packages/cms-core/src/index.ts` | Exports config + all collections | ### packages/cms-client | File | Responsibility | |---|---| | `packages/cms-client/package.json` | Dependencies: payload (types only) | | `packages/cms-client/src/client.ts` | createPayloadClient() factory | | `packages/cms-client/src/local-client.ts` | LocalPayloadClient — wraps Payload instance | | `packages/cms-client/src/http-client.ts` | HTTPPayloadClient — REST API fallback | | `packages/cms-client/src/types.ts` | Shared client types (PayloadClient interface) | | `packages/cms-client/src/index.ts` | Exports | ### apps/cms | File | Responsibility | |---|---| | `apps/cms/package.json` | Dependencies: next, payload, @payloadcms/next, @repo/cms-core | | `apps/cms/next.config.mjs` | Next.js config wrapped with withPayload | | `apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx` | Admin panel catch-all route | | `apps/cms/src/app/(payload)/admin/[[...segments]]/not-found.tsx` | Admin 404 page | | `apps/cms/src/app/(payload)/layout.tsx` | Payload layout with RootLayout | | `apps/cms/src/app/(payload)/custom.scss` | Empty custom styles | | `apps/cms/src/payload-types.ts` | Generated types (via payload generate:types) | --- ### Task 1: Install cms-core dependencies **Files:** - Modify: `packages/cms-core/package.json` - Modify: `packages/cms-core/tsconfig.json` - [ ] **Step 1: Update packages/cms-core/package.json** ```json { "name": "@repo/cms-core", "private": true, "version": "0.0.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "build": "tsc --noEmit", "lint": "eslint .", "typecheck": "tsc --noEmit" }, "dependencies": { "payload": "^3.14.0", "@payloadcms/db-postgres": "^3.14.0", "@payloadcms/richtext-lexical": "^3.14.0" }, "devDependencies": { "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0" } } ``` - [ ] **Step 2: Update tsconfig.json** ```json { "extends": "@repo/typescript-config/base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules", "dist"] } ``` - [ ] **Step 3: Run pnpm install** Run: `pnpm install` Expected: Payload and its dependencies install successfully. - [ ] **Step 4: Commit** ```bash git add packages/cms-core/package.json packages/cms-core/tsconfig.json pnpm-lock.yaml git commit -m "feat(cms-core): add Payload CMS dependencies" ``` --- ### Task 2: Users collection **Files:** - Create: `packages/cms-core/src/collections/users/index.ts` - [ ] **Step 1: Create Users collection** ```typescript import type { CollectionConfig } from "payload"; export const Users: CollectionConfig = { slug: "users", auth: true, admin: { useAsTitle: "email", }, fields: [ { name: "displayName", type: "text", }, { name: "role", type: "select", options: [ { label: "Admin", value: "admin" }, { label: "Editor", value: "editor" }, { label: "Author", value: "author" }, ], defaultValue: "author", required: true, }, ], }; ``` - [ ] **Step 2: Commit** ```bash git add packages/cms-core/src/collections/users/ git commit -m "feat(cms-core): add Users collection with auth" ``` --- ### Task 3: Articles collection with fields and hooks **Files:** - Create: `packages/cms-core/src/collections/articles/fields.ts` - Create: `packages/cms-core/src/collections/articles/hooks/before-change.ts` - Create: `packages/cms-core/src/collections/articles/index.ts` - [ ] **Step 1: Create fields.ts** ```typescript import type { Field } from "payload"; export const articleFields: Field[] = [ { name: "title", type: "text", required: true, maxLength: 255, }, { name: "slug", type: "text", unique: true, admin: { position: "sidebar", description: "Auto-generated from title if left empty", }, }, { name: "content", type: "richText", }, { name: "status", type: "select", options: [ { label: "Draft", value: "draft" }, { label: "Published", value: "published" }, ], defaultValue: "draft", required: true, admin: { position: "sidebar", }, }, { name: "author", type: "relationship", relationTo: "users", required: true, admin: { position: "sidebar", }, }, { name: "featuredImage", type: "upload", relationTo: "media", }, { name: "publishedAt", type: "date", admin: { position: "sidebar", date: { pickerAppearance: "dayAndTime", }, }, }, ]; ``` - [ ] **Step 2: Create hooks/before-change.ts** This is a CMS-operational hook (slug auto-generation) — stays in cms-core per the design spec. ```typescript import type { CollectionBeforeChangeHook } from "payload"; function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } export const autoGenerateSlug: CollectionBeforeChangeHook = ({ data, operation, }) => { if (operation === "create" || operation === "update") { if (data && data.title && !data.slug) { data.slug = generateSlug(data.title); } } return data; }; ``` - [ ] **Step 3: Create articles/index.ts** ```typescript import type { CollectionConfig } from "payload"; import { articleFields } from "./fields.js"; import { autoGenerateSlug } from "./hooks/before-change.js"; export const Articles: CollectionConfig = { slug: "articles", admin: { useAsTitle: "title", defaultColumns: ["title", "status", "author", "updatedAt"], }, hooks: { beforeChange: [autoGenerateSlug], }, versions: { drafts: true, }, fields: articleFields, }; ``` - [ ] **Step 4: Commit** ```bash git add packages/cms-core/src/collections/articles/ git commit -m "feat(cms-core): add Articles collection with slug auto-generation hook" ``` --- ### Task 4: Media collection **Files:** - Create: `packages/cms-core/src/collections/media/index.ts` - [ ] **Step 1: Create Media collection** ```typescript import type { CollectionConfig } from "payload"; export const Media: CollectionConfig = { slug: "media", upload: { mimeTypes: ["image/*", "application/pdf"], }, admin: { useAsTitle: "filename", }, fields: [ { name: "alt", type: "text", required: true, }, ], }; ``` - [ ] **Step 2: Commit** ```bash git add packages/cms-core/src/collections/media/ git commit -m "feat(cms-core): add Media collection with uploads" ``` --- ### Task 5: Site settings global **Files:** - Create: `packages/cms-core/src/globals/site-settings.ts` - [ ] **Step 1: Create site-settings.ts** ```typescript import type { GlobalConfig } from "payload"; export const SiteSettings: GlobalConfig = { slug: "site-settings", admin: { group: "Settings", }, fields: [ { name: "siteName", type: "text", required: true, defaultValue: "My App", }, { name: "siteDescription", type: "textarea", }, ], }; ``` - [ ] **Step 2: Commit** ```bash git add packages/cms-core/src/globals/ git commit -m "feat(cms-core): add SiteSettings global" ``` --- ### Task 6: Payload config + exports **Files:** - Create: `packages/cms-core/src/payload.config.ts` - Modify: `packages/cms-core/src/index.ts` - [ ] **Step 1: Create payload.config.ts** ```typescript import { buildConfig } from "payload"; import { postgresAdapter } from "@payloadcms/db-postgres"; import { lexicalEditor } from "@payloadcms/richtext-lexical"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Users } from "./collections/users/index.js"; import { Articles } from "./collections/articles/index.js"; import { Media } from "./collections/media/index.js"; import { SiteSettings } from "./globals/site-settings.js"; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); export default buildConfig({ editor: lexicalEditor(), collections: [Users, Articles, Media], globals: [SiteSettings], secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/template", }, }), typescript: { outputFile: path.resolve(dirname, "payload-types.ts"), }, }); ``` - [ ] **Step 2: Update src/index.ts** ```typescript export { Users } from "./collections/users/index.js"; export { Articles } from "./collections/articles/index.js"; export { Media } from "./collections/media/index.js"; export { SiteSettings } from "./globals/site-settings.js"; export { default as config } from "./payload.config.js"; ``` - [ ] **Step 3: Commit** ```bash git add packages/cms-core/src/payload.config.ts packages/cms-core/src/index.ts git commit -m "feat(cms-core): add Payload config with postgres adapter and lexical editor" ``` --- ### Task 7: cms-client — dual-mode Payload client **Files:** - Modify: `packages/cms-client/package.json` - Create: `packages/cms-client/src/types.ts` - Create: `packages/cms-client/src/local-client.ts` - Create: `packages/cms-client/src/http-client.ts` - Create: `packages/cms-client/src/client.ts` - Modify: `packages/cms-client/src/index.ts` - [ ] **Step 1: Update packages/cms-client/package.json** ```json { "name": "@repo/cms-client", "private": true, "version": "0.0.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "build": "tsc --noEmit", "lint": "eslint .", "typecheck": "tsc --noEmit" }, "dependencies": { "payload": "^3.14.0" }, "devDependencies": { "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0" } } ``` - [ ] **Step 2: Create src/types.ts** ```typescript export interface FindOptions { where?: Record; sort?: string; limit?: number; page?: number; depth?: number; locale?: string; } export interface PayloadClientResult { docs: T[]; totalDocs: number; limit: number; totalPages: number; page: number; pagingCounter: number; hasPrevPage: boolean; hasNextPage: boolean; prevPage: number | null; nextPage: number | null; } export interface PayloadClient { find>( collection: string, options?: FindOptions ): Promise>; findByID>( collection: string, id: string, options?: { depth?: number } ): Promise; create>( collection: string, data: Record, options?: { depth?: number } ): Promise; update>( collection: string, id: string, data: Record, options?: { depth?: number } ): Promise; delete>( collection: string, id: string ): Promise; } ``` - [ ] **Step 3: Create src/local-client.ts** ```typescript import type { Payload } from "payload"; import type { FindOptions, PayloadClient, PayloadClientResult } from "./types.js"; export class LocalPayloadClient implements PayloadClient { constructor(private payload: Payload) {} async find>( collection: string, options?: FindOptions ): Promise> { const result = await this.payload.find({ collection: collection as any, where: options?.where as any, sort: options?.sort, limit: options?.limit, page: options?.page, depth: options?.depth, locale: options?.locale as any, }); return result as unknown as PayloadClientResult; } async findByID>( collection: string, id: string, options?: { depth?: number } ): Promise { const result = await this.payload.findByID({ collection: collection as any, id, depth: options?.depth, }); return result as unknown as T; } async create>( collection: string, data: Record, options?: { depth?: number } ): Promise { const result = await this.payload.create({ collection: collection as any, data: data as any, depth: options?.depth, }); return result as unknown as T; } async update>( collection: string, id: string, data: Record, options?: { depth?: number } ): Promise { const result = await this.payload.update({ collection: collection as any, id, data: data as any, depth: options?.depth, }); return result as unknown as T; } async delete>( collection: string, id: string ): Promise { const result = await this.payload.delete({ collection: collection as any, id, }); return result as unknown as T; } } ``` - [ ] **Step 4: Create src/http-client.ts** ```typescript import type { FindOptions, PayloadClient, PayloadClientResult } from "./types.js"; export class HTTPPayloadClient implements PayloadClient { constructor(private baseURL: string) {} private async request(path: string, options?: RequestInit): Promise { const response = await fetch(`${this.baseURL}${path}`, { headers: { "Content-Type": "application/json" }, ...options, }); if (!response.ok) { throw new Error(`Payload API error: ${response.status} ${response.statusText}`); } return response.json() as Promise; } async find>( collection: string, options?: FindOptions ): Promise> { const params = new URLSearchParams(); if (options?.limit) params.set("limit", String(options.limit)); if (options?.page) params.set("page", String(options.page)); if (options?.sort) params.set("sort", options.sort); if (options?.depth) params.set("depth", String(options.depth)); if (options?.where) params.set("where", JSON.stringify(options.where)); const query = params.toString(); return this.request>( `/api/${collection}${query ? `?${query}` : ""}` ); } async findByID>( collection: string, id: string, options?: { depth?: number } ): Promise { const params = new URLSearchParams(); if (options?.depth) params.set("depth", String(options.depth)); const query = params.toString(); return this.request( `/api/${collection}/${id}${query ? `?${query}` : ""}` ); } async create>( collection: string, data: Record, options?: { depth?: number } ): Promise { const params = new URLSearchParams(); if (options?.depth) params.set("depth", String(options.depth)); const query = params.toString(); return this.request( `/api/${collection}${query ? `?${query}` : ""}`, { method: "POST", body: JSON.stringify(data) } ); } async update>( collection: string, id: string, data: Record, options?: { depth?: number } ): Promise { const params = new URLSearchParams(); if (options?.depth) params.set("depth", String(options.depth)); const query = params.toString(); return this.request( `/api/${collection}/${id}${query ? `?${query}` : ""}`, { method: "PATCH", body: JSON.stringify(data) } ); } async delete>( collection: string, id: string ): Promise { return this.request(`/api/${collection}/${id}`, { method: "DELETE" }); } } ``` - [ ] **Step 5: Create src/client.ts** ```typescript import type { Payload } from "payload"; import type { PayloadClient } from "./types.js"; import { LocalPayloadClient } from "./local-client.js"; import { HTTPPayloadClient } from "./http-client.js"; type PayloadClientOptions = | { mode: "local"; payload: Payload } | { mode: "http"; baseURL: string }; export function createPayloadClient(options: PayloadClientOptions): PayloadClient { if (options.mode === "local") { return new LocalPayloadClient(options.payload); } return new HTTPPayloadClient(options.baseURL); } ``` - [ ] **Step 6: Update src/index.ts** ```typescript export { createPayloadClient } from "./client.js"; export { LocalPayloadClient } from "./local-client.js"; export { HTTPPayloadClient } from "./http-client.js"; export type { PayloadClient, PayloadClientResult, FindOptions, } from "./types.js"; ``` - [ ] **Step 7: Run pnpm install and commit** Run: `pnpm install` ```bash git add packages/cms-client/ pnpm-lock.yaml git commit -m "feat(cms-client): add dual-mode Payload client (local + HTTP)" ``` --- ### Task 8: apps/cms — thin Next.js shell **Files:** - Modify: `apps/cms/package.json` - Create: `apps/cms/next.config.mjs` - Create: `apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx` - Create: `apps/cms/src/app/(payload)/admin/[[...segments]]/not-found.tsx` - Create: `apps/cms/src/app/(payload)/layout.tsx` - Create: `apps/cms/src/app/(payload)/custom.scss` - Modify: `apps/cms/tsconfig.json` - [ ] **Step 1: Update apps/cms/package.json** ```json { "name": "@repo/cms", "private": true, "version": "0.0.0", "type": "module", "scripts": { "build": "next build", "dev": "next dev --port 3001", "lint": "eslint .", "typecheck": "tsc --noEmit", "generate:types": "payload generate:types" }, "dependencies": { "@payloadcms/next": "^3.14.0", "@payloadcms/ui": "^3.14.0", "@repo/cms-core": "workspace:*", "next": "^15.3.0", "payload": "^3.14.0", "react": "^19.0.0", "react-dom": "^19.0.0", "sharp": "^0.33.0" }, "devDependencies": { "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" } } ``` - [ ] **Step 2: Update apps/cms/tsconfig.json** ```json { "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@payload-config": ["../../packages/cms-core/src/payload.config.ts"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "next-env.d.ts"], "exclude": ["node_modules"] } ``` - [ ] **Step 3: Create next.config.mjs** ```javascript import { withPayload } from "@payloadcms/next/withPayload"; /** @type {import('next').NextConfig} */ const nextConfig = {}; export default withPayload(nextConfig); ``` - [ ] **Step 4: Create admin catch-all page** ```tsx // apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ import type { Metadata } from "next"; import config from "@payload-config"; import { RootPage, generatePageMetadata } from "@payloadcms/next/views"; import { importMap } from "../importMap.js"; type Args = { params: Promise<{ segments: string[] }>; searchParams: Promise>; }; export const generateMetadata = ({ params, searchParams, }: Args): Promise => generatePageMetadata({ config, params, searchParams }); const Page = ({ params, searchParams }: Args) => RootPage({ config, importMap, params, searchParams }); export default Page; ``` - [ ] **Step 5: Create admin not-found page** ```tsx // apps/cms/src/app/(payload)/admin/[[...segments]]/not-found.tsx /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ import type { Metadata } from "next"; import config from "@payload-config"; import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views"; import { importMap } from "../importMap.js"; type Args = { params: Promise<{ segments: string[] }>; searchParams: Promise>; }; export const generateMetadata = ({ params, searchParams, }: Args): Promise => generatePageMetadata({ config, params, searchParams }); const NotFound = ({ params, searchParams }: Args) => NotFoundPage({ config, importMap, params, searchParams }); export default NotFound; ``` - [ ] **Step 6: Create payload layout** ```tsx // apps/cms/src/app/(payload)/layout.tsx /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ import type { ServerFunctionClient } from "payload"; import config from "@payload-config"; import { RootLayout } from "@payloadcms/next/layouts"; import React from "react"; import { importMap } from "./importMap.js"; import "./custom.scss"; type Args = { children: React.ReactNode; }; const serverFunction: ServerFunctionClient = async function (args) { "use server"; const { default: payloadModule } = await import("payload"); return payloadModule.handleServerFunctions({ ...args, config, importMap }); }; const Layout = ({ children }: Args) => ( {children} ); export default Layout; ``` - [ ] **Step 7: Create empty importMap and custom.scss** ```typescript // apps/cms/src/app/(payload)/importMap.js export const importMap = {}; ``` ```scss // apps/cms/src/app/(payload)/custom.scss // Custom admin panel styles ``` - [ ] **Step 8: Run pnpm install and commit** Run: `pnpm install` ```bash git add apps/cms/ pnpm-lock.yaml git commit -m "feat(cms): add thin Next.js shell for Payload admin panel" ``` --- ### Task 9: Update docker-compose with CMS service **Files:** - Modify: `docker-compose.yml` - [ ] **Step 1: Update docker-compose.yml** ```yaml services: postgres: image: postgres:16-alpine restart: unless-stopped ports: - "5432:5432" environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: template volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 5 volumes: postgres_data: ``` Note: CMS app service will be added when Dockerfiles are created in a later plan. For now, local dev uses `pnpm dev --filter @repo/cms`. - [ ] **Step 2: Commit (no change needed if docker-compose is already correct)** Only commit if docker-compose was modified. --- ### Task 10: Verify build - [ ] **Step 1: Run pnpm install from root** Run: `pnpm install` Expected: All dependencies resolve. - [ ] **Step 2: Run turbo build** Run: `pnpm build` Expected: All workspaces build. The cms-core and cms-client packages should pass `tsc --noEmit`. The cms app should run `next build` (may need database for full build — if it fails due to no DB, that's expected and acceptable at this stage). - [ ] **Step 3: Commit any remaining changes** ```bash git add -A && git status # Only commit if there are changes git commit -m "chore: update lockfile after CMS integration" ```