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:

TSX
<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".
  • section groups fields; sectionMeta gives 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:

TS
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

TS
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_id in URLs, registered in @/lib/public-id.

Structural values vs editable labels

TS
// 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:

TS
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

Terminal
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 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 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-only modules throw under tsx. Don't import them from a script.