Proof of concept · Demo · not production

Vercel frontend + Portal API + Neon PostgreSQL

The hosted Demo of the Raisd portals: four portal webs and the Portal API on Vercel (Singapore), with Neon Postgres used only by the API for sessions. Does not replace UltaHost + k3s (SDD-12 / ADR-2 / ADR-3).

Status: Demo deployed (28–29 Sep 2026) PoC only — no Live CMS writes Portals → Portal API only Neon free tier · Postgres 18
Not an ADR. Production remains UltaHost Singapore with k3s. The existing Cyberjaya CMS stays the system of record for Live writes (ADR-1). Do not present this PoC as Live campus architecture. Canonical agent text: docs/ai/architecture/vercel-neon-poc.md.

Live targets

ComponentURLRepoRuntimeDeploy path
Campus landing (Lesotho) raisd-campus-landing.vercel.app · planned lesotho.raisd.co campus-landing Static HTML chooser → Apply / Student / Staff / Teach Manual: npm run deploy from a local checkout
Student portal raisd-student-portal.vercel.app · planned student.lesotho.raisd.co student-portal Vite + React SPA, static prebuilt output GitHub Actions CI/CD on push to main
Applicant portal raisd-applicant-portal.vercel.app · planned apply.lesotho.raisd.co applicant-portal Vite + React SPA; HTTP Portal API when VITE_PORTAL_API_URL is set Manual prebuilt deploy (same CI/CD template; Actions billing may block CI)
Lecturer portal raisd-lecturer-portal.vercel.app · planned teach.lesotho.raisd.co lecturer-portal “Coming soon” placeholder until the repo has an app GitHub Actions CI/CD on push to main
Staff portal raisd-staff-portal.vercel.app · planned staff.lesotho.raisd.co staff-portal “Coming soon” placeholder until the repo has an app GitHub Actions CI/CD on push to main
Portal API raisd-portal-api.vercel.app portal-api Fastify in one Node 22 Vercel Function, region sin1, 30 s max Manual: npm run deploy:vercel from a local checkout (CI runs checks only)
Database No public URL — Neon Postgres 18, aws-ap-southeast-1, database neondb Provisioned via the Vercel Neon integration on the portal-api project
DB admin raisd-db-admin.vercel.app (team only) db-admin — read-only browse / SQL / ERD with CMS·LMS·shared colours Vercel Functions, sin1; read-only table browser + SQL Manual: npm run deploy from a local checkout (no CI/CD)
Docs & OpenAPI raisd-campus.github.io/portal-api-docs portal-api-docs GitHub Pages, site password gate GitHub Actions Pages on push to main

All Vercel projects live in one team and are not Git-connected: deploys only happen through the workflows or manual commands below, always as prebuilt output (vercel deploy --prebuilt --prod). Public routes are listed on the overview.

Target deployment

Portals still never talk to Postgres or the CMS directly. Only the Portal API (and the read-only DB admin) hold a connection string.

