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.
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/:
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:
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:
export const { GET, POST } = collectionHandlers(clubSpec);
export const { PUT, DELETE } = itemHandlers(clubSpec);
4. A screen:
<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
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 →@/liband@/components→ local. - Components
PascalCase, hooksuseX, DB columnssnake_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
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:
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
sqlfragments. Build values in JS. - A backtick inside a
sqltemplate literal terminates it — never in a SQL comment. Number(null)is0, notNaN. 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.