launch-quickly

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:

  1. A generator makes violating it unnecessary
  2. A type makes it impossible
  3. A database constraint makes it impossible
  4. A lint rule makes it fail fast, inside the edit loop
  5. A structure check catches what no single file can see
  6. 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/select sits 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-foreground is a token both themes define deliberately. Interaction variants (hover:opacity-80) and aria-hidden decoration are exempt.
  • A hand-rolled cardrounded-lg border p-4 is Card with the padding guessed. Card was 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 formattingtoLocaleDateString() in a component, or a hand-built Intl formatter. 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.ts is 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.

On this page