436 seeded Praças (municipality-based; per-TSE-zone in Salvador/Camaçari) become the operational unit; roles renamed to coordinator/advisor/leader with advisors scoped to administered Praças. Leadership is one record per person across many Praças/organizations; vote pledges split leader-declared vs staff-estimated (estimates never serialized to leaders). Adds organizations, demand workflow with staff-only cost/receipts, action-plan results with deputy presence, manual political trend, multi-candidate comparison and the diverging red/white/blue map mode. Migration 20260721_020109_remodel_plazas deliberately resets the campaign vertical in production (public site untouched); test suite ported to the new model (unit 187 / int 306 / e2e 9 green).
64 KiB
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 arecoordinator(label "Coordenador Geral"),advisor("Assessor"), andleader("Liderança"), all on the separate auth collectioncampaignUser(see "Campaign auth" below). An advisor sees and manages ONLY the Praças they administer. - 2026-07-20 remodel (in progress): "Praça" replaces "Núcleo Eleitoral" as the operational unit. Praças are PREDEFINED (seeded, geography read-only): one per Bahia municipality, except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171); shared/leaky TSE zones are cut at the municipal boundary. Code identifier:
plaza. Leaderships are one record per person (contactUNIQUE) linked to N plazas and N organizations; vote pledges are per leadership×plaza withdeclaredVotes(leader-visible) vsestimatedVotes(staff-only, hidden from the leader). Campaign production data was reset by the remodel migration (public-site data untouched). Master plan:docs/plans/remodelagem-pracas.md. Sections below describing "nucleus" surfaces are being rewritten as the remodel lands. - 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):
Contactis the normalized "person" record (name, email, phone, state/city viasrc/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 toContact, the same waySignatureandSubscriptiondo.- 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 passesreq: { transactionID }to everypayload.create/payload.updatecall. Follow this pattern for any new multi-collection write. - LGPD consent is tracked via a
Consentcollection (richText, versioned, optional stablekey) referenced by relationship fromSignature/Subscription/campaignleadership. New campaign opt-in flows resolve Consent by stablekey(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 anafterChangehook. 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(notpt-BR— seepayload.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), imagealttext, SEO/metadata values (title/description/keywords), Payload admin config text (labels/singular/plural/admin.description/fieldlabel), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. thepost.typeenum valuesnoticia|campanha|artigo|evento, tag/category slugs likesaude,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 devrefuses a non-localDATABASE_URL(guard:scripts/guard-dev-db.mjs) unlessALLOW_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.testDATABASE_URLat Neon. Full workflow lives in thelocal-databaseskill (.cursor/skills/local-database). - Schema changes go through committed Payload migrations —
push: falseeverywhere, never flip it on against a remote DB. Edit configs →pnpm migrate:create <name>→ commitsrc/migrations/*(both.tsand.json, plusindex.ts) →pnpm migratelocally.pnpm buildrunspayload migrate, so every Vercel deploy applies pending migrations to prod. Prod is baselined at migration20260715_163458_initial— never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in thepayload-migrationsskill (.cursor/skills/payload-migrations). Consent.textwas reconciled fromvarchartojsonbvia20260715_163500_consent_text_to_jsonb(matching therichTextfield). 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/subscriptionrow data). - To (re)populate news content,
pnpm db:seed:postsfetches articles live from jorgesolla.com.br into the LOCAL db (same non-localDATABASE_URLguard aspnpm dev). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the productionpostscache viaPOST /api/revalidate. See "Posts & Tags" below. - To (re)populate the TSE 2022 election baseline,
pnpm db:seed:tsedownloads 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.
- Payload admin RBAC is still absent. The campaign auth collection now has
role, but theusersadmin collection still has no roles; every admin user has full Payload admin access. Add admin roles before opening/adminto a broader team. - Legacy public consent flows still hardcode document IDs (e.g.
consent: 2insubmitWhatsapp.ts). The campaign leadership flow does not: it requiresConsent.key = 'lideranca-autopreenchimento'and fails closed when missing. Migrating the older public flows remains separate work. Post/Tagship the news system, but there is still noPagescollection for institutional content. Partly resolved: thepost/tagcollections 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 (HomePageglobal still exposes only a singleimagefield — the text lives insrc/app/(frontend)/(home)/page.tsx), and there is noPagescollection yet for institutional content such as the bio and propostas.- Campaign LGPD consent texts are 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. The C2 supporter registry (merged tomain, production-blocked) additionally requiresConsent.key = 'apoiador-cadastro'andConsent.key = 'apoiador-intencao-voto'(vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (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. Still on 2026-07-18, A2 shipped: static Bahia municipality↔TSE zone map (src/lib/bahiaTseZones.ts from TSE 2024 detalhe_votacao_munzona BA) plus opt-in territory/zone suggestion chips in the nucleus form (territorySuggestions, NucleusTerritoryAndZonesFields) — no migration, no forced equality on save. Also the same day: C2 Cadastro nominal de apoiadores (supporter join Contact↔campanha with optional nucleus, migration 20260718_222656_add_supporter with UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id), consent keys apoiador-cadastro / apoiador-intencao-voto resolved via a generalized campaignConsent.ts that fails closed, /campanha/apoiadores vertical with list+KPIs/detail/CSV-import wizard; phone required in v1, lideranca has no access to the area; production use with real data still waits on Consent keys + legal sign-off). Also the same day: C3 Planos de Ação (actionPlan + /campanha/planos, migration 20260718_222832_add_action_plan, blocos "Próximos eventos"). Also the same day: B2 map foundation — static TopoJSON for Bahia municipalities + identity territories, bahiaMunicipalityCodes, helpers in bahiaGeometries, re-runnable pnpm build:geometries (no migration, no UI; Leaflet is B3). Post-/simplify scale follow-ups for that delivery are registered as B5 (docs/plans/escala-dry-pos-b2.md). 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).
Recently resolved (2026-07-18 evening / 2026-07-19): A4 Baseline no produto + Gap vs 2022 shipped and merged to main — getNucleusElectoralBaseline / loadNucleusBaseline2022Overview aggregate TSE 2022 by nucleus geography; computeGapVs2022 + NucleusInsights on the overview tab; NucleusElectoralBaseline card (candidate / president / governor via BASELINE_TICKET_2022); "Baseline 2022" card on the filtered nuclei-list overview. No migration (reads existing A3 collections). A /simplify pass applied targeted cleanup (role-generic ticket config, per-city zoneNumber in […], shared geography helpers). Scale/DRY debits larger than cleanup (aggregate federal ranking on detail instead of loading all nominal rows, filter by TSE cityCode, Alert confirmed + Progress reuse) are registered as A7 (docs/plans/escala-dry-pos-a4.md).
Recently resolved (2026-07-19): C6 Escala e DRY pós-C2 shipped and merged to main — bulk supporter import via payload.db.drizzle inside the existing Payload transaction (supporterImportBulk.ts, ON CONFLICT DO NOTHING on (contact_id, nucleus_id), 500-row chunks), skipContactPhoneInvariant opt-in on Contact.enforceUniqueContactPhone (fail-closed: only honored inside an active req.transactionID), single-SQL KPI aggregate (supporterListOverviewAggregate.ts, COUNT(*) FILTER mirroring buildSupporterListWhere + access constraint), HMAC-SHA256 single-use import token + ephemeral supporterImportBatch staging collection (migration 20260719_011015_add_supporter_import_batch, TTL 10 min, actor-bound, consumed on confirm), and shared shells (campaignListUrl.ts, CampaignListPagination, campaignFormFields.ts, mapCampaignFormActionError.ts) replacing the three per-list pagination components. A /simplify pass applied targeted cleanup (identity maps, dead branches, relationshipId/canManageCampaignUsers reuse, native base64url, rowCount ?? 0 bug fix). Scale/DRY debits the simplify reviewers flagged as larger than cleanup (locks in 1 round-trip, .returning(), pg_trgm, shared drizzleBulk.ts, column-name assertion, migrating the remaining formActions) are registered as C8 (docs/plans/escala-dry-pos-c6.md). Production use with real supporter data still waits on the Onda 0 legal batch (Consent keys apoiador-cadastro / apoiador-intencao-voto).
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 fromtitlewhen left empty),type(select enumnoticia|campanha|artigo|evento),category(required single relationship →tag),tags(hasManyrelationship →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 fromname), andhidden(checkbox, admin label "Esconder", defaultfalse). Tags are the shared taxonomy for bothpost.categoryandpost.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/<slug>), 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 <slug>.<ext> 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):
# News (`posts` tag — default)
curl -X POST "https://<prod-domain>/api/revalidate" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`)
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
- Auth: send the secret via
x-revalidate-secretorAuthorization: Bearer <secret>; it is compared toREVALIDATE_SECRETwith a constant-time check. REVALIDATE_SECRETmust 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.- Tag: optional
?tag=query or JSON body{ "tag": "..." }(query wins). Allowlist:posts(default),global_privacy-policy. Unknown tag →400. - Responses:
200 { revalidated: true, tag: '...' }on success;401if the secret is missing/wrong;500ifREVALIDATE_SECRETis 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.
campaignUsercollection (src/collections/CampaignUser.ts) — a separate Payload auth collection withname, optional contactphone, and requiredrole:geral,coordenador, orlideranca(default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone asusername. It is notusers:admin.userremainsusers, so campaign users cannot log into/admin.- Isolated session cookie
campaign-token. The flow sets its own httpOnly cookie namedcampaign-token(constantCAMPAIGN_TOKEN_COOKIEinsrc/utilities/campaignAuth.ts), scoped topath: '/campanha',sameSite: 'lax', andsecureonly in production. It is deliberately NOT the defaultpayload-tokencookie — 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 thecampaign-tokencookie, callspayload.auth({ headers }), accepts onlyuser.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).loginCampaignvalidates input withcampaignLoginSchema(src/lib/schemas/campaign-login.ts), callspayload.login({ collection: 'campaignUser' }), and — because the Local API returns a token WITHOUT setting a cookie — sets thecampaign-tokencookie itself (using the collection'sauth.tokenExpirationasmaxAge) before redirecting to/campanha.logoutCampaignclears the cookie (maxAge: 0) and redirects to/campanha/login. - Route layout. The route group's root layout
src/app/(campaign)/layout.tsxrenders the<html>/<body>withdata-theme="campaign". The public login lives atsrc/app/(campaign)/campanha/login/(page.tsx+ clientLoginForm.tsx). Everything gated sits in the(app)group, whose layout (src/app/(campaign)/campanha/(app)/layout.tsx) callsgetCampaignUser()andredirect('/campanha/login')when there is no session — that layout is the barrier. - Migrations.
20260716_010420_add_campaign_usercreated the auth collection; the unpublished 2026-07-17 campaign chain was consolidated into20260718_010733_consolidate_campaign_schema, which adds role/username auth, contact phone, and the remaining final campaign schema.
Campaign Praças model (2026-07-20 remodel)
/campanha is organized around plaza ("Praça") — the campaign's PREDEFINED operational territory. Master plan: docs/plans/remodelagem-pracas.md.
- Praça definition: 436 seeded rows — one per Bahia municipality (415), except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171). TSE zones shared across municipalities (141, 162) or leaking outside the RMS (128, 185, 200) are cut at the municipal boundary, so every Praça maps to exact
cityCode × zoneselection cells. The canonical catalog issrc/lib/plazaCatalog.ts(slug/name identity frozen bytests/fixtures/plaza-catalog.snapshot.json); the migration20260721_020109_remodel_plazasseeds it. Nobody creates/edits plaza geography in the app — identity fields are admin-only; staff edit only strategy fields (goals, priority, intel, political trend) and the coordinator assignsadvisors. - Collections (admin group
Campanha):plaza(strategy + advisors + manualpoliticalTrend),leadership(ONE per person:contactUNIQUE +plazashasMany +organizationshasMany + staff-onlysupportStatus),votePledge(leadership×plaza UNIQUE:declaredVotesleader-visible vsestimatedVotesstaff-only),organization(sindicatos/associações/movimentos with reverse leadership + actionPlan views),campaignDemand(leader-opened workflowaberta → em_analise → escalada → aprovada|rejeitadawith staff-onlycost/receipts),plazaUpdate(immutable field reports maintainingplaza.lastUpdateAtunder advisory lock),campaignInvite(unchanged: single-use SHA-256 hashes hanging offleadership), plus adaptedsupporter(optionalplaza) andactionPlan(singleplaza,advisors,organizations,deputyPresent,resultSummary/resultMedia). - Roles:
coordinator("Coordenador Geral") sees/manages everything and is the only one who assigns advisors and decides escalated demands;advisor("Assessor") sees and manages ONLY plazas listed inplaza.advisors(list, map, dashboard, leaderships, pledges, demands are all scoped);leader("Liderança") sees only plazas linked through their engaged leadership, declares own votes, opens demands, and writes own updates. - Vote pledge asymmetry (product-critical): the leader INFORMS
declaredVotesper linked plaza and may update it; staff recordestimatedVotes/estimateNote, which field access denies to leaders (same technique assupportStatus). Leader-facing view models never aggregate estimates; staff aggregates useestimatedVotes ?? declaredVotes(src/utilities/votePledgeData.ts). Guarded bytests/int/campaignVotePledge.int.spec.ts. - Political trend: manual conjuncture reading on the plaza (staff-only group
politicalTrend:favoravel|neutra|desfavoravel+ note + derived author/date). The numeric series is deliberately labeled "Evolução de votos" in the UI to keep "tendência" for the political reading. - Elections intelligence: plaza geography → election cells via
src/utilities/plazaElectionGeography.ts; baseline + Solla series 2014/2018/2022 (plazaElectoralBaseline.ts); multi-candidate comparison table on the plaza detail (plazaCandidateComparison.ts, up to 5 candidates via?compare=); map (plazaMapData.ts+PlazaMapPanel) colors municipalities by year (2026 = pledge estimates) and has a diverging compare mode (campaign red#c51414where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole). - URLs:
/campanha/pracas[/slug](seeded slugs from the catalog),/campanha/liderancas[/id],/campanha/organizacoes[/slug],/campanha/demandas[/slug],/campanha/planos,/campanha/apoiadores. Route params stay English keys; slugs/enums stay pt data values. - Invites: unchanged mechanics (hash-only persistence,
NEXT_PUBLIC_SITE_URLorigin,no-storepublic pages, login invites only forengajado); advisor scope now follows leadership↔plaza intersection. Consent still resolves only by stable keylideranca-autopreenchimento(fail closed). - PWA (D1): unchanged — installable
/campanha, versioned cache, offline fallback; push is still D2.
Campaign supporters (C2)
Nominal supporter registry backing the campaign's base of declared supporters (roadmap item C2; engineering ready and merged to main on 2026-07-18, production-blocked on the legal batch). One collection in admin group Campanha:
supporter(src/collections/Supporter.ts) — joinContact↔campaign with optionalplaza(transversal base; aggregates by Praça when linked). Fields:contact(required, indexed),plaza(optional, indexed),voteIntention(selectcerto | tende_a_certo | indeciso | outro, indexed — sensitive data, field access restricted tocoordinator/advisor), paired consent fields for registration and vote intention (consent/consentContentHash/consentedAtandvoteIntentionConsent/voteIntentionConsentContentHash/voteIntentionConsentedAt),source(import_csv | manual | convite | evento; v1 writes only the first two),consentNote,notes(staff-only),createdBy(readOnly). Uniqueness:UNIQUE NULLS NOT DISTINCT (contact_id, plaza_id)(Postgres ≥15) via migration20260721_020109_remodel_plazas. AbeforeChangehook setscreatedByand rejects acontactwhose leadership is already linked to the sameplaza.- Consent by stable key.
campaignConsent.tswas generalized togetConsentByKey/requireConsentByKey(fail-closed). The two keys areapoiador-cadastro(declared support) andapoiador-intencao-voto(vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern aslideranca-autopreenchimento. - Access.
coordinatorsees/manages all;advisorsees supporters of administered plazas (plaza in getAccessiblePlazaIds); supporters without a plaza arecoordinator-only;leaderhas no access to the area. Delete is admin-only. Access helpers live incampaignAccess.ts(canReadSupporter/canManageSupporter/canCreateSupporter). - Server actions (
src/app/(campaign)/campanha/actions/supporter.ts):createSupporter,setSupporterVoteIntention,previewSupporterImport/confirmSupporterImport(CSV,coordinator-only, transactional withreq: { transactionID }, dedup by phone viacontactPhoneInvariant), andremoveSupporterData(deletes thesupporter; anonymizes/clearsContactPII only if no other join —leadership/signature/subscription/othersupporter— references it). - UI
/campanha/apoiadores— list with KPIs (total / "Certo + Tende" / "Indecisos"), filters (vote intention, territory), detail with LGPD consent block + vote-intention segmented control (disabled until consent) + minimal share kit (wa.me+ copy text), and a 3-step CSV import wizard (Upload → Preview → Confirm). Phone is required in v1. - C6 scale layer (merged to
main2026-07-19). CSV import up to 5000 rows now completes in one transaction:confirmSupporterImportRecordacquires all phone advisory locks, thenbulkInsertSupporterImport(src/utilities/supporterImportBulk.ts) inserts contacts + supporters viapayload.db.drizzleon the Payload transaction session (getPostgresTransactionDatabase) in 500-row chunks withON CONFLICT DO NOTHINGon(contact_id, plaza_id).Contact.enforceUniqueContactPhonehonorscontext.skipContactPhoneInvariantfail-closed (only inside an activereq.transactionID), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the fullokset:previewSupporterImportTextstages it insupporterImportBatch(src/collections/SupporterImportBatch.ts, admin-hidden,coordinator/admin only, migration20260719_011015_add_supporter_import_batch) and returns an HMAC-SHA256 single-useimportToken(10-min TTL, actor-bound,src/utilities/supporterImportToken.ts);confirmSupporterImportRecordverifies the token and consumes the batch (delete after commit). The list KPI is a singleCOUNT(*) FILTERaggregate (src/utilities/supporterListOverviewAggregate.ts) mirroringbuildSupporterListWhere+ the access constraint, replacing 3×payload.count. Shared shells —campaignListUrl.ts,CampaignListPagination,campaignFormFields.ts,mapCampaignFormActionError.ts— replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the/simplifypass are registered as C8 (docs/plans/escala-dry-pos-c6.md). - Mandatory production blocker: do not import real supporter data or capture vote intention until electoral counsel documents the LGPD art. 11 basis (vote intention is sensitive data), approves the versioned consent texts, and an admin creates
Consent.key = 'apoiador-cadastro'andConsent.key = 'apoiador-intencao-voto'. The app intentionally fails closed when these keys are absent.
Campaign action plans (C3)
Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to main on 2026-07-18). One collection in admin group Campanha:
actionPlan(src/collections/ActionPlan.ts) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONEplaza, with task checklist, update feed and result record. Fields:title, canonical immutableslug,kind/statusenums in pt-BR,startAt(required beyondrascunho),plaza(required) + free-textlocality,organizations(hasMany — supporting orgs),advisors(staff responsible; ex-coordinators),deputyPresent(checkbox "Deputado presente"),responsible→Contact,leadershipoptional,tasks/updatesarrays (derived fields server-side), and the action result:resultSummary+resultMedia(upload →media) + derivedresultRecordedBy/At— queryable history for future activities in the same Praça. NoConsent(internal staff data).- Access.
coordinatoreverything;advisorwhere listed inadvisorsOR the plan'splazais administered (may create, auto-included);leaderwhereleadership∈ engaged — write limited to togglingtasks.doneand appendingupdates. Domain writes go throughwithPayloadTransaction(src/utilities/payloadTransaction.ts). - UI
/campanha/planos— list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, plaza), cards with kind/status badge + date/time + Praça + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações; result form appears for staff when the plan isrealizado.
Campaign map geometries (B2)
Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). No migration, collection, Consent, or server action — same static-data pattern as bahiaTerritories / bahiaTseZones.
- Artifacts (committed):
src/lib/geometries/bahia-municipalities.topo.json(~132 KB; Topology objectmunicipalities, 417 features,properties: { codarea, name }) andsrc/lib/geometries/bahia-identity-territories.topo.json(~15 KB; objectterritories, 27 features,properties: { code, name }). Soft size budget in tests: ≤ ~600 KB each. - Code table:
src/lib/bahiaMunicipalityCodes.ts— canonical municipality name → IBGE 7-digitcodarea, pluscodeForMunicipality/municipalityForCode. Fixturetests/fixtures/bahia-municipality-codes.official.json+ int tests for bijection/coverage. - Runtime helpers:
src/lib/bahiaGeometries.ts—getMunicipalityFeature/getTerritoryFeature, typed topologies, feature arrays. Eagerly decodes TopoJSON→GeoJSON at module load (lazy split is B5 F1 — prefer with B3). - Territory polygons: dissolved from IBGE municipality meshes via
bahiaIdentityTerritoryRecords(topojsonmerge), not the IDE Bahia shapefile (IDE remains provenance/validation reference only). - Rebuild:
pnpm build:geometries(scripts/build-bahia-geometries.mjs) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names withcanonicalizeMunicipalityName, simplifies/quantizes, emits the artifacts above. Cache underdata/geometries/(gitignored; override withGEOMETRIES_CACHE_DIR). Does not touch the database — noassertLocalDatabase/ no Neon risk. Not part ofpnpm buildorpnpm dev. Details:docs/plans/mapa-bahia-geometrias.md. Scale/DRY follow-ups from/simplify:docs/plans/escala-dry-pos-b2.md(B5).
Election baseline data (TSE 2022)
Public TSE 2022 results back the electoral baseline (roadmap items A3 + 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. Since the Praça remodel, the consumers are plazaElectoralBaseline.ts (per-plaza series/turnout), plazaCandidateComparison.ts (multi-candidate table) and plazaMapData.ts (map, incl. the diverging compare mode); ticket roles live in BASELINE_TICKET_2022. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope).
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
- Confirm
DATABASE_URLispostgresql://teqo:teqo@localhost:5432/teqoand.env.testpoints toteqo_test; never use Neon for development, tests, E2E, or local builds. - Run
pnpm db:start,pnpm migrate:status, andDATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status; every migration must showRan: Yes. - Run
pnpm generate:types,pnpm generate:importmapwhen components changed,pnpm exec tsc --noEmit,pnpm lint,pnpm test(unit + int),pnpm test:e2e(orpnpm test:all), andpnpm buildagainst the local database. Payload CLI scripts (migrate,generate:*,payload) and the build migrate step use--conditions=react-serversoserver-onlymodules load correctly outside Next.js. - 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.
- Review Vercel environment variables and migration SQL before deploy;
pnpm buildapplies pending migrations to production automatically. - 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. - Praça remodel deploy note: migration
20260721_020109_remodel_plazasis DESTRUCTIVE for the campaign vertical (drops nucleus-era tables, wipes campaign rows, convertscampaignUser.rolevalues, seeds 436 plazas). Review its SQL before the first production build that includes it; public-site data is untouched.
Core Principles
- TypeScript-First: Always use TypeScript with proper types from Payload
- Security-Critical: Follow all security patterns, especially access control
- Type Generation: Run
generate:typesscript after schema changes - Transaction Safety: Always pass
reqto nested operations in hooks - Access Control: Understand Local API bypasses access control by default
- 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 <name>, thenpnpm migratelocally. Never rely onpush(it isfalse). See thepayload-migrationsskill.
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, Supporter, SupporterImportBatch, ActionPlan, 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, bahiaTseZones.ts, bahiaMunicipalityCodes.ts, bahiaGeometries.ts, geometries/*.topo.json, territorySuggestions.ts, formData.ts, zod schemas (lib/schemas/*)
├── utilities/ # campaign*, nucleus*, posts, documents, globals, locks, etc.
└── payload.config.ts # Main config
Configuration
Minimal Config Pattern
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
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
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
// 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)
// ❌ 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
// ❌ 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
// ❌ 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
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
// 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
// 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
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
// 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
// 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
{
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
}
{
and: [
{ status: { equals: 'published' } },
{ featured: { equals: true } },
],
}
Getting Payload Instance
// 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 <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
}
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
#ExportNamesuffix orexportNameproperty - Default exports: no suffix needed
- File extensions can be omitted
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
#ExportNamesuffix orexportNameproperty - Default exports: no suffix needed
- File extensions can be omitted
Component Types
- Root Components - Global Admin Panel (logo, nav, header)
- Collection Components - Collection-specific (edit view, list view)
- Global Components - Global document views
- Field Components - Custom field UI and cells
Component Types
- Root Components - Global Admin Panel (logo, nav, header)
- Collection Components - Collection-specific (edit view, list view)
- Global Components - Global document views
- Field Components - Custom field UI and cells
Server vs Client Components
All components are Server Components by default (can use Local API directly):
// Server Component (default)
import type { Payload } from 'payload'
async function MyServerComponent({ payload }: { payload: Payload }) {
const posts = await payload.find({ collection: 'posts' })
return <div>{posts.totalDocs} posts</div>
}
export default MyServerComponent
Client Components need the 'use client' directive:
'use client'
import { useState } from 'react'
import { useAuth } from '@payloadcms/ui'
export function MyClientComponent() {
const [count, setCount] = useState(0)
const { user } = useAuth()
return (
<button onClick={() => setCount(count + 1)}>
{user?.email}: Clicked {count} times
</button>
)
}
Using Hooks (Client Components Only)
'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 <div>Hello {user?.email}</div>
}
Collection/Global Components
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
{
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):
{
name: 'refundButton',
type: 'ui',
admin: {
components: {
Field: '/components/RefundButton',
},
},
}
Performance Best Practices
-
Import correctly:
- Admin Panel:
import { Button } from '@payloadcms/ui' - Frontend:
import { Button } from '@payloadcms/ui/elements/Button'
- Admin Panel:
-
Optimize re-renders:
// ❌ BAD: Re-renders on every form change const { fields } = useForm() // ✅ GOOD: Only re-renders when specific field changes const value = useFormFields(([fields]) => fields[path]) -
Prefer Server Components - Only use Client Components when you need:
- State (useState, useReducer)
- Effects (useEffect)
- Event handlers (onClick, onChange)
- Browser APIs (localStorage, window)
-
Minimize serialized props - Server Components serialize props sent to client
Styling Components
import './styles.scss'
export function MyComponent() {
return <div className="my-component">Content</div>
}
// 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
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:
payload generate:importmap
Set custom location:
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})
Custom Endpoints
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
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
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
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
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
- Always set
overrideAccess: falsewhen passinguserto Local API - Field-level access only returns boolean (no query constraints)
- Default to restrictive access, gradually add permissions
- Never trust client-provided data
- Use
saveToJWT: truefor roles to avoid database lookups
Performance
- Index frequently queried fields
- Use
selectto limit returned fields - Set
maxDepthon relationships to prevent over-fetching - Use query constraints over async operations in access control
- Cache expensive operations in
req.context
Data Integrity
- Always pass
reqto nested operations in hooks - Use context flags to prevent infinite hook loops
- Enable transactions for MongoDB (requires replica set) and Postgres
- Use
beforeValidatefor data formatting - Use
beforeChangefor business logic
Type Safety
- Run
generate:typesafter schema changes - Import types from generated
payload-types.ts - Type your user object:
import type { User } from '@/payload-types' - Use
as constfor field options - Use field type guards for runtime type checking
Organization
- Keep collections in separate files
- Extract access control to
access/directory - Extract hooks to
hooks/directory - Use reusable field factories for common patterns
- Document complex access control with comments
Common Gotchas
- Local API Default: Access control bypassed unless
overrideAccess: false - Transaction Safety: Missing
reqin nested operations breaks atomicity - Hook Loops: Operations in hooks can trigger the same hooks
- Field Access: Cannot use query constraints, only boolean
- Relationship Depth: Default depth is 2, set to 0 for IDs only
- Draft Status:
_statusfield auto-injected when drafts enabled - Type Generation: Types not updated until
generate:typesruns - MongoDB Transactions: Require replica set configuration
- SQLite Transactions: Disabled by default, enable with
transactionOptions: {} - 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
-
payload-overview.md- High-level architecture and core concepts- Payload structure and initialization
- Configuration fundamentals
- Database adapters overview
-
security-critical.md- Critical security patterns (⚠️ IMPORTANT)- Local API access control
- Transaction safety in hooks
- Preventing infinite hook loops
-
collections.md- Collection configurations- Basic collection patterns
- Auth collections with RBAC
- Upload collections
- Drafts and versioning
- Globals
-
fields.md- Field types and patterns- All field types with examples
- Conditional fields
- Virtual fields
- Field validation
- Common field patterns
-
field-type-guards.md- TypeScript field type utilities- Field type checking utilities
- Safe type narrowing
- Runtime field validation
-
access-control.md- Permission patterns- Collection-level access
- Field-level access
- Row-level security
- RBAC patterns
- Multi-tenant access control
-
access-control-advanced.md- Complex access patterns- Nested document access
- Cross-collection permissions
- Dynamic role hierarchies
- Performance optimization
-
hooks.md- Lifecycle hooks- Collection hooks
- Field hooks
- Hook context patterns
- Common hook recipes
-
queries.md- Database operations- Local API usage
- Query operators
- Complex queries with AND/OR
- Performance optimization
-
endpoints.md- Custom API endpoints- REST endpoint patterns
- Authentication in endpoints
- Error handling
- Route parameters
-
adapters.md- Database and storage adapters- MongoDB, PostgreSQL, SQLite patterns
- Storage adapter usage (S3, Azure, GCS, etc.)
- Custom adapter development
-
plugin-development.md- Creating plugins- Plugin architecture
- Modifying configuration
- Plugin hooks
- Best practices
-
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