The conventions
What the rules are, and which rung of the enforcement ladder holds each one up.
Every rule below is followed by how it is enforced, because that is the part that decides whether a convention survives contact with a deadline.
The ladder, strongest first:
- A generator makes violating it unnecessary
- A type makes it impossible
- A database constraint makes it impossible
- A lint rule makes it fail fast, inside the edit loop
- A structure check catches what no single file can see
- A document — last resort, and an admission of defeat
Tenancy
Every tenant-owned table carries org_id and a matching row-level security
policy, written in the same migration by the generator. All request-path access
goes through withTenant(ctx, tx => …), which opens a transaction and sets the
current organization on it.
The raw pool client is not exported.
Enforced by: the database, plus no-unscoped-db and a structure check that
fails any table with tenantColumns and no policy.
The distinction worth internalising: other starters enforce tenancy by
convention — where(eq(t.orgId, orgId)), written by hand, every time. That holds
until someone forgets once. Here a forgotten scope returns zero rows, because
Postgres refused, and the generated test suite includes a case that writes a
deliberately unscoped query and asserts it finds nothing.
Authorization
Authorization lives at the data layer. Every query and mutation takes a
TenantContext as its first argument, and TenantContext is branded — its
type includes a unique symbol that is declared and never given a value, so no
code outside src/lib/auth/context.ts can produce one. Not even by writing an
object literal with the right fields.
Enforced by: the type system.
src/proxy.ts is redirect UX and not a security boundary. It checks whether
a session cookie is present, not whether it is valid. Its matcher lists the paths
that are public, so a new route is private by default and forgetting to update
it fails safe.
Never compare ctx.role to a string. Use can(ctx, 'thing.action') and declare
the grant in src/config/permissions.ts.
Enforced by: no-role-string-compare.
Mutations
Every mutation is a Server Action built with the action client:
export const createProject = action
.input(createProjectSchema)
.authed()
.can('projects.create')
.mutation(async ({ input, ctx }) => {
/* … */
});.mutation() does not exist until you have called .input() and chosen
.authed() or .public(); .public() additionally requires .rateLimit()
before .mutation() appears. So if it compiles, it is validated, it has an
explicit auth stance, and a public one is throttled.
Enforced by: the type system — a phantom-typed builder — plus
require-action-client.
This closes the Server Actions footgun properly. Every server action is a public POST endpoint whether its author remembered that or not; here, being public is something you have to type out.
Actions stay thin: validate, authorize, call mutations.ts, revalidate. They
return ActionResult and never throw to the client. Queries in Server Components
do throw — error.tsx catches those.
Structure
src/app/** is routing only — page.tsx, layout.tsx, loading.tsx,
error.tsx, route.ts. No components, no data access.
Feature code lives in src/features/<name>/, and other code imports it only
through the barrel. Deep imports are a lint error, and the reason is the upgrade
path: codemods can rewrite a module's internals safely precisely because nothing
reaches past its barrel.
Enforced by: eslint-plugin-boundaries, no-client-in-route-entry, and a
structure check for missing barrels.
State
Server Components by default; 'use client' only at leaves. Server state is RSC
and server actions. URL state is nuqs — anything bookmarkable belongs there.
Local state is useState.
Redux, MobX, Jotai, Recoil, SWR and React Context holding server data are banned.
Zustand is allowed in exactly one place: src/features/<name>/store.ts.
Enforced by: no-restricted-imports.
Errors
One taxonomy, in src/lib/errors.ts, each code mapped once to an HTTP status and
a log level. INTERNAL is the only code whose message is withheld from the
client, and it is the fallback for anything unrecognised — so a message leaks
only if someone deliberately chose a code that permits it.
Enforced by: the type system and no-raw-throw-across-boundary.
Design
Unusually for a lint preset, two rules cover how the product LOOKS. They are here because of a measurement rather than a preference: three products built on this template by three independent agents came out looking like the same unfinished app, and every automated gate stayed green throughout — because nothing measured how it looked.
Neither rule enforces taste, which is not enforceable. Both catch a call site reinventing something the template already ships:
- A raw
<select>while@/components/ui/selectsits unused. All three products did this, pasting the same class string character-for-character into eleven files. opacity-*as a text colour. It fades toward the background, so it lands on a different colour in dark mode than every other muted label, and it fades icons and badges inside the element too.text-muted-foregroundis a token both themes define deliberately. Interaction variants (hover:opacity-80) andaria-hiddendecoration are exempt.- A hand-rolled card —
rounded-lg border p-4isCardwith the padding guessed.Cardwas used zero times across three products and hand-rolled instead, each with slightly different padding, so no two panels in the same app lined up. Dashed and transparent borders are exempt: those are callouts and hover targets, not panels. - Ad-hoc formatting —
toLocaleDateString()in a component, or a hand-builtIntlformatter. Without a locale argument these use the RUNTIME's locale, so a laptop, CI and production can each render the same row differently and no test written on one machine catches it. Three products, three date formats.src/lib/format.tsis the one place allowed to build a formatter.
Turning these on found 17 violations in this template and 36 in the site you are reading, all of which were fixed in the same change. A rule that ships red is a rule that gets disabled.
Enforced by: use-design-system and no-ad-hoc-formatting.
The workflow
A brief becomes issues, an issue becomes a spec, a spec becomes a pull request — the pipeline that ships with the template, and the work that should never go through it.
Decisions
Why this codebase is shaped the way it is — the alternatives that were rejected, and, where there was one, the specific bug that settled the argument.