Architecture

The shape of it

Code
                         ┌──────────────────────────────┐
   Visitors  ───────────▶│  Public site      /          │
                         ├──────────────────────────────┤
   Office / principal ──▶│  Admin panel      /admin     │
                         ├──────────────────────────────┤
   Guardians ──────────▶ │  Parent portal    /parent    │
   Teachers  ──────────▶ │  Teacher portal   /teacher   │  Next.js 16 App Router
   Students  ──────────▶ │  Student portal   /student   │  React 19 · TypeScript
                         ├──────────────────────────────┤
   Mobile apps ────────▶ │  REST API   /api/v1/*        │
                         └───────────────┬──────────────┘
                                         │
                    ┌────────────────────┼────────────────────┐
                    ▼                    ▼                    ▼
            ┌───────────────┐   ┌─────────────────┐   ┌──────────────┐
            │  PostgreSQL   │   │  Add-ons        │   │  Storage     │
            │  (Neon / any) │   │  payments·email │   │  R2 · S3 …   │
            └───────────────┘   │  sms · analytics│   └──────────────┘
                                └─────────────────┘

Everything is one Next.js application. The five surfaces are route groups, not separate deployments, which is why a circular written in the admin appears in the parent app without anything being synchronised.

Layers

Route handlers (src/app/api/**/route.ts) — auth first, then validate, then scope, then call the domain. They contain no business rules of their own.

Domain (src/lib/school/*) — one module per subject area, each owning its tables and its rules. generateInvoice, recordPayment, buildReportCard, markAttendance, convertApplication live here, not in routes. This is what lets the admin, the portal and the app share behaviour instead of re-implementing it three times.

Data (@/lib/db) — one tagged-template client. @neondatabase/serverless over HTTP in production, postgres.js locally.

UI (src/components) — CrudPage and the server-side CRUD factory are two halves of one spec: the field list drives both the SQL and the form, so they cannot drift apart.

Request flow

A parent opening their home screen:

Code
GET /api/v1/parent/feed
  │
  ├─ getGuardianId()        ── JWT or cookie; role must be "guardian"
  ├─ parentContext(req)     ── resolves their children; ?student= is VERIFIED, not trusted
  ├─ parentFeed(ctx)        ── eleven small indexed queries, in parallel
  │                            merged and cut in JS, ordered by timestamp
  └─ ok({ data, next_cursor })

The authorization decision happens once, in parentContext, and every query scopes on the ids it returns. No query in the feed reads a student id from the request.

Why one call, not six

A mobile home screen on a school's connection should not fan out to children + attendance + homework + fees + circulars + notifications and then wait for the slowest. /parent/dashboard and /parent/feed are each a single round trip made of small indexed queries. /parent/summary is deliberately separate and tiny — an app polls it on resume to refresh tab badges, and should not pay for the whole home screen to do it.

Schema provisioning

There are no migration files. Each domain module exports an idempotent ensure*Schema() that the routes touching its tables call first. New columns arrive as ADD COLUMN IF NOT EXISTS. The installer calls every one of them in a single pass so a fresh install has the whole domain provisioned before any screen is opened.

Add-ons

Storage, payments, email, SMS, analytics, chat and captcha are channels. A provider becomes usable when it has both an active connection (admin → Settings → Channels) and a registered driver. Credentials live encrypted in integration_connections, not in environment variables, so they travel with a database dump.

Payments illustrate the pattern: twenty gateway add-ons ship, each exporting a driver with method(), create() and verify(). The fee flow asks the registry which drivers have live connections and offers exactly those. Switching a school from one gateway to another is a settings change.

Security boundaries

Boundary Enforced by
Admin actions requirePermission(action, resource)
A guardian's children parentContext → studentIds
A teacher's sections teacherContext → sectionIds
Cross-role replay Role inside the JWT; mismatch is 401
Enumeration Opaque public_id; identical responses for unknown accounts
Money Amounts recomputed from the invoice, never read from the request
Result visibility Report card and exam must both be published

Deployment

A single Node process plus a PostgreSQL database. Vercel, a container, or a plain VPS all work. The only required environment variables are DATABASE_URL and JWT_SECRET; everything else is configuration held in the database.