Every pipeline agent (plan, impl, review, revise) must read this document before working. It defines the quality bar for this codebase beyond the conventions in CLAUDE.md. Plans must account for these standards per feature, implementations must follow them, and review findings may cite sections of this document the same way they cite the plan.
Stack context: Next.js App Router, Prisma, Tailwind CSS 4, shadcn/ui, Stack Auth (lib/auth/server.ts), TypeScript strict, zod, react-hook-form.
- Server-first. Every component is a server component until it provably needs interactivity, hooks, or browser APIs. Push
'use client'to the smallest leaf possible — a page with one interactive button is a server page importing a small client component, not a client page. - Data flows one way. Server components fetch via data-fetching functions in
prisma/data/; mutations go through server actions inprisma/actions/; client components receive data as props. No fetching in client components, no API routes outsideCLAUDE.md's allowlist. - Avoid
useEffect. In this server-first codebase almost everyuseEffectis a mistake — data fetching, deriving state from props, or syncing state all have better homes (server components, values computed during render, event handlers,keyto reset state,nuqsfor URL state). An empty-depsuseEffect(() => {…}, [])is essentially never correct here — it almost always hides fetching/initialization that belongs server-side, so treat it as a near-automatic review finding. UseuseEffectonly to synchronize with a genuinely external system (a non-React widget, a subscription, a DOM measurement) when there is no alternative, and justify it with a comment. - Composition over prop-drilling. If a prop passes through more than two layers untouched, restructure: pass
children, split the component, or fetch closer to where the data is used (server components make this cheap). - Co-location & layering. Route-specific components live next to their route; anything used twice moves to
components/. Server actions live inprisma/actions/, data-fetching queries inprisma/data/, grouped by domain (applications.ts, never a catch-allactions.ts). Shared types and constants are global —lib/types.tsandlib/constants.ts— not per-service files. - Abstract repetition, with judgment. Logic, UI, types, constants, or zod schemas duplicated across 2+ places get extracted to a single cohesive, intention-named home (
components/,lib/,prisma/{actions,data}/). Don't abstract a single use or force unrelated cases into one helper (no premature/over-abstraction). Prefer composition and small focused units. - Small files, one responsibility. A component file that needs scrolling to understand is two components. A service file mixing unrelated domains is two service files.
- Select what you render — but prefer reusing shared types. Default to
select/includewith explicit fields. However, prefer reusing an existing/abstracted query type even if it pulls slightly more data than a given view strictly needs — a little over-fetch is worth one reused type over many near-identical bespoke ones (see §1 abstraction). Hard limit: this never overrides the server/client boundary (§3) — never widen aselectto include sensitive, internal, or other-users' fields that reach a client component. - No N+1. Fetch relations with
include/nestedselectin one query, never by mapping over results and querying per item.
const applications = await prisma.application.findMany({
where: { cycleId },
select: {
id: true,
status: true,
submittedAt: true,
applicant: { select: { id: true, name: true } },
},
orderBy: { submittedAt: 'desc' },
});- Transactions for multi-step writes. Any mutation that writes more than one record (or reads-then-writes) runs in
prisma.$transactionso partial failure cannot corrupt state:
await prisma.$transaction(async (tx) => {
const application = await tx.application.update({
where: { id: applicationId },
data: { status: ApplicationStatus.SUBMITTED, submittedAt: new Date() },
});
await tx.auditLog.create({
data: { applicationId: application.id, action: AuditAction.SUBMIT, userId },
});
});- Schema discipline. Status-like fields are Prisma
enums following existing enum naming; foreign keys get explicit relations; fields queried inwhere/orderByat scale get@@index. Schema changes always come with the corresponding migration and are called out in the plan. - Application status audit trail. Every write that changes
Application.statusrecords anApplicationStatusEventin the same transaction — four paths do this today (submitApplication,updateApplicationStatus,updateApplicationStatuses,withdrawApplication); a fifth write path would otherwise silently skip the trail. - Validate at every boundary. Every server action parses its input with a zod schema before touching the database — even when the form also validates client-side. Client validation is UX; server validation is integrity.
EmailLogdelivery state is provider-reported and eventually consistent.sentmeans handed off to Resend, not received — no UI should treat it as proof of delivery.- One decision email ever, per application. Once a decision email (
application_accepted/application_rejected) reachessentor later, no later status change — any number of flips back and forth, single or bulk — may schedule or send another for that same application.lib/email/application-emails.ts's dispatch functions check this before every send; it is a permanent property of the application, not of the current status (docs/WORKFLOWS.mdXC-8).
- Every server action authenticates. First lines of every action: resolve the user and fail closed. Never trust IDs, roles, or ownership claims from the client.
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/auth/server';
import { prisma } from '@/lib/prisma';
const withdrawSchema = z.object({ applicationId: z.string().cuid() });
export async function withdrawApplication(input: unknown) {
const user = await getCurrentUser();
if (!user) throw new Error('Unauthenticated'); // unexpected from the UI → generic message (§4)
const parsed = withdrawSchema.safeParse(input);
if (!parsed.success) return { error: 'Invalid input' }; // user-facing → toast
// Authorization: scope the write to the caller — no IDOR
const result = await prisma.application.updateMany({
where: { id: parsed.data.applicationId, applicantId: user.id },
data: { status: ApplicationStatus.WITHDRAWN },
});
if (result.count === 0) throw new Error('Application not found for caller'); // shouldn't be reachable from the UI
revalidatePath('/applications');
// success: return nothing (or the updated record if the caller needs it)
}-
Authorization ≠ authentication. Being logged in is not permission. Every query/mutation is scoped to what the caller may see or change (
where: { ..., applicantId: user.id }or an explicit role check). Watch for IDOR: any action taking an ID must verify the caller's right to that specific record. -
Never hand-roll a role check — use
lib/auth/guards.ts(requireAdmin,requireManagerOrAdmin,requirePositionAccess,requireOwnership, and the*Or404page twins). A bareif (!user.isAdmin)in an action or page is a review finding; the onlyuser.isAdminreads outside that module are nav rendering and query scoping. -
Capability matrix. Manager status is derived, not stored — no
role/isManagercolumn exists onUser(prisma/schema.prisma);isManagercounts non-deletedPosition.managersrows (prisma/data/managers.ts). The full capability matrix, route/action gate tables, and the position/application state tables live inPERMISSIONS.md— read it, don't re-derive it here. -
Denial shape depends on the surface, and only on that. In a server action a guard throws — a denial is never user-facing, so it fails the §4 decision test and must never be returned as
{ error: 'Unauthorized' }/{ error: 'Forbidden' }. In a page a guard callsnotFound()— an authenticated-but-not-permitted page renders the same 404 as a missing one, so there is no existence leak and no silentredirect('/')to a page the user never asked for. -
Authenticate before touching the DB. Server actions are POST endpoints reachable by anyone holding the action id. Resolve the user first — an existence check or stale-link lookup placed ahead of the guard turns the action into an anonymous existence oracle.
getCurrentUserisReact.cached, so an early call costs a later guard nothing. -
A thrown denial needs a client-side
catch. It rejects the caller's promise and never reaches the global error boundary on its own, so every call site wraps the action intry/catchand toasts the generic "Something went wrong. Please try again." — never the thrown message. Log the error so a real bug stays distinguishable from a stale-permission denial. -
redirect()is routing, not denial. Onboarding gates, resource-state bounces, and signed-out routing may still redirect; annotate them so they aren't mistaken for authorization. -
No mass assignment. Never spread client input into
data:. Build thedataobject explicitly from parsed, whitelisted fields. -
Server/client boundary. No secrets, tokens, internal IDs beyond necessity, or other-users' data in props passed to client components. Anything serialized to the client is public to that user.
-
Dev-only code is env-gated. Anything like
prisma/actions/dev-bypass.tsmust be impossible to trigger in production (explicitNODE_ENV/env-flag guard). -
No raw SQL with interpolation. Use Prisma query builders; if
$queryRawis unavoidable, use tagged-template parameters only. -
Applicant-facing surfaces receive the public application status only.
Application.status's in-review detail (reached_out,interview_scheduled,reviewing) is reviewer-only data — collapse it throughPUBLIC_APPLICATION_STATUS(lib/constants.ts) in the query, not at render, so it never reaches an applicant's RSC payload. Type the fieldPublicApplicationStatuson every applicant-facing payload so passing the raw enum into one of those components is a compile error, not a review catch.
Every async surface ships all three states — loading, error, empty. A feature without them is incomplete, not minimal.
-
Loading: wrap slow server-component subtrees in
<Suspense>with a skeleton that matches the final layout (no layout shift on resolve). Route-levelloading.tsxfor whole-page fetches. -
Error: use one global error boundary —
app/global-error.tsx(root shell) plus a single route-grouperror.tsxwith a retry affordance — not a per-pageerror.tsxin every route folder. The boundary is only for unexpected render / data-fetch errors; expected errors never reach it. -
Action results & feedback. A server action either succeeds (returns nothing, or the relevant created/updated record) or returns
{ error: 'message' }for a user-facing condition — never{ ok }. If something unexpected happens, throw (don't return a message that shouldn't be shown). Every action gives toast feedback (sonner): a success toast; a specific error toast for a returned{ error }; a generic error toast when the action throws during an interaction; a render-time throw hits the global boundary instead. Never leak internals.Throw vs. return
{ error }— the decision test: "Would you show this exact sentence to the end user, and can they act on it?" Yes →return { error: '…' }. No →throw.- Return
{ error }— expected, user-actionable failures with safe copy: business-rule violations the user can resolve — e.g.'Email already registered','This position is closed','You've already applied','Cycle is locked'. (zod field errors usually surface inline on the form via the resolver, not as a returned{ error }.) throw— unexpected / internal / not user-actionable: a failed auth or authorization check (shouldn't happen behind a gated UI), a record that should exist but doesn't, DB / network / third-party failures, an unreachabledefault:/invariant, or anything whose message would leak internals. → generic toast (during an action) or global boundary (during render/data-fetch).- Gray area — "not found": reachable normally (a stale link to a deleted item) →
return { error: 'No longer available' }; not reachable for this caller (an IDOR-style miss) → throw.
- Return
-
Empty: zero-item lists render a designed empty state (icon, one-line explanation, primary action), not a blank container. (A secondary card on a detail page may opt into
SectionCardEmpty's compact variant — one muted line — instead; the default stays the roomy form.)
<Suspense fallback={<ApplicationListSkeleton />}>
<ApplicationList cycleId={cycle.id} />
</Suspense>;
// inside ApplicationList (server component)
if (applications.length === 0)
return (
<EmptyState
title="No applications yet"
description="Applications will appear here once submitted."
/>
);- Forms: pending state on submit (disabled button + spinner via
useFormStatusor react-hook-formisSubmitting), inline field errors from zod, preserved input on failure, success feedback (toast or redirect). Progressive enhancement where practical. - Mutations feel instant. After a successful action:
revalidatePath/revalidateTagalways; optimistic UI where the interaction is high-frequency (toggles, votes). - A domain email is a side effect, dispatched in
after(). The mutation it follows (a status write, a submission) has already committed by the time the email is attempted, so a failed send must never fail that mutation and must never surface to the actor —lib/email/application-emails.tsis the swallow site, with theEmailLogrow as the record that makes swallowing correct (docs/WORKFLOWS.mdXC-9).
A thrown error reaches Vercel's runtime logs with a stack and request context; a console.error string is a detached line with neither. Logging is not error reporting.
console.lognever ships in application code —app/,components/,lib/,prisma/actions/,prisma/data/. Debug logging is a local tool; it comes out before the PR.console.erroris not error handling. Catch-log-continue hides the failure from the user and leaves nothing actionable in the logs. The default for an unexpected failure is to throw — the error model above already routes it: generic toast during an interaction, global boundary during render/data-fetch.- Preserve the cause when rethrowing:
throw new Error('Failed to …', { cause: err })— never log the original and throw a bare new one. - Never
catchpurely to silence. An empty or log-only catch carries a one-line comment stating the invariant that makes swallowing correct (§7's comment bar applies); without it, it's a bug. - The permitted logging sites, and no others:
- Standalone scripts off the request/render path (
prisma/seed.ts) — progress output is fine. - Fail-open paths where degrading beats throwing (
lib/rate-limit.ts— a limiter that's down must not take the app down). The comment states why it cannot throw. - A client
catchthat also toasts — the §3-mandated wrapper around a server action, where the log keeps a real bug distinguishable from a stale-permission denial. Logging with no toast is not exempt. - A server-side cause the browser can't see (
prisma/actions/auth.ts) — log the upstream error, return{ error }with safe copy. - Best-effort side effects get no automatic exemption. Where the caller can retry (a webhook whose 500 is retried), throw —
lib/email/resend.tsstill does, for both the single send and the batch send. The one exemption is a domain email already dispatched fromafter():lib/email/application-emails.tsis the named swallow site, since the mutation it follows has already committed and theEmailLogrow is the record that makes swallowing correct.
- Standalone scripts off the request/render path (
- Semantic HTML first. Buttons are
<button>, navigation is<nav>, headings are hierarchical (h1→h2, no skips). ARIA only when no semantic element exists. - Keyboard. Every interaction works without a mouse: visible focus rings (never
outline-nonewithout a replacement), logical tab order, Escape closes overlays. - Focus management. Dialogs/sheets trap focus while open and return it to the trigger on close — shadcn/Radix primitives do this; don't break it with custom wrappers.
- Labels. Every input has an associated
<label>(shadcnFormLabel); icon-only buttons getaria-label; images get meaningfulalt(oralt=""if decorative). - Contrast & targets. Text meets WCAG AA contrast against its actual background; touch targets are at least ~44px on mobile.
- Cache deliberately. Know whether each fetch is static, revalidated, or dynamic — and say so in the plan. Use
revalidateTag/revalidatePathrather than opting whole routes out of caching. - Stream what's slow. Independent slow sections get their own
<Suspense>boundary so fast content paints immediately. - Ship less JS. Adding
'use client'to a large subtree, or a new dependency, is a bundle decision — justify it. Prefer server rendering + small client islands. - Images via
next/imagewith propersizes; no unoptimized<img>for content images. - Query cost. Paginate unbounded lists (
take/cursor); aggregate in the database (_count,groupBy), not in JS over full tables.
-
Strict TypeScript, no
any— including noas anyescapes. Unavoidable unknowns areunknown+ narrowing. Server action inputs areunknownuntil zod parses them. -
Exhaustiveness.
switchover enums/unions handles every case, with aneverdefault to break the build when a case is added. -
Prisma-generated types (
Prisma.ApplicationGetPayload<...>, generated enums) over hand-written duplicates that drift. -
Naming follows the neighborhood. Match the file's existing patterns for casing, ordering, and component structure before introducing anything new.
-
Comments are rare, one line by default. Default to none — name things so the code reads without help. Write one only where a reader fluent in the language and this codebase would still get it wrong: a non-obvious invariant, an external requirement, a deliberate exclusion, or a workaround for someone else's bug.
- One line. Two only rarely, when a single constraint genuinely cannot compress further (JSDoc
/**and*/delimiters don't count). Three is always wrong — restructure the code, or put the narrative in the PR body where it belongs. - Terse fragments, not sentences. Drop the subject, the hedging, and the connective tissue:
// Spoofable — server re-sniffs.not// Note that file.type is client-supplied and therefore spoofable, so the server checks it again. - No provenance. Never cite an issue, PR, review thread, or
ENGINEERING §ref.git blamelinks every line to its PR and its review permanently; a hardcoded#334only rots. - No narration. Never restate what the next line does; never label an obvious section.
- Function docs get the same bar. A JSDoc block only where the contract isn't clear from the name and signature, and no
@param/@returnsthat merely restate types.
// before — 5 lines, provenance ref, restates what the code already says // Sentinel used only for RadioGroup/RadioGroupItem value comparisons on the // virtual "Other" choice — kept distinct from OTHER_OPTION_LABEL so an // admin-authored option literally named "Other" can never collide with the // virtual choice (see PR #334 review). // after — the one fact a reader cannot infer // Distinct from OTHER_OPTION_LABEL so a real option named "Other" can't collide.
- One line. Two only rarely, when a single constraint genuinely cannot compress further (JSDoc
-
Leave it better, narrowly. Fix problems in code you touch when they affect your change; do not refactor unrelated code in a feature PR.
A scannable summary of the issues that recur in this codebase. impl builds to it, revise must not reintroduce these, and review uses it as its dimensions. Detail lives in the sections above.
- Server actions: authenticate, zod-parse input, scope writes to the caller (no IDOR). Return
void/relevant data on success,{ error }for user-facing failures,throwfor unexpected ones — never{ ok }. Decision test: would you show this exact sentence to the user, and can they act on it? yes →{ error }, no → throw. (§3, §4) - Feedback: every action shows a toast (
sonner) — success, specific error for{ error }, generic on an unexpected throw;revalidatePath/revalidateTagafter writes. (§4) - Errors: one global boundary (
global-error.tsx+ a single route-grouperror.tsx), never per-pageerror.tsx; expected errors are toasts, not the boundary. (§4) - Logging: no
console.login app code; unexpected failures throw (Vercel logs them with a stack and request context) rather than being logged and swallowed; everyconsole.errorsits on a documented fail-open path, a script, or a client catch that toasts. (§4) - Queries:
selectwhat's rendered but reuse sharedlibtypes (slight over-fetch OK; never sensitive/internal/other-users' fields to a client); no N+1;$transactionfor multi-step writes. (§2) - Async states: every async surface ships loading + empty (plus the error model above). (§4)
- Components: server-first;
'use client'only on the smallest leaf; nouseEffect(empty-deps especially); shadcn/Radix primitives, not hand-rolled raw elements; role-gate nav where the route is role-gated. (§1, §5) - Structure & conventions: server actions in
prisma/actions/, queries inprisma/data/; shared types/constants inlib/types.ts/lib/constants.ts(not per-service); abstract repetition sensibly (no over-abstraction); named exports only (except route files); no API routes outsideCLAUDE.md's allowlist; design tokens, never hardcoded colors; strict TS, noany. (§1, §7) - Hygiene: no dead scaffolding/shims/transitional re-exports; schema changes ship with their migration. (§1)
- Comments: rare, one line by default (two only rarely, never three); terse fragments, not sentences; no issue/PR/
§refs, no narration; JSDoc only where the signature doesn't already say it. (§7)
Current-behavior reference so agents don't code from stale training data. This repo is on Next.js 16.2.9, React 19. When in doubt about caching/rendering, fetch the canonical page (links below) rather than recalling — the model has changed across versions.
Fetch live docs:
nextjs.org/docs/llms.txtis a machine-readable index; most pages also have a.mdform. Allowlisted forWebFetchin.claude/settings.json.
next.config.ts does not set cacheComponents: true, so the previous caching model applies — not Cache Components / PPR. Do not write use cache, cacheLife, or cacheTag here; those belong to Cache Components, which is not enabled.
What that means in practice:
- Data is fetched in server components via data-fetching functions in
prisma/data/. Reading request data (cookies(),headers(),searchParams) opts a route into dynamic (request-time) rendering — expected for authed, per-user pages. - After a mutation, refresh caches explicitly with
revalidatePath(path)orrevalidateTag(tag)from the server action. This is the canonical pattern; always revalidate after a write. - Reference (previous model): https://nextjs.org/docs/app/guides/caching-without-cache-components and https://nextjs.org/docs/app/getting-started/revalidating
If you ever enable Cache Components (cacheComponents: true), the rules change substantially (PPR default, use cache/cacheLife/cacheTag, updateTag, "Uncached data accessed outside <Suspense>" build errors, connection() before non-deterministic ops). Read https://nextjs.org/docs/app/getting-started/caching first — and that is a deliberate architectural change, not a casual edit.
middleware.ts is deprecated in Next.js 16. The file convention is now proxy.ts and the exported function must be named proxy (not middleware):
// proxy.ts — at the project root (or src/)
export function proxy(request: NextRequest) { ... }
export const config = { matcher: [...] }- Rename
middleware.ts→proxy.ts - Rename
export function middleware→export function proxy - Type is
NextProxy(notNextMiddleware) - Official codemod:
npx @next/codemod@canary middleware-to-proxy . NextRequest,NextResponse,config.matcherare all unchanged
- Server vs Client Components — server by default;
'use client'only for interactivity/hooks/browser APIs, on the smallest leaf. Prisma/secrets never reach a client component. Ref: https://nextjs.org/docs/app/getting-started/server-and-client-components - Server Actions —
'use server', used for all mutations and formactions; progressive enhancement; validate input with zod; returnvoid/data on success,{ error }for user-facing failures,throwfor unexpected ones (§4). Ref: https://nextjs.org/docs/app/getting-started/updating-data - Streaming — wrap slow async server subtrees in
<Suspense>with a skeleton; use route-levelloading.tsxfor whole-page fetches; errors go through the global boundary (§4), never a per-pageerror.tsx. - Dynamic APIs are async in 16 —
await cookies(),await headers(), andparams/searchParamsare promises in page props. Don't access them synchronously. - Routing — route groups
(group), dynamic segments[id],generateStaticParamsfor static dynamic routes,generateMetadatafor metadata.
- App Router index: https://nextjs.org/docs/app
- Caching (Cache Components): https://nextjs.org/docs/app/getting-started/caching
- Caching (previous model — this repo): https://nextjs.org/docs/app/guides/caching-without-cache-components
- Revalidating: https://nextjs.org/docs/app/getting-started/revalidating
- Server & Client Components: https://nextjs.org/docs/app/getting-started/server-and-client-components
- Updating data / Server Actions: https://nextjs.org/docs/app/getting-started/updating-data
- Docs index for agents: https://nextjs.org/docs/llms.txt