Developer Guide

Getting oriented

Read CLAUDE.md in the repository root first — it is the short version of everything below, and it is what the codebase is actually held to.

Terminal
npm install
cp .env.example .env      # DATABASE_URL + JWT_SECRET
npm run db:init
npm run db:seed:master
npm run db:seed:school    # a demo school to work against
npm run dev

The rule that matters most

Reuse the building blocks. Forty admin screens are a list over a flat table. Writing one by hand means writing the loading skeleton, the delete confirmation, the error clearing and the validation again — and forgetting one of them.

Need Use
An admin list + edit screen CrudPage
A CRUD API over a flat table collectionHandlers / itemHandlers + a spec
A dialog / confirm / toast Modal · ConfirmModal · Toast
Any input @/components/form — never a raw <input>
Loading A layout-shaped skeleton

Adding a module end to end

1. Table and rules — a new file in src/lib/school/:

TS
let ready = false;
export async function ensureClubSchema() {
  if (ready) return;
  await sql`CREATE TABLE IF NOT EXISTS clubs ( … )`;
  await ensurePublicId("clubs");
  ready = true;
}

Register the table in @/lib/public-id if it will be addressable.

2. A spec in @/lib/school/admin-specs — the field list is the writable-column allowlist:

TS
export const clubSpec: CrudSpec = {
  table: "clubs", resource: "clubs", ensure: ensureClubSchema,
  fields: [
    { column: "name", type: "string", required: true, max: 120 },
    { column: "capacity", type: "int", min: 0 },
  ],
};

3. Routes:

TS
export const { GET, POST } = collectionHandlers(clubSpec);
export const { PUT, DELETE } = itemHandlers(clubSpec);

4. A screen:

TSX
<CrudPage title="Clubs" endpoint={EP.CLUBS} fields={…} columns={…} />

5. Register it — the resource in @/lib/permissions-catalog, the route in @/product/admin-routes, the nav item in @/product/admin-nav.

6. Regenerate the API reference — npm run gen:openapi.

Screens with real interaction — the timetable grid, the register, marks entry, fee collection — are hand-written. A generated form cannot express them.

Writing an app endpoint

TS
export async function GET(req: NextRequest) {
  try {
    const ctx = await parentContext(req);        // auth + scope, first
    if ("error" in ctx) return ctx.error;

    const rows = await sql`
      SELECT … FROM clubs WHERE student_id = ANY(${ctx.studentIds})`;
    return ok({ data: rows });
  } catch (e) { return serverErr(e); }
}

Never take a student id from the request and query on it. parentContext has already resolved and verified which ids this caller may see; scope on those.

Business rules go in the domain

A route that computes a fee, clamps a mark or decides who to notify is a rule in the wrong place. Put it in src/lib/school/* and call it — that is how the admin screen, the portal and the app end up with identical behaviour instead of three near-identical implementations.

Code style

  • Match the surrounding file — it is terse and densely commented where the why is not obvious.
  • TypeScript, avoid any. Client components start "use client";.
  • Imports use the @/ alias; group React → third-party → @/lib and @/components → local.
  • Components PascalCase, hooks useX, DB columns snake_case.
  • Wrap user-facing strings in t(...), interpolating with {name} placeholders.
  • Money in cents; format with formatMoneyCents.
  • Design tokens only — ink-*, slate-*, brand-*, font-display. No raw hex.

Verifying

Terminal
npx tsc --noEmit -p tsconfig.json
npm run lint
npx next build
npm run gen:openapi        # after adding or renaming routes

Do not claim a change is done before the typecheck passes.

Scripts

Put one-offs in scripts/*.ts. Load env with dotenv first, then import the database client dynamically so DATABASE_URL is populated before it initialises:

TS
import * as dotenv from "dotenv";
dotenv.config({ path: ".env" });
(async () => {
  const sql = (await import("../src/lib/db")).default;
  …
})();

Make them idempotent. server-only modules throw under tsx, so don't import anything that pulls one in.

Traps

  • The Neon HTTP driver cannot compose nested sql fragments. Build values in JS.
  • A backtick inside a sql template literal terminates it — never in a SQL comment.
  • Number(null) is 0, not NaN. Check presence before defaulting, or a threshold silently becomes zero.
  • A partial unique index needs its predicate repeated in ON CONFLICT.
  • Turbopack can 404 a neighbouring route after you add a folder in dev. Touch the file or restart.
  • Don't seed a real school's database with db:seed:school — it creates fictional children.