# Payload CMS Development Rules You are an expert Payload CMS developer. When working with Payload projects, follow these rules: ## Teqo Project Context (read this first) Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public site + editorial CMS + an internal `/campanha` campaign-management area, all in this one Next.js + Payload app. Full architecture/decision doc lives outside this repo at `plano-arquitetura-campanha-2026.md` (Cowork workspace) — check it for the "why" behind decisions below. **Locked decisions — do not relitigate without a new concrete reason:** - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `geral`, `coordenador`, and `lideranca`, all on the separate auth collection `campaignUser` (see "Campaign auth" below). - Hosting stays on Vercel for now. No self-host/Coolify migration in progress. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. **Real conventions already established in this codebase (follow these, not just the generic patterns below):** - `Contact` is the normalized "person" record (name, email, phone, state/city via `src/lib/cities.ts`, CEP). Don't create a parallel "person" collection for new features (e.g. líderanças/apoiadores) — add a join collection that relates to `Contact`, the same way `Signature` and `Subscription` do. - Every action that writes to more than one collection (see `src/app/(frontend)/actions/*.ts`) wraps the writes in a Payload transaction (`payload.db.beginTransaction/commitTransaction/rollbackTransaction`) and passes `req: { transactionID }` to every `payload.create`/`payload.update` call. Follow this pattern for any new multi-collection write. - LGPD consent is tracked via a `Consent` collection (richText, versioned, optional stable `key`) referenced by relationship from `Signature`/`Subscription`/campaign `leadership`. New campaign opt-in flows resolve Consent by stable `key` (e.g. `lideranca-autopreenchimento`) and fail closed when missing — don't invent a parallel consent mechanism or hardcode document IDs for new work. - Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page. Collections whose listing is cached under a shared tag also call the listing helper (`revalidateCollectionListing`, e.g. `revalidatePostsListing()` → `revalidateTag('posts')`) — see "Posts & Tags" below. - Admin UI is organized via `admin.group` (e.g. `'Abaixo-assinados'`, `'Contatos'`, `'Configurações'`, `'Paginas'`, `'Campanha'`, `'Dados Eleitorais'`) — group new collections consistently instead of leaving them ungrouped. - i18n: Payload admin defaults to `pt` (not `pt-BR` — see `payload.config.ts`, `payload/i18n/pt`). - Language & naming conventions: ALL code identifiers are in English — variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. the `post.type` enum values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`, `eleitoral`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL (segment values come from the data). **Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):** - Local dev and tests must NEVER touch production. Local dev runs against a local Docker Postgres (`pnpm db:start`); `pnpm dev` refuses a non-local `DATABASE_URL` (guard: `scripts/guard-dev-db.mjs`) unless `ALLOW_REMOTE_DB=true`. Tests load `.env.test` (`teqo_test`) and refuse any database whose name doesn't end in `_test` (`tests/helpers/assertTestDatabase.ts`). Never repoint `.env`/`.env.local`/`.env.test` `DATABASE_URL` at Neon. Full workflow lives in the `local-database` skill (`.cursor/skills/local-database`). - Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.cursor/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). - To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below. - To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard; no revalidate needed — campaign pages are dynamic). See "Election baseline data (TSE 2022)" below. ## Known Gaps (as of 2026-07-18 — resolve before relying on this file for onboarding new devs) Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.md`](docs/roadmap.md). 1. **Payload admin RBAC is still absent.** The campaign auth collection now has `role`, but the `users` admin collection still has no roles; every admin user has full Payload admin access. Add admin roles before opening `/admin` to a broader team. 2. **Legacy public consent flows still hardcode document IDs** (e.g. `consent: 2` in `submitWhatsapp.ts`). The campaign leadership flow does not: it requires `Consent.key = 'lideranca-autopreenchimento'` and fails closed when missing. Migrating the older public flows remains separate work. 3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas. 4. **Campaign LGPD consent text is not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy (see deploy checklist). **Recently resolved (2026-07-18):** the `/campanha` nuclei MVP shipped (auth/RBAC, nuclei, leaderships, vote estimates, updates, WhatsApp invites, dashboard) with consolidated migration `20260718_010733_consolidate_campaign_schema`. Later the same day, cycle 2 was merged and deployed: multi-municipality/neighborhood territory arrays (migration `20260718_190559_territorio_multi_municipio_bairro`), TSE 2022 election baseline collections + `pnpm db:seed:tse` (migration `20260718_195854_add_election_results`), the filtered nuclei-list overview panel, the nucleus share dialog, and the installable `/campanha` PWA. Earlier (2026-07-15): local Postgres via `docker-compose.yml` (Postgres 17, `teqo_test`); Payload migrations baselined; dev/test database guards; `post`/`tag` news routes deployed (see "Posts & Tags" below). ## Posts & Tags (news / publications) The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`. - **`post`** (`src/collections/Post.ts`) — fields: `title`, `slug` (unique, indexed, auto-slugified from `title` when left empty), `type` (select enum `noticia|campanha|artigo|evento`), `category` (required single relationship → `tag`), `tags` (`hasMany` relationship → `tag`), `subtitle`, `coverImage` (upload → `media`), `publishedDate`, `body` (richText). Has drafts/versions (`schedulePublish`, `maxPerDoc: 5`). - **`tag`** (`src/collections/Tag.ts`) — fields: `name`, `slug` (unique, indexed, auto-slugified from `name`), and `hidden` (checkbox, admin label "Esconder", default `false`). Tags are the shared taxonomy for both `post.category` and `post.tags`. **Tags are a taxonomy, not a person record.** The `Contact` convention above applies to _people_; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa. **Electoral visibility control (`hidden` + fail-closed `isPostVisible`).** Marking a tag `hidden` hides every post that references it — as `category` or in `tags` — with a single toggle. This exists so campaign / pre-candidacy content (tagged `eleitoral`) can be pulled from the public site during the electoral period. `isPostVisible(post)` (`src/utilities/posts.ts`) returns true only when the post is `published` AND none of its related tags (`category` + `tags`) is `hidden`. It **fails closed**: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with `depth >= 1`. Every public read filters through `getVisiblePosts()`, which applies this predicate to the cached published list. **URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's _current_ `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata. **Caching (mixed ISR, `posts` tag).** Public reads go through the `unstable_cache` wrappers in `src/utilities/posts.ts` (`getCachedPublishedPosts`, `getCachedPostBySlug`), all tagged `posts`. The routes set `export const dynamicParams = true` and build `generateStaticParams` from `getVisiblePosts()`: known paths are statically generated, unknown ones render on demand, and everything stays cached until the `posts` tag is busted. `revalidatePostsListing()` (`src/utilities/documents.ts`) is `revalidateTag('posts')` (the listing tag is `` `${collection}s` `` → `posts`), so one call busts the home page, every listing route, and every article route at once. **Self-revalidation on admin edits.** Both collections have an `afterChange` hook. `post` busts its own document tag + the `posts` listing on every publish/update (skipping only the initial draft that the admin create view generates during render). `tag` is broader: on change it re-reads every post referencing that tag (as `category` or in `tags`, passing `req` for transaction safety) and revalidates each one plus the listing — so flipping `hidden` is reflected on the site immediately. **Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~39 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images to Vercel Blob. The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). The Vercel Blob store is shared across environments, so before uploading a cover it deletes any orphan blob left under the same deterministic `.` key, keeping the seed re-runnable. ### Revalidating after a manual/direct DB change Admin edits made through the deployed app self-revalidate via the `afterChange` hooks above. But any write that does **not** go through the deployed Payload runtime — direct SQL, `pnpm db:seed:posts`, a DB restore / `db:pull` — will **not** bust production's frozen `posts` cache. (Even when such a write triggers the hook, its `revalidateTag('posts')` runs in that CLI/local process and cannot touch the deployed server's cache.) The pages keep serving stale content until the tag is busted. Bust it with the secured endpoint (`src/app/(frontend)/api/revalidate/route.ts`), which calls `revalidatePostsListing()`: ```bash curl -X POST "https:///api/revalidate" \ -H "x-revalidate-secret: $REVALIDATE_SECRET" ``` - **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer `; it is compared to `REVALIDATE_SECRET` with a constant-time check. - `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`. - **Responses:** `200 { revalidated: true, tag: 'posts' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server. - This POST is the required last step of the post-seed / direct-DB-change runbook. ## Campaign auth (`/campanha`) The internal `/campanha` area is gated by its own authentication, deliberately kept **isolated from the Payload admin (`/admin`)** so a campaign session and an admin session can coexist in the same browser. - **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a separate Payload auth collection with `name`, optional contact `phone`, and required `role`: `geral`, `coordenador`, or `lideranca` (default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as `username`. It is not `users`: `admin.user` remains `users`, so campaign users cannot log into `/admin`. - **Isolated session cookie `campaign-token`.** The flow sets its own httpOnly cookie named `campaign-token` (constant `CAMPAIGN_TOKEN_COOKIE` in `src/utilities/campaignAuth.ts`), scoped to `path: '/campanha'`, `sameSite: 'lax'`, and `secure` only in production. It is deliberately NOT the default `payload-token` cookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other. - **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })`, accepts only `user.collection === 'campaignUser'`, then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role. - **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, and — because the Local API returns a token WITHOUT setting a cookie — sets the `campaign-token` cookie itself (using the collection's `auth.tokenExpiration` as `maxAge`) before redirecting to `/campanha`. `logoutCampaign` clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`. - **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the ``/`` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier. - **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema. ## Campaign nuclei MVP The first `/campanha` operational cycle is centered on `electoralNucleus`; a Núcleo is the campaign's operating unit and is not a TSE electoral zone. - **Collections:** `electoralNucleus` (territory, coordinators, TSE zone references, intelligence and vote estimates), `leadership` (unique `Contact`↔nucleus join with support status), `nucleusUpdate` (immutable field reports), and `campaignInvite` (single-use WhatsApp invite hashes). All are in admin group `Campanha`. - **Canonical nucleus URLs:** nuclei use a unique canonical `slug` generated from `name` and route through `/campanha/nucleos/[slug]`. IDs stay internal. Names are immutable after creation so existing shared links remain durable without an alias table. - **Territory:** Bahia is implicit. Since 2026-07-18 nuclei use `hasMany` arrays `regions` / `cities` / `neighborhoods` (plus `locality` / `territoryNotes`), validated against official Bahia identity territories (`src/lib/bahiaTerritories.ts`): `regions` is derived server-side from the selected cities, and neighborhoods require exactly one city. List filters stay single-select and query array membership with `{ equals }`. Migration `20260718_190559_territorio_multi_municipio_bairro` backfilled the old scalar fields (`docs/plans/territorio-multi-municipio-bairro.md`). - **Role scope:** `geral` sees/manages all nuclei, assigns coordinators, and confirms estimates; `coordenador` sees assigned nuclei and manages their leaderships, updates, invites, and estimates; `lideranca` sees only nuclei linked through an engaged leadership record, creates own updates, and suggests estimates. - **Sensitive fields:** `leadership.supportStatus`, internal notes, consent notes, and strategic nucleus intelligence are staff-only and are excluded from leadership view models. Revoking `engajado` revokes nucleus access immediately. Dashboard aggregates omit Payload-redacted missing statuses and fail closed on invalid enum values. - **People and consent:** leaderships always reuse `Contact`; multi-collection writes are transactional. Self-service updates accept a strict whitelist derived from the invite token. Campaign consent is looked up only by the stable key `lideranca-autopreenchimento`. - **Invites:** only SHA-256 token hashes are persisted. Absolute invite URLs require a valid `NEXT_PUBLIC_SITE_URL` (exact HTTPS DNS origin in production). Public invite pages are dynamic, `no-store`, `noindex,nofollow`, `no-referrer`, and have no third-party application scripts. Login invites are allowed only for engaged leaderships. - **Vote estimates:** proposals never overwrite the confirmed value; each proposal carries a server-generated UUID version compared under advisory lock on confirm. Only a coordinator assigned to the nucleus or `geral` may confirm. - **List overview (B1, 2026-07-18):** `/campanha/nucleos` renders an aggregate panel between the filters and the list (`NucleusListOverview` + `loadNucleusListOverviewData`): confirmed vote estimate total/%, coordinator coverage, and a 3-update preview, computed over the entire filtered set (`buildNucleusListWhere`, `pagination: false`, `overrideAccess: false`). Hidden when the filtered set is empty; `lideranca` gets a reduced select without pending-suggestion counts. - **Share dialog (C1, 2026-07-18):** the nucleus detail header has a "Compartilhar" dialog (WhatsApp `wa.me` per recipient + copy link) that sends only the URL — it never creates invites or grants access. Recipients (gerais, assigned coordinators, `engajado` leaderships) are loaded on open via a read-only Server Action; the loader gates on nucleus access (`overrideAccess: false`) then reads name+phone privileged (`overrideAccess: true`) to avoid N+1 field-access checks. `canReadCampaignUserPhone` allows any campaign user to read `geral` phones. - **PWA (D1, 2026-07-18):** `/campanha` is installable — manifest and service worker are served by route handlers under `/campanha` (script built in `src/utilities/campaignPwa.ts`), scoped to the vertical only (public site and `/admin` have no SW). Cache is named `campanha-` and swapped on deploy (`skipWaiting`/`claim`); navigation is network-first with an `/campanha/offline` fallback; RSC/Flight responses and `/campanha/convite/*` are never cached. `InstallPwaToast` prompts install on mobile (Android `beforeinstallprompt`, iOS step-by-step drawer, `sessionStorage` dismissal), and logout wipes the Cache API client-side before the server action runs. Push handlers are placeholders for D2. ## Election baseline data (TSE 2022) Public TSE 2022 results back the electoral baseline (roadmap item A3; UI lands with A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. `pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached. ### Local verification and deploy checklist 1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds. 2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`. 3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. 4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change. 5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically. 6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent. ## Core Principles 1. **TypeScript-First**: Always use TypeScript with proper types from Payload 2. **Security-Critical**: Follow all security patterns, especially access control 3. **Type Generation**: Run `generate:types` script after schema changes 4. **Transaction Safety**: Always pass `req` to nested operations in hooks 5. **Access Control**: Understand Local API bypasses access control by default 6. **Access Control**: Ensure roles exist when modifiyng collection or globals with access controls ### Code Validation - To validate typescript correctness after modifying code run `tsc --noEmit` - Generate import maps after creating or modifying components. - Any change to a collection/global/field schema requires a migration: `pnpm migrate:create `, then `pnpm migrate` locally. Never rely on `push` (it is `false`). See the `payload-migrations` skill. ## Project Structure Generic Payload layout (aspirational — `access/` and `hooks/` as separate dirs don't exist yet in this repo; access control and hooks are currently written inline inside each collection/global file): ``` src/ ├── app/ │ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts) │ ├── (payload)/ # Payload admin routes │ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth") ├── collections/ # Users, CampaignUser, CampaignInvite, ElectoralNucleus, Leadership, NucleusUpdate, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag ├── globals/ # Global configs (SiteSettings, HomePage, Metadata) ├── components/ # Custom React components (campaign/*, ui from shadcn) ├── lib/ # cities.ts, bahiaTerritories.ts, formData.ts, zod schemas (lib/schemas/*) ├── utilities/ # campaign*, nucleus*, posts, documents, globals, locks, etc. └── payload.config.ts # Main config ``` ## Configuration ### Minimal Config Pattern ```typescript import { buildConfig } from 'payload' import { mongooseAdapter } from '@payloadcms/db-mongodb' import { lexicalEditor } from '@payloadcms/richtext-lexical' import path from 'path' import { fileURLToPath } from 'url' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfig({ admin: { user: 'users', importMap: { baseDir: path.resolve(dirname), }, }, collections: [Users, Media], editor: lexicalEditor(), secret: process.env.PAYLOAD_SECRET, typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, db: mongooseAdapter({ url: process.env.DATABASE_URL, }), }) ``` ## Collections ### Basic Collection ```typescript import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'author', 'status', 'createdAt'], }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', unique: true, index: true }, { name: 'content', type: 'richText' }, { name: 'author', type: 'relationship', relationTo: 'users' }, ], timestamps: true, } ``` ### Auth Collection with RBAC ```typescript export const Users: CollectionConfig = { slug: 'users', auth: true, fields: [ { name: 'roles', type: 'select', hasMany: true, options: ['admin', 'editor', 'user'], defaultValue: ['user'], required: true, saveToJWT: true, // Include in JWT for fast access checks access: { update: ({ req: { user } }) => user?.roles?.includes('admin'), }, }, ], } ``` ## Fields ### Common Patterns ```typescript // Auto-generate slugs import { slugField } from 'payload' slugField({ fieldToUse: 'title' }) // Relationship with filtering { name: 'category', type: 'relationship', relationTo: 'categories', filterOptions: { active: { equals: true } }, } // Conditional field { name: 'featuredImage', type: 'upload', relationTo: 'media', admin: { condition: (data) => data.featured === true, }, } // Virtual field { name: 'fullName', type: 'text', virtual: true, hooks: { afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`], }, } ``` ## CRITICAL SECURITY PATTERNS ### 1. Local API Access Control (MOST IMPORTANT) ```typescript // ❌ SECURITY BUG: Access control bypassed await payload.find({ collection: 'posts', user: someUser, // Ignored! Operation runs with ADMIN privileges }) // ✅ SECURE: Enforces user permissions await payload.find({ collection: 'posts', user: someUser, overrideAccess: false, // REQUIRED }) // ✅ Administrative operation (intentional bypass) await payload.find({ collection: 'posts', // No user, overrideAccess defaults to true }) ``` **Rule**: When passing `user` to Local API, ALWAYS set `overrideAccess: false` ### 2. Transaction Safety in Hooks ```typescript // ❌ DATA CORRUPTION RISK: Separate transaction hooks: { afterChange: [ async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, // Missing req - runs in separate transaction! }) }, ], } // ✅ ATOMIC: Same transaction hooks: { afterChange: [ async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req, // Maintains atomicity }) }, ], } ``` **Rule**: ALWAYS pass `req` to nested operations in hooks ### 3. Prevent Infinite Hook Loops ```typescript // ❌ INFINITE LOOP hooks: { afterChange: [ async ({ doc, req }) => { await req.payload.update({ collection: 'posts', id: doc.id, data: { views: doc.views + 1 }, req, }) // Triggers afterChange again! }, ], } // ✅ SAFE: Use context flag hooks: { afterChange: [ async ({ doc, req, context }) => { if (context.skipHooks) return await req.payload.update({ collection: 'posts', id: doc.id, data: { views: doc.views + 1 }, context: { skipHooks: true }, req, }) }, ], } ``` ## Access Control ### Collection-Level Access ```typescript import type { Access } from 'payload' // Boolean return const authenticated: Access = ({ req: { user } }) => Boolean(user) // Query constraint (row-level security) const ownPostsOnly: Access = ({ req: { user } }) => { if (!user) return false if (user?.roles?.includes('admin')) return true return { author: { equals: user.id }, } } // Async access check const projectMemberAccess: Access = async ({ req, id }) => { const { user, payload } = req if (!user) return false if (user.roles?.includes('admin')) return true const project = await payload.findByID({ collection: 'projects', id: id as string, depth: 0, }) return project.members?.includes(user.id) } ``` ### Field-Level Access ```typescript // Field access ONLY returns boolean (no query constraints) { name: 'salary', type: 'number', access: { read: ({ req: { user }, doc }) => { // Self can read own salary if (user?.id === doc?.id) return true // Admin can read all return user?.roles?.includes('admin') }, update: ({ req: { user } }) => { // Only admins can update return user?.roles?.includes('admin') }, }, } ``` ### Common Access Patterns ```typescript // Anyone export const anyone: Access = () => true // Authenticated only export const authenticated: Access = ({ req: { user } }) => Boolean(user) // Admin only export const adminOnly: Access = ({ req: { user } }) => { return user?.roles?.includes('admin') } // Admin or self export const adminOrSelf: Access = ({ req: { user } }) => { if (user?.roles?.includes('admin')) return true return { id: { equals: user?.id } } } // Published or authenticated export const authenticatedOrPublished: Access = ({ req: { user } }) => { if (user) return true return { _status: { equals: 'published' } } } ``` ## Hooks ### Common Hook Patterns ```typescript import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', hooks: { // Before validation - format data beforeValidate: [ async ({ data, operation }) => { if (operation === 'create') { data.slug = slugify(data.title) } return data }, ], // Before save - business logic beforeChange: [ async ({ data, req, operation, originalDoc }) => { if (operation === 'update' && data.status === 'published') { data.publishedAt = new Date() } return data }, ], // After save - side effects afterChange: [ async ({ doc, req, operation, previousDoc, context }) => { // Check context to prevent loops if (context.skipNotification) return if (operation === 'create') { await sendNotification(doc) } return doc }, ], // After read - computed fields afterRead: [ async ({ doc, req }) => { doc.viewCount = await getViewCount(doc.id) return doc }, ], // Before delete - cascading deletes beforeDelete: [ async ({ req, id }) => { await req.payload.delete({ collection: 'comments', where: { post: { equals: id } }, req, // Important for transaction }) }, ], }, } ``` ## Queries ### Local API ```typescript // Find with complex query const posts = await payload.find({ collection: 'posts', where: { and: [{ status: { equals: 'published' } }, { 'author.name': { contains: 'john' } }], }, depth: 2, // Populate relationships limit: 10, sort: '-createdAt', select: { title: true, author: true, }, }) // Find by ID const post = await payload.findByID({ collection: 'posts', id: '123', depth: 2, }) // Create const newPost = await payload.create({ collection: 'posts', data: { title: 'New Post', status: 'draft', }, }) // Update await payload.update({ collection: 'posts', id: '123', data: { status: 'published' }, }) // Delete await payload.delete({ collection: 'posts', id: '123', }) ``` ### Query Operators ```typescript // Equals { status: { equals: 'published' } } // Not equals { status: { not_equals: 'draft' } } // Greater than / less than { price: { greater_than: 100 } } { age: { less_than_equal: 65 } } // Contains (case-insensitive) { title: { contains: 'payload' } } // Like (all words present) { description: { like: 'cms headless' } } // In array { category: { in: ['tech', 'news'] } } // Exists { image: { exists: true } } // Near (geospatial) { location: { near: [-122.4194, 37.7749, 10000] } } ``` ### AND/OR Logic ```typescript { or: [ { status: { equals: 'published' } }, { author: { equals: user.id } }, ], } { and: [ { status: { equals: 'published' } }, { featured: { equals: true } }, ], } ``` ## Getting Payload Instance ```typescript // In API routes (Next.js) import { getPayload } from 'payload' import config from '@payload-config' export async function GET() { const payload = await getPayload({ config }) const posts = await payload.find({ collection: 'posts', }) return Response.json(posts) } // In Server Components import { getPayload } from 'payload' import config from '@payload-config' export default async function Page() { const payload = await getPayload({ config }) const { docs } = await payload.find({ collection: 'posts' }) return
{docs.map(post =>

{post.title}

)}
} ``` ## Components The Admin Panel can be extensively customized using React Components. Custom Components can be Server Components (default) or Client Components. ### Defining Components Components are defined using **file paths** (not direct imports) in your config: **Component Path Rules:** - Paths are relative to project root or `config.admin.importMap.baseDir` - Named exports: use `#ExportName` suffix or `exportName` property - Default exports: no suffix needed - File extensions can be omitted ```typescript import { buildConfig } from 'payload' export default buildConfig({ admin: { components: { // Logo and branding graphics: { Logo: '/components/Logo', Icon: '/components/Icon', }, // Navigation Nav: '/components/CustomNav', beforeNavLinks: ['/components/CustomNavItem'], afterNavLinks: ['/components/NavFooter'], // Header header: ['/components/AnnouncementBanner'], actions: ['/components/ClearCache', '/components/Preview'], // Dashboard beforeDashboard: ['/components/WelcomeMessage'], afterDashboard: ['/components/Analytics'], // Auth beforeLogin: ['/components/SSOButtons'], logout: { Button: '/components/LogoutButton' }, // Settings settingsMenu: ['/components/SettingsMenu'], // Views views: { dashboard: { Component: '/components/CustomDashboard' }, }, }, }, }) ``` **Component Path Rules:** - Paths are relative to project root or `config.admin.importMap.baseDir` - Named exports: use `#ExportName` suffix or `exportName` property - Default exports: no suffix needed - File extensions can be omitted ### Component Types 1. **Root Components** - Global Admin Panel (logo, nav, header) 2. **Collection Components** - Collection-specific (edit view, list view) 3. **Global Components** - Global document views 4. **Field Components** - Custom field UI and cells ### Component Types 1. **Root Components** - Global Admin Panel (logo, nav, header) 2. **Collection Components** - Collection-specific (edit view, list view) 3. **Global Components** - Global document views 4. **Field Components** - Custom field UI and cells ### Server vs Client Components **All components are Server Components by default** (can use Local API directly): ```tsx // Server Component (default) import type { Payload } from 'payload' async function MyServerComponent({ payload }: { payload: Payload }) { const posts = await payload.find({ collection: 'posts' }) return
{posts.totalDocs} posts
} export default MyServerComponent ``` **Client Components** need the `'use client'` directive: ```tsx 'use client' import { useState } from 'react' import { useAuth } from '@payloadcms/ui' export function MyClientComponent() { const [count, setCount] = useState(0) const { user } = useAuth() return ( ) } ``` ### Using Hooks (Client Components Only) ```tsx 'use client' import { useAuth, // Current user useConfig, // Payload config (client-safe) useDocumentInfo, // Document info (id, collection, etc.) useField, // Field value and setter useForm, // Form state useFormFields, // Multiple field values (optimized) useLocale, // Current locale useTranslation, // i18n translations usePayload, // Local API methods } from '@payloadcms/ui' export function MyComponent() { const { user } = useAuth() const { config } = useConfig() const { id, collection } = useDocumentInfo() const locale = useLocale() const { t } = useTranslation() return
Hello {user?.email}
} ``` ### Collection/Global Components ```typescript export const Posts: CollectionConfig = { slug: 'posts', admin: { components: { // Edit view edit: { PreviewButton: '/components/PostPreview', SaveButton: '/components/CustomSave', SaveDraftButton: '/components/SaveDraft', PublishButton: '/components/Publish', }, // List view list: { Header: '/components/ListHeader', beforeList: ['/components/BulkActions'], afterList: ['/components/ListFooter'], }, }, }, } ``` ### Field Components ```typescript { name: 'status', type: 'select', options: ['draft', 'published'], admin: { components: { // Edit view field Field: '/components/StatusField', // List view cell Cell: '/components/StatusCell', // Field label Label: '/components/StatusLabel', // Field description Description: '/components/StatusDescription', // Error message Error: '/components/StatusError', }, }, } ``` **UI Field** (presentational only, no data): ```typescript { name: 'refundButton', type: 'ui', admin: { components: { Field: '/components/RefundButton', }, }, } ``` ### Performance Best Practices 1. **Import correctly:** - Admin Panel: `import { Button } from '@payloadcms/ui'` - Frontend: `import { Button } from '@payloadcms/ui/elements/Button'` 2. **Optimize re-renders:** ```tsx // ❌ BAD: Re-renders on every form change const { fields } = useForm() // ✅ GOOD: Only re-renders when specific field changes const value = useFormFields(([fields]) => fields[path]) ``` 3. **Prefer Server Components** - Only use Client Components when you need: - State (useState, useReducer) - Effects (useEffect) - Event handlers (onClick, onChange) - Browser APIs (localStorage, window) 4. **Minimize serialized props** - Server Components serialize props sent to client ### Styling Components ```tsx import './styles.scss' export function MyComponent() { return
Content
} ``` ```scss // Use Payload's CSS variables .my-component { background-color: var(--theme-elevation-500); color: var(--theme-text); padding: var(--base); border-radius: var(--border-radius-m); } // Import Payload's SCSS library @import '~@payloadcms/ui/scss'; .my-component { @include mid-break { background-color: var(--theme-elevation-900); } } ``` ### Type Safety ```tsx import type { TextFieldServerComponent, TextFieldClientComponent, TextFieldCellComponent, SelectFieldServerComponent, // ... etc } from 'payload' export const MyField: TextFieldClientComponent = (props) => { // Fully typed props } ``` ### Import Map Payload auto-generates `app/(payload)/admin/importMap.js` to resolve component paths. **Regenerate manually:** ```bash payload generate:importmap ``` **Set custom location:** ```typescript export default buildConfig({ admin: { importMap: { baseDir: path.resolve(dirname, 'src'), importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'), }, }, }) ``` ## Custom Endpoints ```typescript import type { Endpoint } from 'payload' import { APIError } from 'payload' // Always check authentication export const protectedEndpoint: Endpoint = { path: '/protected', method: 'get', handler: async (req) => { if (!req.user) { throw new APIError('Unauthorized', 401) } // Use req.payload for database operations const data = await req.payload.find({ collection: 'posts', where: { author: { equals: req.user.id } }, }) return Response.json(data) }, } // Route parameters export const trackingEndpoint: Endpoint = { path: '/:id/tracking', method: 'get', handler: async (req) => { const { id } = req.routeParams const tracking = await getTrackingInfo(id) if (!tracking) { return Response.json({ error: 'not found' }, { status: 404 }) } return Response.json(tracking) }, } ``` ## Drafts & Versions ```typescript export const Pages: CollectionConfig = { slug: 'pages', versions: { drafts: { autosave: true, schedulePublish: true, validate: false, // Don't validate drafts }, maxPerDoc: 100, }, access: { read: ({ req: { user } }) => { // Public sees only published if (!user) return { _status: { equals: 'published' } } // Authenticated sees all return true }, }, } // Create draft await payload.create({ collection: 'pages', data: { title: 'Draft Page' }, draft: true, // Skips required field validation }) // Read with drafts const page = await payload.findByID({ collection: 'pages', id: '123', draft: true, // Returns draft if available }) ``` ## Field Type Guards ```typescript import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType, fieldIsBlockType, fieldSupportsMany, fieldHasMaxDepth, } from 'payload' function processField(field: Field) { // Check if field stores data if (fieldAffectsData(field)) { console.log(field.name) // Safe to access } // Check if field has nested fields if (fieldHasSubFields(field)) { field.fields.forEach(processField) // Safe to access } // Check field type if (fieldIsArrayType(field)) { console.log(field.minRows, field.maxRows) } // Check capabilities if (fieldSupportsMany(field) && field.hasMany) { console.log('Multiple values supported') } } ``` ## Plugins ### Using Plugins ```typescript import { seoPlugin } from '@payloadcms/plugin-seo' import { redirectsPlugin } from '@payloadcms/plugin-redirects' export default buildConfig({ plugins: [ seoPlugin({ collections: ['posts', 'pages'], }), redirectsPlugin({ collections: ['pages'], }), ], }) ``` ### Creating Plugins ```typescript import type { Config, Plugin } from 'payload' interface MyPluginConfig { collections?: string[] enabled?: boolean } export const myPlugin = (options: MyPluginConfig): Plugin => (config: Config): Config => ({ ...config, collections: config.collections?.map((collection) => { if (options.collections?.includes(collection.slug)) { return { ...collection, fields: [...collection.fields, { name: 'pluginField', type: 'text' }], } } return collection }), }) ``` ## Best Practices ### Security 1. Always set `overrideAccess: false` when passing `user` to Local API 2. Field-level access only returns boolean (no query constraints) 3. Default to restrictive access, gradually add permissions 4. Never trust client-provided data 5. Use `saveToJWT: true` for roles to avoid database lookups ### Performance 1. Index frequently queried fields 2. Use `select` to limit returned fields 3. Set `maxDepth` on relationships to prevent over-fetching 4. Use query constraints over async operations in access control 5. Cache expensive operations in `req.context` ### Data Integrity 1. Always pass `req` to nested operations in hooks 2. Use context flags to prevent infinite hook loops 3. Enable transactions for MongoDB (requires replica set) and Postgres 4. Use `beforeValidate` for data formatting 5. Use `beforeChange` for business logic ### Type Safety 1. Run `generate:types` after schema changes 2. Import types from generated `payload-types.ts` 3. Type your user object: `import type { User } from '@/payload-types'` 4. Use `as const` for field options 5. Use field type guards for runtime type checking ### Organization 1. Keep collections in separate files 2. Extract access control to `access/` directory 3. Extract hooks to `hooks/` directory 4. Use reusable field factories for common patterns 5. Document complex access control with comments ## Common Gotchas 1. **Local API Default**: Access control bypassed unless `overrideAccess: false` 2. **Transaction Safety**: Missing `req` in nested operations breaks atomicity 3. **Hook Loops**: Operations in hooks can trigger the same hooks 4. **Field Access**: Cannot use query constraints, only boolean 5. **Relationship Depth**: Default depth is 2, set to 0 for IDs only 6. **Draft Status**: `_status` field auto-injected when drafts enabled 7. **Type Generation**: Types not updated until `generate:types` runs 8. **MongoDB Transactions**: Require replica set configuration 9. **SQLite Transactions**: Disabled by default, enable with `transactionOptions: {}` 10. **Point Fields**: Not supported in SQLite ## Additional Context Files For deeper exploration of specific topics, refer to the context files located in `.cursor/rules/`: ### Available Context Files 1. **`payload-overview.md`** - High-level architecture and core concepts - Payload structure and initialization - Configuration fundamentals - Database adapters overview 2. **`security-critical.md`** - Critical security patterns (⚠️ IMPORTANT) - Local API access control - Transaction safety in hooks - Preventing infinite hook loops 3. **`collections.md`** - Collection configurations - Basic collection patterns - Auth collections with RBAC - Upload collections - Drafts and versioning - Globals 4. **`fields.md`** - Field types and patterns - All field types with examples - Conditional fields - Virtual fields - Field validation - Common field patterns 5. **`field-type-guards.md`** - TypeScript field type utilities - Field type checking utilities - Safe type narrowing - Runtime field validation 6. **`access-control.md`** - Permission patterns - Collection-level access - Field-level access - Row-level security - RBAC patterns - Multi-tenant access control 7. **`access-control-advanced.md`** - Complex access patterns - Nested document access - Cross-collection permissions - Dynamic role hierarchies - Performance optimization 8. **`hooks.md`** - Lifecycle hooks - Collection hooks - Field hooks - Hook context patterns - Common hook recipes 9. **`queries.md`** - Database operations - Local API usage - Query operators - Complex queries with AND/OR - Performance optimization 10. **`endpoints.md`** - Custom API endpoints - REST endpoint patterns - Authentication in endpoints - Error handling - Route parameters 11. **`adapters.md`** - Database and storage adapters - MongoDB, PostgreSQL, SQLite patterns - Storage adapter usage (S3, Azure, GCS, etc.) - Custom adapter development 12. **`plugin-development.md`** - Creating plugins - Plugin architecture - Modifying configuration - Plugin hooks - Best practices 13. **`components.md`** - Custom Components - Component types (Root, Collection, Global, Field) - Server vs Client Components - Component paths and definition - Default and custom props - Using hooks - Performance best practices - Styling components ## Resources - Docs: https://payloadcms.com/docs - LLM Context: https://payloadcms.com/llms-full.txt - GitHub: https://github.com/payloadcms/payload - Examples: https://github.com/payloadcms/payload/tree/main/examples - Templates: https://github.com/payloadcms/payload/tree/main/templates