teqo-backup/AGENTS.md
Francisco Ignácio Fontoura Solla 4f0f997c32 Isolate local/dev and tests from the production database
Local dev and the test suite previously defaulted to the live production
Neon database, and running the tests mutated prod data. Add guardrails plus
a proper local Postgres + migrations workflow so production can't be hit by
mistake:

- docker-compose: local Postgres 17 (matches prod) + auto-created teqo_test
- dev guard refuses a non-local DATABASE_URL; tests refuse non-_test databases
- .env.test isolates the suite; vitest/playwright load it and fail closed
- Payload migrations set up (push disabled); build runs `payload migrate`,
  so deploys apply pending migrations to prod automatically
- baseline `initial` migration + idempotent consent.text varchar->jsonb fix
- `pnpm db:pull` copies prod content locally, excluding supporter PII
- docs (README, AGENTS.md) + agent skills for the local-db/migration workflow
2026-07-15 13:52:30 -03:00

32 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 + (soon) 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, (campanha) internal campaign tool (roles: assessores). No separate Rust service, no separate frontend app for /campanha — model it as Payload collections (it already has native point fields + near queries for the political map).
  • 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) referenced by relationship from Signature/Subscription. Any new form/opt-in flow should link to a Consent document the same way — don't invent a new consent mechanism.
  • 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.
  • Admin UI is organized via admin.group (e.g. 'Abaixo-assinados', 'Contatos', 'Configurações', 'Paginas') — 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).

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 <name> → 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).

Known Gaps (as of 2026-07-15 — resolve before relying on this file for onboarding new devs)

  1. Users has no roles field yet. The RBAC examples below are generic Payload patterns, not yet implemented here — every admin user today has full access. Add roles (e.g. admin/editor/assessor) before opening the admin to more people or building /campanha.
  2. Consent document IDs are hardcoded in code (e.g. consent: 2 in submitWhatsapp.ts). Fragile — breaks silently if that document is ever recreated with a different ID, and blocks having a reproducible local seed. Consider referencing by a stable slug/key instead. (The column-type drift is fixed; the hardcoded-ID fragility remains.)
  3. No Pages/Posts collections yet for institutional content (bio, propostas, notícias/imprensa). HomePage global today only has a single image field — most homepage content is still hardcoded in src/app/(frontend)/(home)/page.tsx. This is the biggest gap for reproducing jorgesolla.com.br content in Payload.

Recently resolved (2026-07-15): local Postgres now runs via docker-compose.yml (Postgres 17, matching prod, auto-creating teqo_test); Payload migrations are set up with prod baselined; dev/test database guards prevent accidental production access. See the two database skills.

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 <name>, 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
│   └── (campanha)/          # NOT YET CREATED — planned internal campaign-management area
├── collections/             # Collection configs (Users, Media, Petition, Signature, Consent, Contact, Subscription)
├── globals/                 # Global configs (SiteSettings, HomePage, Metadata)
├── components/              # Custom React components (incl. components/ui from shadcn)
├── lib/                     # cities.ts, zod schemas (lib/schemas/*)
├── utilities/                # documents.ts / globals.ts (revalidation helpers)
└── 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 #ExportName suffix or exportName property
  • 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 #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):

// 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

  1. Import correctly:

    • Admin Panel: import { Button } from '@payloadcms/ui'
    • Frontend: import { Button } from '@payloadcms/ui/elements/Button'
  2. 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])
    
  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

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

  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