Technical Documentation
Next.js 16 (App Router) · React 19 · TypeScript · Tailwind · PostgreSQL.
The golden rule
Reuse the building blocks. The admin has ~100 screens and forty of them are a list over a flat table; hand-writing each one produced the same 130 lines forty times, with forty chances to forget the loading skeleton or the delete confirmation.
| Need | Use |
|---|---|
| An admin list + edit screen over a flat table | CrudPage from @/components/school/CrudPage |
| A CRUD API over a flat table | collectionHandlers / itemHandlers + a spec |
| A dialog | Modal, or ModalBase + header/body/footer |
| A confirm | ConfirmModal / ConfirmDeleteModal |
| A toast | Toast (admin) / useToast() (portals) |
| Form inputs | @/components/form — never a raw <input> |
| Client validation | validateForm / hasErrors |
| Loading | A layout-shaped skeleton from @/components/ui/Skeleton |
Screens with real interaction — the timetable grid, the attendance register, marks entry, fee collection, promotion — are hand-written, because their value is in the interaction and a generated form cannot express it.
CrudPage
One spec drives both the SQL and the form, so the two cannot drift apart:
<CrudPage
title="Academic Years" endpoint={EP.ACADEMIC_YEARS}
fields={[
{ name: "name", label: "Name", required: true },
{ name: "start_date", label: "Starts", kind: "date" },
{ name: "is_current", label: "Current year", kind: "toggle" },
]}
columns={columns}
/>
- Under 12 fields the edit form opens in a modal, keeping the list on screen. Longer specs take a page. Override with
layout="modal" | "page". sectiongroups fields;sectionMetagives each group a heading, a subtitle, and optionally moves it to the side rail on the page layout.kind: "photo"uploads to storage and stores the URL — never a text box holding a link.- Opening an edit form hydrates from the item endpoint, not the list row. A list query selects the columns its table displays; seeding a form from it left the others blank and saving wrote those blanks back.
The domain layer
src/lib/school/ is the whole school. Each module owns its tables and its rules, and routes call into it rather than embedding logic:
const result = await recordPayment({ invoiceId, amountCents, paidByGuardianId });
if ("error" in result) return err(result.error);
recordPayment clamps to the outstanding balance and recomputes the invoice from the ledger. Because it lives here, the counter screen, the parent app and the gateway webhook all get the same guarantees.
API route shape
export async function POST(req: NextRequest) {
try {
const ctx = await parentContext(req); // 1. AUTH + SCOPE FIRST
if ("error" in ctx) return ctx.error;
const b = await req.json();
const name = String(b.name ?? "").trim().slice(0, 160); // 2. RE-VALIDATE
if (!name) return err("A name is required.");
const [row] = await sql` // 3. SCOPE THE QUERY
UPDATE students SET … WHERE id = ANY(${ctx.studentIds})`;
if (!row) return err("Not found", 404);
return ok(row);
} catch (e) { return serverErr(e); }
}
Non-negotiable:
- Auth is the first line. Admin:
requirePermission(action, resource). Portals:parentContext/teacherContext/studentContext. - Re-validate everything. The client's checks are UX only.
- Never trust a client amount. Recompute from the database.
- Parameterised SQL only. The tagged template, or
rawSql(query, params). - Opaque
public_idin URLs, registered in@/lib/public-id.
Structural values vs editable labels
// types.ts — structural. Renaming would break queries.
export type AttendanceStatus = "present" | "absent" | "late" | "excused" | "half_day";
// master_items — editable. Renaming must break nothing.
kind: "designation" | "department" | "house" | "leave_type" | …
The parent feed
src/lib/school/feed.ts merges eleven sources into one stream. Two decisions worth understanding:
Merged in JS, not by SQL UNION. The sources share nothing but a timestamp — different columns, joins and audience rules. A UNION would force them into one column list and cast everything to text. Each source instead runs as its own small indexed query for limit + 1 rows, and they are merged and cut in code. The cost is bounded by page size × source count, not by table size.
Paged by timestamp cursor, not offset. A merged feed has no stable row number: a circular published mid-scroll would shift every offset and duplicate a card.
Payments
A gateway is usable when it has both an active connection and a registered driver:
export interface PaymentDriver {
provider: string;
method(config): PaymentMethod; // the buyer-facing tile — never secrets
create(ctx): Promise<CreateOutcome>; // start
verify?(ctx): Promise<VerifyOutcome>; // confirm
}
The fee flow asks the registry which drivers have live connections and offers exactly those. Settlement credits the amount recorded when the payment started — never a number from the callback.
Verifying a change
npx tsc --noEmit -p tsconfig.json
npx next build
npm run gen:openapi # after adding or renaming routes
Traps specific to this codebase
- 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 explicitly before defaulting.- Turbopack can 404 a neighbouring route after you add a folder in dev. Touch the file or restart; it is not a code bug.
server-onlymodules throw undertsx. Don't import them from a script.