Case study · 2026

PassingLab — a UTBK tryout platform that never loses an answer

A paid UTBK tryout platform: students buy a bundle, sit timed subject simulations, then read the explanations — with an admin panel behind it for the question bank, the catalogue, and payments. A full rewrite from Laravel to Next.js 16.

An exam is not allowed to lose an answer. Autosave, deterministic shuffling, and a server-side timer are requirements, not features.

PassingLab is an online tryout platform for Indonesian high schoolers preparing for UTBK, the university entrance exam. There are two sides to it, and they are the same size. The student side: buy a bundle, work through a timed tryout package per subject, then read the score and the explanation for every question. The admin side: eleven pages covering the question bank, the product catalogue, students, payments, exam results, notifications, and bug reports. That second side is the part nobody sees from outside, and it is the reason the product can run day to day without me touching the database by hand.

The version I built is a rewrite. The old app was Laravel and Blade and has been retired; I kept the domain rules and replaced the stack — Next.js 16 App Router, React 19, Server Actions, and Supabase Postgres through Drizzle. The old MySQL data came across through a migration script with a --check mode, so it could run dry before touching anything real. It now stands at 24 tables, 177 TypeScript files, roughly 24,000 lines.

An exam is not allowed to lose an answer

This is the first principle, and it shaped more of the code than anything else. Every answer autosaves through an idempotent upsert endpoint, including when the tab is hidden. The timer is computed on the server, never from the browser clock. Question order and option order are shuffled deterministically from the attempt seed, so a reload shows exactly the same arrangement — without that, autosave would write to the wrong question number.

A few old defects got fixed on the way. The unique index behind autosave is built on coalesce(true_false_item_id, 0), because Postgres treats NULLs as always distinct and the upsert would otherwise duplicate rows. Questions are soft-deleted, since a hard delete would cascade away answers from students who already sat the test.

Two question types, one scoring rule

Questions come in two shapes: multiple choice worth full marks, and true/false where the points split evenly across statements — a 20-point question with four statements is 5 points per correct statement. The final score is points earned divided by total points, times 100, rounded to two decimals. The scoring logic lives in a pure module with no database imports at all, so it can be tested directly. Pricing and EXP calculations follow the same pattern.

Questions can carry rich HTML from a TipTap editor, images, and LaTeX rendered by MathJax.

The admin panel: the question bank

The question bank is laid out as a tiered workspace rather than a flat table: bundle, then tryout package, then subject, and questions only appear once all three are chosen. The reason is structural — the exam engine looks up questions by that combination, so a question saved without one of them would never be visible to a student. In the old Laravel form the tryout package was optional; here I made it required, so that mistake cannot be made at all.

There are three ways a question gets in, and all three are deliberately different shapes. The single form uses the rich editor for questions that need an image or a formula. The bulk form uses plain textareas — ten questions in a rich editor means sixty TipTap instances at once, and someone typing up a question bank needs keyboard speed, not a toolbar; LaTeX still works through dollar-delimited math. The third is a pnpm import:soal script that pulls a JSON question bank straight into the database — idempotent, keyed on the natural identity of each row, so running it again duplicates nothing.

One small thing in the bulk form matters more than it looks: every field is controlled. React clears an uncontrolled form as soon as the action resolves, which means a single validation error would throw away everything the admin typed — and that is precisely the one thing a bulk form must not do. Next to it sits the stimulus manager: long passages shared by several questions, scoped to a package because outside one they mean nothing.

The admin panel: students, money, and results

The payments page is where access is actually granted: approving an order writes every bundle line inside it into the entitlements table. What I guard here is that the logic for marking an order paid and handing over the bundle is written once and shared by both the gateway webhook and the admin button. Two copies of the same rule eventually drift, and the way that failure shows up is an order that reads as paid while the student still cannot open anything. The function is also safe to repeat: a webhook that arrives twice does not grant access twice.

Slow side effects — the invoice PDF, the email, the Telegram notification — run after the response is done, so an admin approval never waits on a mail server. If mail is down, the order is still paid and the access is still issued. The count of payments waiting for confirmation shows as a red bubble in the sidebar, so nothing sits stuck just because it is out of sight.

