--- name: payload-cms description: Put a Payload CMS admin on a site built from this starter — every visible string and content photo editable, derived from the site's own content objects with the code's copy as the fallback, a per-page SEO global that drives /JSON-LD/sitemap/llms.txt, Supabase Postgres + Storage, migrations only, static routes revalidated on save. Ships copy-ready kits (core, admin skin, analytics, editor's guide, legal rich text) proven on a production site. Use when the user asks to "add a CMS / admin / Payload", "make the content editable", "let the client edit the text", "SEO in the admin", or wants marketing copy out of hardcoded props. For the admin's look and the editor's guide see `payload-admin`; for visitor analytics see `payload-analytics`. --- # Payload CMS — the admin flow This is the flow a production site went through to get an admin its owner called "great": every word on the site editable, grouped the way the site reads, SEO per page with a live link preview, light consented analytics, the site's own look, and a guide with screenshots inside the admin. It is written down as kits (`templates/`) and decisions (below) so the next site gets there in one pass instead of twenty requests. **Read first:** `obsidian/workflows/cms-admin.md` (the flow at a glance) and `obsidian/backend/cms-payload.md` (the architecture). Verified against **Payload 3.89 · Next 16.3 · Node 24** (2026-10). ## The decisions — and why Every one of these was a request or a defect on the reference site. Don't undo one without the reason in front of you. | # | Decision | Why | |---|---|---| | D1 | **Payload inside the app** — `app/(payload)` + `app/(site)`, one build, one deploy | The owner asked for one project. Local API reads in process — no HTTP hop, works at build time. | | D2 | **Fields are derived from the content objects** (`text-schema.ts` walks `src/data/mocks/*`), not hand-written | Every string becomes a field defaulted to today's copy; a new line in code is a new field next deploy. Hand-written schemas drift and miss copy. | | D3 | **Reading is a merge**: `getText("hero")` returns the code's `HERO` with the admin's strings laid over it — **same type** | Views and components stay untouched. A deliberate exception to "use the generated type in the view" (rule `payload.md`). | | D4 | **Blank / missing / DB down → the code's copy**, error logged | The site never goes blank because the CMS did. A fresh database renders the full site. | | D5 | **Globals, not collections or blocks**, for a marketing page's sections | Each section exists once; its order is choreographed with motion/scene, so it is not the editor's to reorder. Collections only for things that are many (posts, cases). Blocks only when the editor genuinely composes pages. | | D6 | **Wiring is skipped** (`SKIP`: `id`, `href`, anchors, textures, scene data…); card lists have **fixed rows** matched by a hidden `key` | The editor rewrites a card but cannot break the layout, cross-wire a card to another's image, or add a 7th card to a 6-slot ring. | | D7 | **`OPEN_LISTS`** with a floor/ceiling for lists the layout can resize; an added row must fill every line + photo | The client asked to change counts; the floor is what keeps a carousel from showing empty glass. | | D8 | **Photos are optional uploads beside their copy** ("Replace photo"): blank → photo in code; upload → its URL, alt, a 512 px `small` size for textures, crop dropped | Same fallback rule as text. Only content photos — decorative art and the scene stay in code. | | D9 | **One SEO global, a tab per page** + Site defaults; parity with everything ``, JSON-LD and `sitemap.xml` emit; a **link-preview card** per tab | "SEO for each page, the same as the site has it." Titles are absolute (`Brand \| …`, 50–60 chars) and written for search; the plugin's fields are placed by hand because routes are fixed. | | D10 | **Share image seeded into Media** on `onInit` (idempotent, never throws) | The editor sees the real card in the admin and can replace it, instead of a hint about a file in the code. | | D11 | **Routes stay static**; every global's `afterChange` → `revalidatePath("/", "layout")` | Hard rule #15 holds; saves still show at once. | | D12 | **Migrations only, `push: false` everywhere**; `yarn migrate:direct` over the session pooler | The same steps on every machine and the host. `push` against a live DB is how schemas get rewritten. | | D13 | **Storage plugin always registered** (`enabled` from env, `alwaysInsertFields`), files linked from the **public bucket URL** | The schema — and so the migrations — never depend on whether a laptop has S3 keys. `next/image` and WebGL loaders fetch from Supabase's CDN, not through `/api/media/file`. | | D14 | **English admin**, labels in the editor's words, sidebar groups in reading order, row labels from the row's own copy | An admin that reads like the site is one an owner uses. | | D15 | **Every legal section is one rich-text field** (bold, links, lists, h3; table/button as blocks) | Asked for after a block-per-paragraph model: an editor could not bold a word or link inside a sentence. Start with rich text. | | D16 | `/admin` and `/api/` disallowed in `robots.ts`; `/llms.txt` from the SEO global; sitemap `lastmod` = the globals' `updatedAt` | SEO audit findings: a login screen in the crawl budget, request-time `lastmod` (ignored), no llms.txt. | ## The kits `bash .claude/skills/payload-cms/scaffold.sh ` copies a kit into the project (never overwrites without `--force`) and prints every file — that list is your checklist. Then `grep -rn 'TODO\|PROJECT CONFIG' src/cms src/app` and fill each one. | Kit | Files | Skill | |---|---|---| | `core` | `payload.config.ts`, `(payload)/*` plumbing, `(site)/layout.tsx`, `global-not-found.tsx`, `layouts/site-document.tsx`, `cms/{text-schema,content,globals,seo,seo-pages,seed}.ts`, collections users/media, `admin/share-preview.tsx`, `llms.txt/route.ts`, `scripts/migrate-direct.mjs` | this one | | `admin` | `(payload)/custom.css` (the skin), `admin/{graphics,welcome,row-label}.tsx` | `payload-admin` | | `analytics` | `cms/analytics.ts`, `collections/page-views.ts`, `api/track/route.ts`, `analytics-beacon.tsx`, `admin/analytics-*.tsx`, `admin/format.ts` | `payload-analytics` | | `guide` | `admin/guide-{content,body,view,nav}.tsx` (+ `yarn qa:shots`) | `payload-admin` | | `legal` | `cms/legal*.ts`, `views/legal/legal-rich-text.tsx` — **adapt-kit** (`--force`) | `references/legal-rich-text.md` | `core` + `admin` is the minimum (the `(payload)` layout imports the skin). `payload.config.ts` marks each kit's lines — delete the ones not installed. ## Phase 0 — Pre-flight (stop if any fails) 1. **Node ≥ 22 (24 recommended), pinned in `.nvmrc`.** On Node 20.17 Payload's CLI (`generate:importmap`, `generate:types`, `migrate:*`) **exits 0 and does nothing** — no error, no file. `node -v` before every CLI call. 2. **Next vs Payload peers:** `npm view @payloadcms/next@ peerDependencies` against `package.json`'s `next`. Pin all `@payloadcms/*` and `payload` to the **same exact version** (no caret). 3. **A Supabase project** with its connection strings — run `supabase-db` first if not. Details: `references/supabase-wiring.md`. 4. **All copy lives in typed objects in `src/data/mocks/`.** Derivation (D2) only sees what is there. Sweep `src/views` and `src/components` for literal copy (headings, buttons, form errors, cookie banner, menu, emails in `lib/`, 404, legal labels) and move it into mocks **before** installing — this is the step the reference site had to redo twice. aria-labels may stay in code. 5. **Propose the admin to the user before writing config**: the sidebar (`Home page` → numbered sections in scroll order, `Site` → header/footer/forms/ emails/cookie banner/SEO, `Pages & documents` → 404 + legal, `Settings`), what stays in code, which lists are open and why, which kits. Get a yes. ## Phase 1 — Install and split ```bash yarn add payload@ @payloadcms/next@ @payloadcms/db-postgres@ \ @payloadcms/richtext-lexical@ @payloadcms/storage-s3@ \ @payloadcms/plugin-seo@ @payloadcms/translations@ graphql sharp bash .claude/skills/payload-cms/scaffold.sh core admin ``` - `package.json`: `"type": "module"`; scripts `payload`, `generate:types`, `generate:importmap`, `migrate`, `migrate:create`, `"migrate:direct": "node scripts/migrate-direct.mjs"`. - `tsconfig.json` paths: `"@payload-config": ["./src/payload.config.ts"]`. - `next.config.ts`: `export default withPayload(nextConfig)`, `experimental.globalNotFound: true`, and `images.remotePatterns` for `*.supabase.co` + `*.storage.supabase.co` at `/storage/v1/object/public/**`. - `src/env.ts` + `.env.example` — `references/supabase-wiring.md` §Env (all optional strings, empty = unset, so a laptop without a DB still builds). - **Split the app into two root layouts:** `git mv` `src/app/{page.tsx, error.tsx,not-found.tsx,privacy-policy,robot-view,…}` → `src/app/(site)/`; move the body of the old `app/layout.tsx` into `src/layouts/site-document.tsx` (the kit's copy is the starter's layout as shipped — merge, don't overwrite) and delete `app/layout.tsx`. `api/`, `robots.ts`, `sitemap.ts`, `manifest.ts`, `globals.css` stay at `app/`. Point `global-not-found.tsx` at the real 404 view. - `verify.sh` already skips `src/payload-types.ts` and `src/app/(payload)/`. ## Phase 2 — The content model In `text-schema.ts` and `globals.ts` (`references/content-model.md` has the rules and the edge cases): 1. **`TEXT_GLOBALS`** — one entry per content object, slug / numbered label / group / `base` / a one-line `note` (what the screen is + any rule before Save). Group order = sidebar order. 2. **`SKIP`** — every wiring key the mocks use. Read each mock; anything a reader never sees as words. 3. **`OPEN_LISTS`** / **`FIXED_LISTS`** — with the reason in a comment, from the component's real constraints (a ring's angle, a marquee's fill, a headline's line count). 4. **`LABELS`** — every key whose humanised name an editor wouldn't understand. 5. **Views read through `content.ts`**: `const hero = await getText("hero")` in the view (Server Component), passed down exactly as the mock was. Nothing below the view changes. Shared chrome (header, footer, cookie banner) is read once in `SiteDocument` / the layout and passed as props. 6. Emails and other server copy: `getText("emails")` in the route; placeholders are **named** (`{name}`, `{company}`) and the editor's text is escaped. ## Phase 3 — SEO (part of core — never skip) 1. `seo-pages.ts`: one entry per route + the 404 — titles **50–60 chars, absolute, opening on the brand**; descriptions 100–150; written for search. 2. Every route's metadata comes **through its view** (hard rule #5 — `page.tsx` imports only `@/views`): the view exports `export const getHomeMetadata = () => getPageMetadata("home")` and the page does `export const generateMetadata = getHomeMetadata`. `(site)/layout.tsx` uses `getSiteMetadata()`; `SiteDocument` renders `getStructuredData()`. 3. `sitemap.ts` → `getSitemapPages()` (noindex pages dropped, `lastmod` from the content). `robots.ts` → disallow `/admin` and `/api/` (keep `/robot-view`). 4. `/llms.txt` from the kit. Details: `references/seo-global.md`. ## Phase 4 — Schema, migration, first user ```bash node -v # ≥ 22 — see Phase 0 yarn generate:importmap # silent no-op? → node node_modules/payload/bin.js generate:importmap --force yarn generate:types yarn migrate:direct create init # bare migrate:create does not load .env.local yarn migrate:direct # apply over the session pooler yarn dev # /admin → create the first user ``` Commit `src/payload-types.ts`, `src/migrations/*`, `importMap.js`. A rename the drizzle prompt would ask about cannot be answered non-interactively — split it into drop + add migrations, or hand-write it and patch the `.json` snapshot (`references/supabase-wiring.md` §Migrations). ## Phase 5 — The admin's look → `payload-admin` skill (§Skin) ## Phase 6 — Analytics (only if wanted) → `payload-analytics` skill ## Phase 7 — The editor's guide → `payload-admin` skill (§Guide) — **last**, it documents the final admin ## Phase 8 — Prove it (each one, not "it built") 1. **Edit → site:** change a field in each group, Save, refresh the site — shown. Put it back. 2. **Blank → fallback:** clear a field — the code's copy shows, nothing breaks. 3. **No database:** unset `DATABASE_URL`, `yarn build && yarn start` — the full site renders from code, errors logged once per global. 4. **Photo:** upload into a "Replace photo" field — the file lands in the bucket, the site shows it via the public URL; delete it → the code's photo returns. 5. **Open list:** cut to the floor and add a row — the section still looks right at rest and in motion; a half-filled new row is refused with its missing lines. 6. **SEO:** view-source on each route — title, description, canonical, og:image (1200 × 630), JSON-LD; `sitemap.xml`, `robots.txt`, `/llms.txt`. 7. **Static:** the build output lists the site's routes as static (○), not ƒ. 8. `yarn verify` · `yarn lint` · `yarn build` · Lighthouse unchanged (`yarn qa:lh`) — the CMS must not cost the site a point. ## Phase 9 — Vault (same turn) `cms-payload.md` (what this project's admin holds: groups, open lists, kits, where data lives), `tech-stack.md` + `changelog.md` (deps), `environment- variables.md`, an ADR for any departure from D1–D16. ## Traps (each cost time on the reference site) - Payload CLI on Node 20.17 → silent no-op (Phase 0). - `.env.local` overrides `.env` — a `DATABASE_URL` left in both takes `.env.local`'s. - Password with `%`/`$` in the URL: `%` throws "URI malformed"; `$` is expanded by `@next/env` and silently shortens it. Percent-encode, or letters+digits. - Supabase's Direct host is IPv6-only → `ENOTFOUND` on IPv4; use the session pooler (5432) for `DATABASE_URL_DIRECT`. - `revalidatePath` throws outside a request (seed, script) — the kit catches it. - A `"use client"` module's exports are client references — a server component can't call a helper exported from one (why `admin/format.ts` exists). - Payload's rich-text types use `any` — `verify.sh` skips `payload-types.ts`. - Payload's admin class names aren't a public API: after an upgrade, open `/admin` and re-check the skin. - Saving locally writes the live DB when dev and prod share one Supabase project — say so to the user; test edits go in and come back out.