This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
When working with frameworks in this project, reference the official LLM documentation:
- Next.js: https://nextjs.org/docs/llms-full.txt
- Payload CMS: No official llms.txt yet (requested in issue #13362)
When you need to understand Payload internals, reference the source code in node_modules:
- Core Payload:
node_modules/payload/dist/- Collections, fields, access control, operations - Admin UI:
node_modules/@payloadcms/ui/dist/- React components, forms, views - Next.js Integration:
node_modules/@payloadcms/next/dist/- Routes, handlers, middleware - Lexical Rich Text:
node_modules/@payloadcms/richtext-lexical/dist/- Editor, features, nodes - SQLite Adapter:
node_modules/@payloadcms/db-sqlite/dist/- Database operations
The code is readable ES modules with source maps. This matches the exact Payload version used in this project.
pnpm dev- Start development server with debugging enabledpnpm build- Build for productionpnpm start- Start production serverpnpm dev:prod- Clean build and start production server locally
pnpm bootstrap- Quick setup: creates DB with bootstrap@avy.com user and super-admin rightspnpm seed- Full database seed with all test data (uses shell script)pnpm seed:standalone- Faster standalone seed with all test data (recommended)pnpm reseed- Incremental update of changed seed datapnpm migrate- Run Payload migrationspnpm migrate:check [filename]- Check migrations for potentially destructive patternspnpm migrate:diff- Analyze differences between migration JSON snapshots
pnpm test- Run all tests (client and server environments)pnpm test:watch- Run tests in watch modepnpm lint- Run Next.js linterpnpm eslint- Run ESLint directlypnpm tsc- TypeScript type checkingpnpm prettify- Format code with Prettier
pnpm email:dev- Start React Email preview server on port 3001
pnpm generate:types- Generate Payload typespnpm generate:importmap- Generate Payload import map
- Framework: Next.js 15.5.9 (App Router)
- CMS: PayloadCMS 3.68.3
- Database: SQLite locally (WAL mode), Turso (libSQL) in production
- Storage: Vercel Blob
- Styling: Tailwind CSS with Radix UI components
- Email: Resend (production), Nodemailer/Mailtrap (development)
- Monitoring: Sentry, PostHog, Vercel Analytics
src/
├── app/ # Next.js App Router routes and API endpoints
├── collections/ # 27 PayloadCMS collections
├── blocks/ # 27+ content block components
├── components/ # React components (admin and frontend)
├── fields/ # Custom field configurations
├── access/ # RBAC access control functions
├── utilities/ # Utility functions organized by domain
├── globals/ # Global Payload CMS configurations
├── plugins/ # Custom Payload plugins
├── services/ # External service integrations
├── emails/ # React Email templates
├── migrations/ # Database migrations (90+ files)
docs/ # Architecture decisions and guides
__tests__/ # Jest tests (client and server)
The application serves multiple avalanche centers from a single codebase using dynamic tenant resolution:
- Middleware (
src/middleware.ts) intercepts requests - Edge Config Lookup - Primary method using Vercel Edge Config for fast global lookups
- Cached API Fallback - Falls back to
/api/tenants/cached-publicwith 5-minute cache - Request Rewriting - Rewrites URLs to inject tenant context
Two-level permission system:
- Global Roles - Super admin, cross-tenant access
- Tenant Roles - Scoped to specific avalanche centers
- Access Functions -
src/access/contains reusable access patterns - Escalation Protection - Prevents users from granting roles above their level
Key RBAC utilities:
src/access/byTenantRole.ts- Main access function (checks BOTH global and tenant roles)src/access/byTenantRoleOrReadPublished.ts- Allows public read of published contentsrc/utilities/rbac/ruleMatches.ts- Core rule matcher with wildcard supportsrc/utilities/rbac/hasGlobalOrTenantRolePermission.ts- Synchronous permission check for admin UI
Important patterns:
- Global roles are checked first and bypass tenant restrictions if matched
- Role data is saved to JWT (
saveToJWT: true) for synchronous access checks - Escalation checks only apply to REST API calls (Local API bypasses them for seeding)
- Rules use wildcards:
{collections: ['*'], actions: ['*']}= super admin - Tenant context comes from cookie via
getTenantFromCookie()
Read these docs for detailed guidance on specific topics:
/docs/coding-guide.md- Coding patterns and conventions (TypeScript, relationships, error handling, blocks)/docs/revalidation.md- ISR and cache invalidation strategy/docs/migration-safety.md- Automated checks for destructive migrations/docs/onboarding.md- Checklist for new tenant setup/docs/decisions/- Architectural decision records
- SQLite with WAL mode for local development
- Seed data includes multiple avalanche centers (NWAC, DVAC, SAC, SNFAC)
- Database file:
dev.db(with .shm and .wal files)
Localhost Subdomains (add to /etc/hosts):
127.0.0.1 dvac.localhost
127.0.0.1 nwac.localhost
127.0.0.1 sac.localhost
127.0.0.1 snfac.localhost
Admin Access: localhost:3000/admin
- Bootstrap user:
bootstrap@avy.com/password - Seeded users available with various role assignments
See .env.example for all available flags. Key ones:
LOCAL_FLAG_ENABLE_LOCAL_PRODUCTION_BUILDS=true- Enables local prod buildsLOCAL_FLAG_ENABLE_FULL_URL_LOGGING=true- Enhanced loggingENABLE_LOCAL_MIGRATIONS=true- Enable migration mode for testing
Dual Environment Setup (Jest config in jest.config.mjs):
- Client Tests -
__tests__/client/using jsdom environment - Server Tests -
__tests__/server/using node environment
These patterns are critical for secure Payload development. Violations can cause security vulnerabilities or data corruption.
// ❌ SECURITY BUG: Access control bypassed - user param is ignored!
await payload.find({
collection: 'posts',
user: someUser, // This does nothing! Operation runs with ADMIN privileges
})
// ✅ SECURE: Enforces user permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED when passing user
})
// ✅ Administrative operation (intentional bypass, no user)
await payload.find({
collection: 'posts',
// No user = admin operation, overrideAccess defaults to true
})Rule: When passing user to Local API, ALWAYS set overrideAccess: false
// ❌ DATA CORRUPTION RISK: Runs in separate transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
// Missing req = separate transaction, can cause orphaned data
})
},
],
}
// ✅ ATOMIC: Same transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
req, // Maintains atomicity - if parent fails, this rolls back too
})
},
],
}Rule: ALWAYS pass req to nested Payload operations in hooks
// ❌ INFINITE LOOP: update triggers afterChange which updates again
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
}) // Triggers afterChange again forever!
},
],
}
// ✅ SAFE: Use context flag to break the loop
hooks: {
afterChange: [
async ({ doc, req, context }) => {
if (context.skipViewCount) return
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
context: { skipViewCount: true },
})
},
],
}// Collection-level access CAN return query constraints (row-level security)
const collectionAccess: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
return { author: { equals: user.id } } // Query constraint OK here
}
// ❌ Field-level access can ONLY return boolean
{
name: 'salary',
type: 'number',
access: {
read: ({ req: { user } }) => {
return { department: { equals: user.department } } // WRONG! Will error
},
},
}
// ✅ Field-level access - boolean only
{
name: 'salary',
type: 'number',
access: {
read: ({ req: { user }, doc }) => {
return user?.id === doc?.id || user?.roles?.includes('admin')
},
},
}- Only add code comments when necessary - i.e. the code is not easy to understand and needs more thorough explanation
- Always add a code comment for regex expressions or string replacements
- Never use TypeScript type assertions / casting like
const someVar = val as SomeType. Write code so that TypeScript can infer the correct type. Type guards can be helpful when you might otherwise use a type assertion. - Prefer Payload's logger over
console.log - Prefer Tailwind utility classes over adding
.cssor.scssfiles - Use the
cn()utility for conditional class names
Always use the relationship helper utilities from src/utilities/relationships.ts:
import {
isValidRelationship,
filterValidRelationships,
isValidPublishedRelationship,
filterValidPublishedRelationships,
} from '@/utilities/relationships'Never cast relationship fields - they can be resolved objects, unresolved IDs, or null/undefined.
- Check if seed data needs updating - If you add/change fields, update the seed script to include appropriate test data
- Run the seed script - After updating, run
pnpm seed:standaloneto verify it completes without errors - Generate a migration - Run
pnpm payload migrate:createfor schema changes - Check migration safety - Run
pnpm migrate:checkto detect potentially destructive patterns - Review
/docs/migration-safety.mdfor guidance on safe migrations
- Consider revalidation - Reference
/docs/revalidation.mdto determine if revalidation hooks are needed - Add seed data - Add seed data to the seed script when the schema is finished
- Run the seed script - Verify
pnpm seed:standalonecompletes without errors - Generate a migration - Run
pnpm payload migrate:create - Update type generation - Run
pnpm generate:typesafter schema changes
- Follow the naming conventions in
/docs/coding-guide.md(e.g.,SingleButtonBlock,singleButtonslug) - Add new blocks to:
src/blocks/RenderBlocks.tsxsrc/components/RichText/index.tsx(if there's a Lexical variation)- At least one
blockstype field orrichTextBlocksFeatureso Payload generates types
Components should degrade gracefully when data is missing:
// Return null instead of crashing
if (!document || !isValidRelationship(document)) {
return null
}- Include tests with PRs when possible
- Client tests go in
__tests__/client/(jsdom environment) - Server tests go in
__tests__/server/(node environment)
When triggered via GitHub Issues (with @claude mention):
- Read the full issue description and any linked context
- Identify which files need to be modified
- Understand existing patterns in nearby code
- Create a new branch from
mainwith a descriptive name - Make focused changes that address the issue requirements
- Follow existing code patterns and styles in the codebase
- Run
pnpm prettify,pnpm tsc, andpnpm lintbefore committing
When asked to write a PR description, follow the template in .github/PULL_REQUEST_TEMPLATE.md and return the description in markdown.
- Write a clear PR title summarizing the change
- Follow the PR description template in .github/PULL_REQUEST_TEMPLATE.md
- Reference the issue number (e.g., "Fixes #123")
- Describe what was changed and why
- Note any decisions made or alternatives considered
Before marking work complete, verify:
- Changes address all requirements in the issue
- Code follows existing patterns in the codebase
- No TypeScript errors (
pnpm tsc) - No lint errors (
pnpm lint) - Tests pass if applicable (
pnpm test) - No unrelated changes included