The recap page computes averages, best score, latest score, accuracy, and per-subject scores — plus each student's strongest and weakest subject, exportable to PDF. There is one deliberate correction here from the old version: accuracy used to be measured from multiple choice alone, so every true/false row counted as wrong and the number was always dragged down. Both question types are now scored by their own rule. Something similar was happening on the admin dashboard, which counted attempts by a status value that never existed in that column, so the number was always zero.

The rest closes the operational loop: student records (create accounts, activate or deactivate, set bundle entitlements, and revoke every login session when a password is changed), cross-bank question search with pagination and an option to show deleted rows, exam result drill-down to the per-item answer, notifications sent to one student or broadcast to everyone, and bug report triage from open to in progress to resolved to closed, which notifies the reporter automatically on every status change.

Security

This is where the app departs most from the old one, and nearly all of it started from a concrete defect I found there. The worst: OTPs had no purpose column, so a signup code could be replayed in the password reset flow — a straight path to account takeover. OTPs are now scoped by purpose (signup, device, reset), an older code is deleted whenever a new one is issued, a correct code is destroyed immediately so it cannot be used twice, and the comparison is constant-time. Six digits is a million combinations — which sounds like a lot until you notice nothing stops a script from walking all of them inside the lifetime of the code, so wrong guesses are capped at five and hitting the cap burns the code.

The guards are layered and all of them live on the server. Passwords are hashed with bcrypt at 12 rounds, and Google-only accounts still run one dummy comparison so response time does not leak whether an account exists. Failed logins are recorded per username and IP: three failures raise a captcha, five lock the account for fifteen minutes. The rate limiter is stored in the database rather than in process memory — the app runs on several instances, and a per-process counter would hand an attacker a fresh allowance on each one. New devices must clear an email OTP before they are trusted. Sessions live in their own table with a 32-byte random token, and the cookie is httpOnly, SameSite=Lax, and Secure in production. The admin role check that used to be copied at the top of some forty controller methods — with one route missing it entirely — now sits on the admin layout, so no page can be reached without passing through it.

For the integrity of the exam itself, answer keys never travel to the client: the question payload sent to the browser carries only ids and option text, and grading happens entirely on the server. Every exam endpoint checks ownership of the attempt, not just its status. If a student switches tabs the question is blurred client-side but the count is held by the server, and past three the attempt is force-submitted server-side, so a tampered client cannot buy itself extra trips away. This is not proctoring and I do not claim it is; it closes the cheapest gaps.

The rest is input and secrets. All question HTML is sanitised before display through an allowlist of tags and attributes, with URL schemes limited to http, https, mailto, and tel — javascript: and data: are dropped along with the URL; the old Blade views printed that HTML raw, which meant anyone who reached the admin form could inject a script into every student's exam page. Uploaded images are not trusted by their MIME type — the bytes are re-decoded, because the declared type is attacker-controlled. Payment proofs go to a private bucket served through signed URLs, unlike question images, which are public. The Supabase service-role key only exists on the server and its module is marked server-only. Every form input is validated with Zod on the server, and required environment variables fail loudly when read instead of quietly becoming undefined.

Payments

It started as manual transfers: the student uploaded a receipt, a Telegram bot notified an admin, and the admin confirmed each one by hand. It now runs on QRIS through Pakasir and activates automatically, but the manual path was deliberately kept — it still gets used when something needs sorting out by hand. The webhook is never trusted as proof of payment: that endpoint is public by design, so anyone could fire a completed payload for an order code they know. Its contents are treated only as a nudge to go ask Pakasir directly, and the amount in that question comes from my own order record rather than the request body, because a forged body can point at a real order with a different total. Access is issued from the answer to that question, not from the request that prompted it.

Prices are stored as integer rupiah — in the old schema they were varchar, for money. The final pricing formula also lives in exactly one place, shared by the admin form and the server, so the number an admin sees while typing is never different from the number the student is charged.

Not finished yet

There is an alignment plan still open: full-length tests (160 questions, 195 minutes, seven subtests in one sitting), per-question time tracking, and a quadrant analysis engine — fast or slow against right or wrong — that surfaces at most three study priorities per test. Time tracking deliberately comes first, because that data cannot be created retroactively: every tryout that runs before the column exists is a cohort permanently blind to the speed signal.

Right now it holds 1,604 questions, 7 UTBK subtests, 24 tables, and 8 unit test files covering the parts where a mistake costs the most: scoring, pricing, shuffling, OTP, and rate limiting.