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.
978 lines
25 KiB
Markdown
978 lines
25 KiB
Markdown
# 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<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**
|
|
|
|
```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<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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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) => (
|
|
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
|
{children}
|
|
</RootLayout>
|
|
);
|
|
|
|
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"
|
|
```
|