10 tasks covering: cms-core dependencies, Users/Articles/Media collections, slug auto-generation hook, SiteSettings global, Payload config, dual-mode cms-client (local + HTTP), thin Next.js admin shell, build verification.
25 KiB
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
{
"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
{
"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
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
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
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
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.
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
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
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
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
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
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
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
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
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
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
{
"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
export interface FindOptions {
where?: Record<string, unknown>;
sort?: string;
limit?: number;
page?: number;
depth?: number;
locale?: string;
}
export interface PayloadClientResult<T> {
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<T = Record<string, unknown>>(
collection: string,
options?: FindOptions
): Promise<PayloadClientResult<T>>;
findByID<T = Record<string, unknown>>(
collection: string,
id: string,
options?: { depth?: number }
): Promise<T>;
create<T = Record<string, unknown>>(
collection: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T>;
update<T = Record<string, unknown>>(
collection: string,
id: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T>;
delete<T = Record<string, unknown>>(
collection: string,
id: string
): Promise<T>;
}
- Step 3: Create src/local-client.ts
import type { Payload } from "payload";
import type { FindOptions, PayloadClient, PayloadClientResult } from "./types.js";
export class LocalPayloadClient implements PayloadClient {
constructor(private payload: Payload) {}
async find<T = Record<string, unknown>>(
collection: string,
options?: FindOptions
): Promise<PayloadClientResult<T>> {
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<T>;
}
async findByID<T = Record<string, unknown>>(
collection: string,
id: string,
options?: { depth?: number }
): Promise<T> {
const result = await this.payload.findByID({
collection: collection as any,
id,
depth: options?.depth,
});
return result as unknown as T;
}
async create<T = Record<string, unknown>>(
collection: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
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<T = Record<string, unknown>>(
collection: string,
id: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
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<T = Record<string, unknown>>(
collection: string,
id: string
): Promise<T> {
const result = await this.payload.delete({
collection: collection as any,
id,
});
return result as unknown as T;
}
}
- Step 4: Create src/http-client.ts
import type { FindOptions, PayloadClient, PayloadClientResult } from "./types.js";
export class HTTPPayloadClient implements PayloadClient {
constructor(private baseURL: string) {}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
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<T>;
}
async find<T = Record<string, unknown>>(
collection: string,
options?: FindOptions
): Promise<PayloadClientResult<T>> {
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<PayloadClientResult<T>>(
`/api/${collection}${query ? `?${query}` : ""}`
);
}
async findByID<T = Record<string, unknown>>(
collection: string,
id: string,
options?: { depth?: number }
): Promise<T> {
const params = new URLSearchParams();
if (options?.depth) params.set("depth", String(options.depth));
const query = params.toString();
return this.request<T>(
`/api/${collection}/${id}${query ? `?${query}` : ""}`
);
}
async create<T = Record<string, unknown>>(
collection: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
const params = new URLSearchParams();
if (options?.depth) params.set("depth", String(options.depth));
const query = params.toString();
return this.request<T>(
`/api/${collection}${query ? `?${query}` : ""}`,
{ method: "POST", body: JSON.stringify(data) }
);
}
async update<T = Record<string, unknown>>(
collection: string,
id: string,
data: Record<string, unknown>,
options?: { depth?: number }
): Promise<T> {
const params = new URLSearchParams();
if (options?.depth) params.set("depth", String(options.depth));
const query = params.toString();
return this.request<T>(
`/api/${collection}/${id}${query ? `?${query}` : ""}`,
{ method: "PATCH", body: JSON.stringify(data) }
);
}
async delete<T = Record<string, unknown>>(
collection: string,
id: string
): Promise<T> {
return this.request<T>(`/api/${collection}/${id}`, { method: "DELETE" });
}
}
- Step 5: Create src/client.ts
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
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
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
{
"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
{
"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
import { withPayload } from "@payloadcms/next/withPayload";
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withPayload(nextConfig);
- Step 4: Create admin catch-all page
// 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<Record<string, string | string[]>>;
};
export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams });
export default Page;
- Step 5: Create admin not-found page
// 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<Record<string, string | string[]>>;
};
export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams });
export default NotFound;
- Step 6: Create payload layout
// 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) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
export default Layout;
- Step 7: Create empty importMap and custom.scss
// apps/cms/src/app/(payload)/importMap.js
export const importMap = {};
// apps/cms/src/app/(payload)/custom.scss
// Custom admin panel styles
- Step 8: Run pnpm install and commit
Run: pnpm install
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
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
git add -A && git status
# Only commit if there are changes
git commit -m "chore: update lockfile after CMS integration"