Guidance for AI coding agents (and human contributors) working in this repository. Read this before making changes — it documents the stack, conventions, known issues, and guardrails that are not always obvious from the code alone.
Personal website + blog + resume builder for daniel.heene.io, built on:
- Next.js 16 (App Router, React 19) —
app/contains both the public frontend (app/(frontend)) and the Payload admin panel (app/(payload)). - Payload CMS 3.x — configured in
payload.config.ts, content modeled insrc/collections/,src/globals/,src/blocks/,src/fields/. - MongoDB (via
@payloadcms/db-mongodb) as the primary database. - Redis — used as Payload's own KV store (
@payloadcms/kv-redis, no TTL support — seesrc/lib/redirects/redirectCache.tsfor the caching pattern to use instead when you need expiry) and viasrc/lib/RedisHandler.ts, a thin wrapper around theredispackage that provides app-levelget/set/invalidate(with real TTL) plus the pub/sub layer that powers Server-Sent Events (app/(frontend)/api/sse/route.ts). - S3-compatible storage (
@payloadcms/storage-s3, RustFS locally via Docker) for media. - Tailwind CSS 4 for styling, Storybook for component development.
- Oxlint + Oxfmt (the Oxc toolchain) for linting/formatting (not ESLint/Prettier/Biome).
- Bun as the package manager and script runner; Node.js still runs Next, Payload, the scripts and the tests.
See README.md for setup/run instructions; this file focuses on conventions and pitfalls.
bun install # install deps (Bun only — see packageManager in package.json)
bun run dev # runs Next.js dev server + Storybook in parallel
bun run dev:app # Next.js dev server only
bun run generate # regenerate Payload types + import map (run after changing collections/fields)
bun run build # production build
bun run lint # oxlint + oxfmt --check (does NOT auto-fix)
bun run lint:fix # oxlint --fix + oxfmt (auto-fixes what it can)
bun run format # oxfmt (formatting and import order only)
bun run test # unit tests (Vitest)
bun run deps:lint # syncpack: dependency version groups
bun run test:e2e # Playwright E2E (needs docker compose up -d)Environment variables come from Doppler, but no script wraps doppler run. bun run load-env
writes the active Doppler config to .env.local, which Next loads on its own, so every script
works unwrapped. Re-run it after changing anything in Doppler or switching configs — nothing
detects drift automatically, and bun run load-env --check reports it without writing. On the
deployment server Dokploy supplies the environment directly.
Always run bun run generate after adding/renaming a collection, global, field, or block — many
files import generated types from src/types/payload.ts and @/types/collections.
bun run lint fails only on errors; it prints pre-existing warnings (see "Known Issues" below).
Don't let those block your task, but do not introduce new lint errors or warnings, and prefer
fixing warnings in files you already touch.
Always spell scripts bun run <script>: bare bun test and bun build run Bun's own test runner
and bundler, not the package scripts.
Commits go through Lefthook (lefthook.yml): pre-commit runs oxfmt then oxlint --fix on
staged files (re-staging the fixes), commit-msg enforces Conventional
Commits. Use the existing types (feat, fix, chore, refactor, docs,
test, build, ci, perf, style, revert); scopes are unrestricted.
- 2-space indentation, LF line endings, 100-char line width.
- Single quotes for JS/TS, double quotes in JSX attributes.
- No semicolons (
semi: false, ASI style) — do not add trailing semicolons. - Trailing commas everywhere (
trailingComma: "all"). - Import order is sorted by oxfmt's
sortImports, in this group order:- Node/Bun builtins
react*/next*/payload*/@payloadcms/**- other npm packages
@/**path-alias imports- relative imports
- style imports
Run
bun run format(or your editor's Oxc integration) instead of manually sorting imports.
tsconfig.jsonhas"strict": false— do not rely on the compiler to catch null/undefined bugs; be explicit and defensive, especially in Payload hooks and access-control functions.import typevs. value imports is not enforced (typescript/consistent-type-importsis off) — either style is accepted, but preferimport typefor type-only imports for clarity when touching a file.
- Enum-like access: prefer dot notation (
CollectionSlug.Pages) over bracket notation (CollectionSlug['Pages']). Bracket notation is widespread in older code; don't add more of it. - Exports: prefer named exports (
export const X = ...) over default exports for components; this is the dominant pattern insrc/components/. - Barrel files: existing
index.ts/index.tsxfiles use eitherexport * from './X'orexport { X } from './X'— match whichever pattern already exists in the folder you're editing. - Hooks vs. utils naming: everything lives under
src/lib/now (see Architecture Notes), but the two hook flavors stay visually distinct — React hooks insrc/lib/hooks/are camelCase named after the hook (useIsMobile.ts), Payload lifecycle hooks insrc/lib/payloadHooks/are camelCase named after what they do (generateChecksum.ts). Plain utils use camelCase (generateSlug.ts). - Fields: folder name has no suffix (
src/fields/Slug/), but the exported factory function has anXFieldsuffix (SlugField,TitleField). Keep this pattern for new fields. - Globals: naming is currently inconsistent (
SiteSettings,SettingsGlobalUser,PDFGeneratorSettings). When adding a new global, prefer theXSettingssuffix pattern. - Avoid leftover
console.logdebug statements and large commented-out code blocks — several exist in the codebase (see Known Issues) but should not be added to. - Avoid
any; if you must use it, add an// oxlint-disable-next-line typescript/no-explicit-any -- <reason>comment with a real justification (seesrc/lib/resolveRelation.tsfor a good example), not a placeholder like<TODO>. - Comment style: a single non-obvious fact anchored to one specific line stays a plain
//right above that line — don't promote it to JSDoc just because it's explanatory. Reserve a leading/** ... */block for when the explanation genuinely spans multiple lines or applies to the whole function/conditional block, not a single statement. Either way, keep it short and precise: state the fact (the why/constraint/mechanism) and stop — no restating what the code already says, no narrative padding.
- Access control (
src/lib/access/): small, composableAccessfunctions (anyone,authenticated,authenticatedOrPublished,forbidden). There is no role/permission system —authenticatedonly checks "is any Payload user logged in". Treat every Payload user as fully trusted (single-admin trust model) unless you introduce roles explicitly. Read access follows the drafts setting: every schema withversions.draftsusesauthenticatedOrPublished, and nothing without drafts may (there is no_statusto filter on).src/lib/access/schemaAccess.test.tsenforces this. Local-API reads default tooverrideAccess: true, so fetchers that skip access must filter_status: 'published'themselves. src/lib/layout: domain subfolders group related code —access/(PayloadAccessfunctions),hooks/(React hooks),payloadHooks/(Payload collection/global lifecycle hooks — distinct fromhooks/, don't conflate the two),anthropic/andmapbox/(external API clients, same shape asunsplash/), plusactions/,fetchers/,date/,i18n/,jsonLd/,redirects/,references/,seed/,shiki/,sse/,umami/,unsplash/. A new external API client or hook goes in the matching subfolder; a handful of genuinely standalone utilities stay flat at the top ofsrc/lib/.- Collections/Blocks/Fields/Globals are factory-function based — most fields (e.g.
TitleField(),SlugField()) accept anoverridesobject rather than being edited in place. Reuse existing field factories instead of inlining raw Payload field configs when one exists. - Redis (
src/lib/RedisHandler.ts) exposesget/set/invalidate(cache) andpublish/subscribe(pub/sub, used by the SSE route). Keys/channels are plain strings — no namespacing helper exists yet; be consistent with existing key formats when adding new ones. - Revalidation: Payload collection hooks call
revalidate*helpers (e.g.src/collections/Pages/hooks/revalidatePage.ts) to invalidate Next.js cache tags after content changes — follow this pattern for any new collection that's rendered on the frontend. - Blog listings (
/blog,/blog/<topic>) paginate and sort via?page=/?sort=, read only inside the grid's Suspense boundary so the shell still prerenders.src/lib/blog/listing.tsowns the params (sort options, parsing, href building);proxy.tsuses it to redirect non-canonical forms (legacy/page/<n>segments,page=1, unknown sorts) and to mark non-default sortsnoindex. The RSS feed (/blog/feed.xml) andapp/sitemap.tscache under thepoststag, whichrevalidateBlogPostinvalidates. - Dashboard widgets (
src/widgets/): async server components registered inpayload.config.tsunderadmin.dashboard.widgets. Each widget has aslug,Componentpath, and optionalminWidth/maxWidth. Runbun run generateafter adding a new widget so its slug is included in the inferreddefaultLayoutunion type. Client-side widget parts live alongside as*.client.tsxfiles and call server actions fromsrc/lib/actions/for mutations. - Server actions (
src/lib/actions/):'use server'functions for admin mutations (job rescheduling, cancellation, etc.). UseuseTransitionon the client side when calling them.
Each push to main or develop runs its own next build against that environment's real secrets
(see build-and-deploy.yml) and ships straight into that environment's container — there is no
single compile deployed to several environments. Values the browser needs (site URL, status page,
Umami, the Iconify API) are therefore ordinary NEXT_PUBLIC_* environment variables, read via a
plain process.env.NEXT_PUBLIC_* reference; server-only values (e.g. SERVER_URL) are read the
same way without the prefix. src/types/environment.ts is the schema for the full set.
Task and workflow configs (tasks/, workflows/) are part of the Payload config and therefore of
the Next.js bundle; their handlers are not. Each handler lives in handlers/<name>.ts and the
config points at it with handler: handlerPath('<name>.ts') (Payload loads string handlers at run
time). A handler module must use req.payload instead of getPayload({ config }). The web process
only enqueues jobs (never runByID); the worker
(scripts/start-worker.mjs, polling every 5 seconds) runs them.
- SSE / Redis channels (
app/(frontend)/api/sse/route.ts): thechannelquery param is matched against an allowlist (src/lib/sse/channels.ts) beforesubscribe()ever sees it, and per-job channels (bilingual-translate:,seed-task:) additionally require a signed-inpayload.auth()session. The public channels inSSE_CHANNELS(e.g.service-status) stay unauthenticated by design. If you add a new channel that carries non-public data, add it to the authenticated branch rather thanSSE_CHANNELS— see the route's own comments for the exact split. - Raw SVG rendering:
src/collections/ResumeCustomersstores raw SVG markup rendered viadangerouslySetInnerHTMLinsrc/components/LogoCarousel/LogoCarousel.tsx. It is sanitized server-side on write by abeforeChangehook (src/lib/sanitizeSvg.ts, DOMPurify with an explicit tag/attribute deny-list) — the admin UI's client-sidesvgopass is a bypassable optimizer, not the security boundary. Route any other raw-HTML/SVG field through the samesanitizeSvghelper (or an equivalent real, server-side sanitizer) before it's ever rendered unescaped. CRON_SECRETis declared insrc/types/environment.tsbut not enforced anywhere — there is no cron route yet. If you add one, validate this secret explicitly (ideally withcrypto.timingSafeEqual, not!==).- Never commit real secrets. Configuration lives in Doppler, selected per clone with
doppler setupand written to a gitignored.env.localbybun run load-env;src/types/environment.tsdeclares the schema and is the only place the full set of variables is enumerated. The one committed env file is.env.test, which holds dummy, format-valid values for E2E runs. - New authenticated API routes should mirror
app/(frontend)/api/preview/route.ts's pattern of pairing a shared-secret check with a realpayload.auth()session check where feasible.
bun run lintpasses but reports pre-existing warnings, mostlyno-explicit-any,no-unused-vars,no-img-elementand the React Compiler rules (set-state-in-effect,purity,refs), whichoxlint.config.tskeeps atwarnuntil they are cleaned up.- Leftover debug
console.logs (e.g.src/collections/ResumeJobs/index.ts,src/blocks/ResumeDownloadsBlock/Renderer/Renderer.tsx) and commented-out dead code (e.g.src/collections/ResumeSkillTags/index.ts) exist and should be cleaned up opportunistically. package.jsonoverridespinspostcss-merge-rulesto9.0.4:9.0.6throwsRangeError: Invalid string lengthwhile cssnano minifies Payload's admin CSS in the production build. Lift the pin once a newer release builds cleanly (bun run build).- No app-level rate limiting exists on public API routes.
- No shared logging abstraction — error logging is ad hoc
console.errorcalls.
- Unit tests run via Vitest (
bun run test), E2E tests via Playwright (bun run test:e2e) — seeREADME.mdfor prerequisites. Co-locate unit tests as*.test.tsnext to the code under test; E2E specs live ine2e/*.spec.ts(never*.spec.tsundersrc/). - Shared mocks live in
vitest.setup.ts:payload(getPayloadstubbed viaimportOriginalspread — never replace the whole module, value imports from it are used at runtime),next/cacheandredis.tailwind-merge,date-fns,slugify,pupaandneotraverseare intentionally NOT mocked — their real behavior is the contract under test. TZ=UTCis forced invitest.setup.tsand date tests depend on it. Anything touchingInterval.setToX()orgetLocalISOStringneedsvi.useFakeTimers()+vi.setSystemTime(). Usevi.stubEnv()for env-dependent code (SERVER_URL,PREVIEW_SECRET).- There is no CI-enforced coverage threshold; when fixing a bug, add a regression test near existing tests for that module if a suitable test file already exists.
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.