Repository navigation
Contributing Nextjs Guidelines
Vladimir Kromm edited this page Sep 17, 2026
·
5 revisions
Code-level conventions for Next.js/React/TypeScript in this repo. Companion to Contributing-UI-Guidelines (styling, components) and Contributing (process). Referenced from CLAUDE.md — binding for agents and humans alike.
- Public, crawlable pages (e.g. the preps catalog) are strict SSR for SEO:
page.tsxfetches everything via server actions (src/actions/*) and passes it down through props — no client-side fetching, the first render already has the content. - Authenticated/personal pages (prep creation, dashboard) can relax this: a self-contained client component may fetch its own data via a server action instead of threading it through props from
page.tsx, when that measurably cuts prop-drilling or complexity. Not the default — reach for it only when the plain page → props flow is actually getting in the way. - A component that fetches its own data uses a route-level
loading.tsxfor the loading state, not a hand-rolled spinner (see #79). - Mutations go through server actions, not API routes. Mutating server actions call
revalidatePath— the client receives fresh data through props on the next render, norouter.refresh()or mirroreduseStateneeded. - Client components receive server data through props and use it directly. Do not mirror props in
useState+useEffectto sync them — that duplicates the data flow Next.js already manages. Only hold local state for values the component mutates independently of the server (UI toggles, in-progress pipeline output, form input). - Shareable/bookmarkable page state (filters, tab, progress) lives in the URL query string, not client state.
- Internal navigation:
Linkfor links,useRouterfor imperative navigation,redirectfor server-side. Never a bare<a>for an internal route. - Images: always
Imagefromnext/image, never<img>.
-
src/actions/<entity>.ts— kebab-case,"use server", named exports. Export each action's return type alongside it. Owns the request boundary: auth, input validation, cache revalidation. Calls a service when there's real business logic; calls a repository directly only for a pure passthrough (fetch, list, insert with no logic attached). -
src/services/<entity>.ts— business logic that doesn't belong in a single repository call: calculations, orchestrating several repositories, business-specific mapping. Named exports, no framework imports (no"use server", nonext/cache) — services stay callable outside a request. -
src/repositories/<entity>.ts— Prisma queries only, no business logic. -
src/components/<domain>/Component.tsx— PascalCase, named export,"use client"when interactive. -
src/components/ui/*— business-agnostic primitives; check here before writing a new one (see Contributing-UI-Guidelines). - Route-specific client components live alongside their route's
page.tsx(e.g.app/catalog/CatalogPage.tsx). New pages follow thepage.tsx+ server-action flow.
- One pair per entity:
EntityBase(list/reference fields) andEntity extends EntityBase(detail view, full relations). Extend an existing pair instead of forking a near-duplicate for a 1–2 field difference (noPrepForCard/PrepForModal). - Fixed value sets are enums, never string literals. In this codebase that means an
as constarray plus a derived type (export const DISCIPLINES = [...] as const; type Discipline = (typeof DISCIPLINES)[number]) — not TypeScript'senumkeyword. Pair it with a type-guard function (value is Discipline) wherever an untyped string needs to become one, instead of asserting withas. - Never cast with
as. Narrow with a type guard, a validated parse, or a schema (zod) instead — anasasserts safety the compiler didn't check, and the two common spots it creeps in (a UI library callback handing back a rawstring, a URL query param) are exactly where the value is least trustworthy. The only exception is an isolated file whose entire job is wrapping a library's own types — not a call site inside otherwise-normal feature code.
- Fail fast: invalid state throws, it doesn't fall back to a default or an empty result unless naturally needed. A
catchthat swallows and returns[]ornullhides the bug it should surface. - No nested if-else — use guard clauses / early returns. If a function still needs nesting after that, extract a function.
- No silent default case on an unsupported enum/union value — handle it explicitly or throw.
- Minimize optional fields. An optional field is a claim that both the present and absent shape are valid — don't make it optional just because one code path happens not to set it.
- Optimize for readable and maintainable, not maximally efficient. Two simple, obvious Prisma queries beat one clever query that's hard to read, debug, or change later.
- Straightforward joins are welcome. Reach for a nested query or subquery only when the simple version is a measured problem, not on the chance it might shave off a few milliseconds.
- Prefer a framework built-in (
useState,FormEvent) over a library for anything simple enough to hand-roll. - Check
package.jsonfor an existing dependency that already does this before adding a new one.