flowchart TB
  user(["Browser"])
  team(["Raisd team"])

  subgraph gh ["GitHub · raisd-campus"]
    repos["Portal repos
ci-cd.yml"] apirepo["portal-api
ci.yml (checks)"] pkg[("GitHub Packages
@raisd-campus/design-system")] pages["portal-api-docs
GitHub Pages"] end subgraph vercel ["Vercel team · Singapore sin1"] land["raisd-campus-landing
Lesotho chooser"] sp["raisd-student-portal
static SPA"] ap["raisd-applicant-portal
static SPA"] lp["raisd-lecturer-portal
placeholder"] stp["raisd-staff-portal
placeholder"] api["raisd-portal-api
Node 22 function · Fastify
Student + Applicant Demo engines"] dba["raisd-db-admin
read-only · sign-in"] end subgraph neon ["Neon · aws-ap-southeast-1"] db[("neondb · Postgres 18 · schema v4
record tables · 4 views · audit_events
student_portal_state · applicant_portal_state
portal_meta · poc_sessions · poc_applicant_sessions")] end subgraph prod ["Production — not part of this PoC"] cms[("Cyberjaya CMS
system of record · ADR-1")] ulta["UltaHost Singapore + k3s
ADR-2 / ADR-3 · SDD-12"] end user -- HTTPS --> land land --> sp land --> ap land --> lp land --> stp user --> sp user --> ap user --> lp user --> stp sp -- "PortalApi RPC · bearer
VITE_PORTAL_API_URL" --> api ap -- "ApplicantPortalApi RPC · bearer
VITE_PORTAL_API_URL" --> api pages -. "Swagger Try it out" .-> api api -- "DATABASE_URL (pooled, TLS)" --> db team -- "sign-in (session cookie)" --> dba dba -- "raisd_db_admin_reader
READ ONLY" --> db repos -- "npm ci" --> pkg repos -- "vercel deploy --prebuilt" --> sp team -- "npm run deploy:vercel" --> api sp -. never .-x db api -. no Live writes .-x cms classDef poc fill:#e8f0f8,stroke:#1a4f8b,color:#152033 classDef store fill:#eef7ee,stroke:#2f7d32,color:#152033 classDef out fill:#f6f6f6,stroke:#9aa5b1,color:#5a6a7e,stroke-dasharray:4 3 class land,sp,ap,lp,stp,api,dba poc class db,pkg store class cms,ulta out

Solid arrows are live traffic or deploys. Crossed dashed arrows are forbidden paths; the grey group is production, which this PoC does not touch.

Vercel projects

ProjectOutputRoutingEnvironment variables (names only)
student-portal npm run build:vercel → dist/ as .vercel/output/static Immutable cache on /assets/*; missing hashed chunk → 404; everything else → index.html VITE_PORTAL_API_URL baked in at build (build fails if unset, so MockPortalApi never ships)
applicant-portal npm run build → dist/ packaged by the workflow Filesystem, then SPA fallback to index.html VITE_PORTAL_API_URL (build time)
lecturer-portal · staff-portal Generated “Coming soon” page (no package.json yet) Static —
portal-api scripts/build-vercel.mjs: esbuild bundles src/vercel.ts plus the student and applicant Demo engines into functions/index.func All paths → the one function; Fastify routes inside (/v1/portal/:method, /v1/applicant/:method) DATABASE_URL / DATABASE_URL_UNPOOLED + PG* / POSTGRES_* (Neon integration), CORS_ORIGIN. Student and applicant demo passwords are inlined at build from gitignored credentials
db-admin Zero-build Vercel Functions (api/*.ts) + public/ icons / → UI function; noindex, no framing, no-store DATABASE_URL (reader role), DB_ADMIN_USER, DB_ADMIN_PASSWORD

CORS_ORIGIN on the API includes the *.vercel.app student and applicant SPAs, the planned Lesotho campus hostnames (student.lesotho.raisd.co, apply.lesotho.raisd.co, staff.lesotho.raisd.co, teach.lesotho.raisd.co), and https://raisd-campus.github.io for Swagger. Working Demo path today is *.vercel.app; custom DNS is attached on Vercel and waiting on Cloudflare CNAMEs. Add an origin there before another portal calls the API from a browser.

CI/CD pipelines

flowchart LR
  subgraph portals ["Portal repos · ci-cd.yml"]
    pr1["Pull request"] --> chk["Check
npm ci · lint · test · build"] push1["Push to main"] --> chk chk -- "main only" --> dep["Deploy
build Vercel output
vercel deploy --prebuilt --prod"] dep --> v1(["raisd-<repo>.vercel.app"]) end subgraph api ["portal-api · ci.yml"] pr2["PR / push to main"] --> smoke["Checkout api + student-portal + control-plane
npm ci · docs:check · boot + smoke"] dev(["Developer laptop"]) --> man["npm run deploy:vercel"] man --> v2(["raisd-portal-api.vercel.app"]) end subgraph docs ["portal-api-docs · pages.yml"] push3["Push to main"] --> lint["Redocly lint openapi.yaml
validate design-system pages"] lint --> pub["Assemble _site + site auth
deploy-pages"] pub --> v3(["GitHub Pages"]) end dev --> man2["npm run deploy"] --> v4(["raisd-db-admin.vercel.app"])
Repo · workflowTriggersChecksDeployRepo configuration
student / applicant / lecturer / staff portal · CI/CD (.github/workflows/ci-cd.yml) Pull request, push to main, manual Node from .nvmrc (default 22); npm ci with GitHub Packages auth; lint, test, build if present Only push/manual on main: build:vercel if defined, else build + SPA packaging, else placeholder; then vercel@60 deploy --prebuilt --prod. PR runs never deploy; main runs are never cancelled mid-deploy Secret VERCEL_TOKEN; variables VERCEL_ORG_ID, VERCEL_PROJECT_ID, VITE_PORTAL_API_URL. Design-system package grants each repo Actions read access
portal-api · CI (ci.yml) Pull request, push to main Checks out portal-api, student-portal, control-plane; npm ci; docs:check (mirrored docs in sync); boots the API with throwaway demo passwords and runs scripts/smoke.sh (health, meta, login, wrong password → 401, portal call, logout → token revoked) None — deploy is manual (npm run deploy:vercel) because the build needs the sibling student-portal and the gitignored demo credentials Secret RAISD_READ_TOKEN (read-only PAT for the private sibling checkouts)
portal-api-docs · Pages (pages.yml) Push to main, manual; PRs touching published paths run validation only Redocly lint of openapi.yaml; validate-design-system.py Assemble _site, verify required pages, inject site password, actions/deploy-pages Secrets DOCS_SITE_USER, DOCS_SITE_PASSWORD
control-plane · OpenAPI, Sync diagrams note Changes to openapi.yaml, docs/** Validates the contract and design-system pages None — canonical sources; copies are mirrored into portal-api-docs —
design-system · Package check and release Pull request, push to main, v* tags Docs-impact check, build, tests Publishes @raisd-campus/design-system to GitHub Packages on tags GITHUB_TOKEN with packages: write
db-admin — npm run typecheck locally Manual npm run deploy (vercel deploy --prod, remote build) Vercel env only

The portal workflow is one shared file: canonical copy control-plane/scripts/templates/portal-ci-cd.yml, copied verbatim into each portal repo. Deployment status across repos: npm run ci in control-plane.

Manual deploys

# Portal API (needs ../student-portal and ../control-plane/.credentials/demo-students.json)
cd portal-api
npm run deploy:vercel          # build:vercel + vercel deploy --prebuilt --prod --scope <team>
API_URL=https://raisd-portal-api.vercel.app npm run smoke

# DB admin
cd db-admin
npm run deploy                 # vercel deploy --prod --scope <team>

Neon database

PropertyValue
ProviderNeon (Vercel Marketplace native integration, attached to portal-api)
Engine · regionPostgreSQL 18 · aws-ap-southeast-1 (Singapore, next to Vercel sin1)
Databaseneondb
ConnectionsPooled endpoint for runtime (DATABASE_URL); direct endpoint for DDL / admin (DATABASE_URL_UNPOOLED); TLS with channel binding
Rolesneondb_owner — used by the Portal API. raisd_db_admin_reader — DB admin only: pg_read_all_data, default_transaction_read_only = on, no write grants
MigrationsNone: on first use the API seeds when portal_meta is missing or schema_version ≠ contract version 4 (keeps student state + audit). Applicant Demo state uses applicant_portal_state / poc_applicant_sessions. Creates four reporting views (academic-status membership joins campus policy). npm run db:reset drops and reseeds records (sessions kept)
DataDemo records for the four sample students (2,895 rows across 73 tables), their per-student portal state, and session rows — fictional data only, no CMS records

Schema

Every collection of the student-portal PortalRecordGraph is one table, named in snake case (studentProfiles → student_profiles): people, student profiles, programmes and versions, modules, offerings, registrations, class schedules and sessions, attendance, assessments and results, finance accounts, invoices, payments, funding, online forms, immigration, graduation, announcements, campus services and more. The record jsonb is the source of truth; every top-level field is also a read-only generated column so rows are readable in DB admin.

erDiagram
  RECORD_TABLE {
    text id PK "record id"
    integer position "fixture order"
    jsonb record "source of truth"
    timestamptz updated_at "last write"
    generated fields "one column per top-level field"
  }
  student_portal_state {
    text student_profile_id PK
    jsonb personal_profile
    jsonb resume_profile
    jsonb portfolio_profile
    jsonb finance_payment_proofs
    jsonb chat_store
    timestamptz updated_at
  }
  portal_meta {
    boolean id PK "single row"
    bigint revision "+1 per record write"
    timestamptz seeded_at
    text_array collections
  }
  poc_sessions {
    text token_hash PK "sha256 hex of the bearer token"
    text scenario_id "Demo student scenario"
    text student_profile_id "profile for the scenario"
    timestamptz created_at "default now()"
    timestamptz expires_at "created + 12 h (indexed)"
    timestamptz revoked_at "set on logout, else null"
  }
        
-- one per collection, e.g. invoices
create table if not exists invoices (
  id                 text primary key,
  position           integer not null,
  record             jsonb not null,
  updated_at         timestamptz not null default now(),
  finance_account_id text    generated always as (record->>'financeAccountId') stored,
  total_minor        numeric generated always as ((record->>'totalMinor')::numeric) stored,
  status             text    generated always as (record->>'status') stored
  -- … one column per top-level field
);

create table if not exists poc_sessions (
  token_hash         text primary key,
  scenario_id        text not null,
  student_profile_id text not null,
  created_at         timestamptz not null default now(),
  expires_at         timestamptz not null,
  revoked_at         timestamptz
);
create index if not exists poc_sessions_expires_at on poc_sessions (expires_at);

Flows

Sign-in

sequenceDiagram
  autonumber
  actor S as Student
  participant SP as Student portal (Vercel static)
  participant API as Portal API (Vercel function)
  participant DB as Neon poc_sessions
  S->>SP: Student number + password
  SP->>API: POST /v1/auth/login {scenarioId, password}
  API->>API: Validate body, constant-time password check
  alt wrong password
    API-->>SP: 401 "Student and password do not match."
  else ok
    API->>DB: create table if not exists (first use)
    API->>DB: insert token_hash, scenario, profile, expires_at
    API-->>SP: 200 {accessToken, expiresIn 43200, scenarioId, studentProfileId}
    SP->>SP: Keep bearer token for the session
  end
        

Portal call

sequenceDiagram
  autonumber
  participant SP as Student portal
  participant API as Portal API instance
  participant DB as Neon
  participant M as MockPortalApi (per request)
  SP->>API: POST /v1/portal/submitOnlineForm {args} + Bearer
  API->>DB: poc_sessions lookup by sha256(token)
  alt no row
    API-->>SP: 401 Session expired or missing
  else session found
    API->>DB: portal_meta revision + student_portal_state row
    opt cache is not at seeded_at + revision
      API->>DB: read all record tables (one read-only snapshot)
    end
    API->>M: fresh engine over the records for scenario_id
    M-->>API: result (+ changed records / student state)
    opt method wrote
      API->>DB: begin · lock portal_meta · revision unchanged?
      API->>DB: upsert / delete changed rows · revision + 1 · upsert student state · commit
      Note over API,DB: revision moved → reload and rerun (3 tries, then 409)
    end
    API-->>SP: 200 {result, cms: {acknowledged: true, mode: "stub"}}
  end
        

Any instance accepts any token and sees the same records because both live in Neon. Edits persist across instances and cold starts, and everyone signed in as the same Demo student sees them. Only the 55 PortalApi methods are callable (404 otherwise); database errors return 503.

Switch student and sign out

sequenceDiagram
  participant SP as Portal
  participant API as Portal API
  participant DB as Neon
  SP->>API: POST /v1/auth/scenario {scenarioId} + Bearer
  API->>DB: update scenario_id, student_profile_id
  API-->>SP: 200 {scenarioId, studentProfileId}
  SP->>API: POST /v1/auth/logout + Bearer
  API->>DB: update revoked_at = now()
  API-->>SP: 200 {ok: true}
        

Portal deploy

sequenceDiagram
  actor Dev as Developer
  participant GH as GitHub (portal repo)
  participant GA as GitHub Actions ci-cd.yml
  participant PK as GitHub Packages
  participant V as Vercel
  Dev->>GH: merge / push to main
  GH->>GA: push event
  GA->>PK: npm ci (@raisd-campus/design-system via GITHUB_TOKEN)
  GA->>GA: lint · test · build
  GA->>GA: build .vercel/output (VITE_PORTAL_API_URL baked in)
  GA->>V: vercel deploy --prebuilt --prod (VERCEL_TOKEN)
  V-->>GA: deployment URL, alias raisd-<repo>.vercel.app
        

Config and secrets

ValueLives inUsed by
Neon owner connection stringsVercel env on portal-api (integration-managed)Portal API runtime
Reader connection string, DB admin loginVercel env on db-adminDB admin
Demo student passwordsGitignored control-plane/.credentials/; inlined into the API bundle at buildPOST /v1/auth/login
VERCEL_TOKENGitHub secret on each portal repoPortal deploy job
RAISD_READ_TOKENGitHub secret on portal-apiSibling checkouts in CI
Docs site loginGitHub secrets on portal-api-docsPages assembly

Limitations

Why Neon on Vercel

Vercel’s first-party Vercel Postgres is discontinued; databases come from Vercel Marketplace. Neon is the simplest free option: native integration, $0 start, scale-to-zero, branching. Prefer Supabase only if a PoC needs bundled Auth / Storage / Realtime — Raisd’s shape (SPA → Portal API → Postgres) does not.

Graduation path: keep the Portal API contract; move durable data to SDD-12 Postgres on UltaHost (or the CAP-53 CMS adapter). Swap DATABASE_URL / CMS adapter — do not teach the SPA a second data plane.

Out of scope

Public endpoints Every reachable site and API route OpenAPI Swagger Portal API contract Migration — hosting stages Production UltaHost / k3s sequence Agent Markdown source docs/ai/architecture/vercel-neon-poc.md PoC requirements Prerequisites, functional, security, acceptance Target deployment (k3s) Production stage 1 shape for portal-api