Skip to main content

Engineering UniExamer: 7 Technical Challenges Behind an Exam Practice Platform on Next.js and Cloudflare Workers

Crash-safe answer saving, versioned questions, a 10 ms CPU budget, hreflang in five languages and a blind-review pipeline for 1,000+ questions

Updated 12 min readMarkdown
Part of seriesbuilding of uniexamer.com
Engineering UniExamer: 7 Technical Challenges Behind an Exam Practice Platform on Next.js and Cloudflare Workers

UniExamer is a practice platform for university admission tests, including the Bocconi Online Test, CISIA TOLC, Politecnico di Torino TIL, Digital SAT, Enhanced ACT, LSAT, GMAT and GRE. It's built with Next.js 16, React 19, TypeScript, Supabase PostgreSQL, Cloudflare Workers (via OpenNext), next-intl and KaTeX. It serves five languages and more than 1,000 reviewed practice questions.

This article covers seven technical problems we ran into between September and October 2026, and how we solved each one. Several of them were bugs we shipped and then found. We describe those too.

  1. Exams change; past results must not The problem. Admission tests change format: the GMAT Focus Edition, the shorter GRE, the Enhanced ACT, Bocconi's penalty rules. Our own questions change as well, whenever a review finds a fault. But a student's result from last week has to stay exactly as it was: the same questions, the same text, the same scoring.

What we did.

Exams are data. Each exam is a versioned configuration file (sections, timing, navigation, calculator policy, scoring, skill taxonomy, plus an explicit list of what couldn't be verified). The assessment engine is a set of pure functions for scoring, navigation, timing and seeded question selection, with no exam hardcoded. Attempts pin versions. Each attempt records the exam configuration version and every question's version. Editing a question bumps its version, so earlier attempts keep the text they were shown. Scoring rules live in configuration. When we found that Bocconi publishes a heavier -0.33 penalty for wrong answers on three-option critical-thinking questions, while our engine charged -0.2 everywhere, the fix was a data rule (itemPenaltyOverrides, matched on domain and option count), not a branch in the scoring code. Results recorded before the fix aren't rescored. Lesson: if your domain has external rules that change, make them versioned data from day one. That's what made adding TOLC-E, TOLC-F, TOLC-I and the PoliTo TIL a matter of new configuration files, not new features.

  1. Answer saving that survives tunnels, reloads and closed tabs The problem. A timed practice section is stressful. If a dropped connection loses an answer, students stop trusting the product. A naive "send on change" approach has a second, quieter bug: requests arrive out of order. A slow request carrying an older answer can land after a newer one and overwrite it.

What we did.

Device first, server second. Every answer and navigation step is written to localStorage (keyed by account and attempt) before it's sent, and stays there until the server confirms it. When the player opens, it reconciles what the device kept against the server's state and resends pending answers oldest first, one at a time. Guarded writes with a logical clock. Each answer carries the player's server-anchored clock. The database writes an answer only if its clock is ahead of the stored one, checked in the same guarded SQL statement as the write. A stale answer is reported back with the answer the server holds. A duplicate succeeds without writing, so a retry never counts time twice. The server still decides. Nothing is replayed into a closed section, a locked answer or another account's attempt. An answer delivered after the deadline is refused, even if it was made in time on a disconnected device. Honest status. The UI says saving, saved, offline or failed, and only says "saved" once the server has confirmed every answer. Retries back off from 2 s to 30 s and resume when the connection comes back. Lesson: offline support is two problems, persistence and ordering. Solving only the first can corrupt data.

  1. Fitting a Next.js app into Cloudflare Workers Free's 10 ms CPU limit This was our hardest operational problem.

Background. We deploy Next.js to Cloudflare Workers with OpenNext, and connect to Supabase PostgreSQL through Hyperdrive. A Worker can't share a database pool between requests, so each request opens a small pool. One early surprise: behind Hyperdrive, Supabase's transaction pooler left connections stuck "still in a transaction". The session pooler works.

The problem. The Workers Free plan allows 10 ms of CPU per request. When we crawled our own sitemap the way Googlebot does (back-to-back requests), 9 to 15 of 29 pages answered 503 with Cloudflare error 1102 ("Worker exceeded resource limits"). The same page could alternate between 200 and 1102 in six consecutive fetches. Crawlers that keep getting 503s slow down, and pages that keep failing eventually drop out of the index. For a site that depends on search traffic, that's an existential problem.

Why trimming wouldn't work. Workers Observability over 24 hours (2,381 requests) showed a median of 28 ms CPU for successful requests and a p90 of 378 ms. The home page used 381 ms, and even robots.txt used 49 ms, because every request boots the Next.js server. No amount of optimisation would get a full Next.js render under 10 ms.

What we did: serve public pages as static assets. Cloudflare answers a request that matches a static asset before it runs the Worker, and an asset costs no Worker CPU. So our deploy script now uploads twice:

Deploy the new build. Wait for it to answer, then fetch every public sitemap page in all five languages, plus robots.txt, sitemap.xml, llms.txt and the icons, as an anonymous visitor. Write those responses into the Worker's static assets and deploy again. The parts of a page that depend on the visitor moved to the browser:

The header became a client component that shows the learner menu when the browser's own storage remembers a session. Every server-rendered page keeps that note in step, and signing out clears it. The home page's "continue studying" line comes from a small API call, made only when a session is remembered. Page-view counting moved to a lightweight /api/views beacon, because the server no longer sees those page loads. Language detection, which a proxy used to do, runs as an inline script that applies the locale cookie or browser language. Result: three full crawls of the sitemap plus 40 back-to-back fetches all answered 200, with a median of about 75 ms. Before, time to first byte had ranged from 0.2 to 4.6 seconds. Sign-in, practice, pricing and checkout still run on the Worker.

Gotcha: static assets are served for any hostname before the Worker's own redirects run. So the deploy script first checks that www and the old domain already redirect through Cloudflare Redirect Rules. If they don't, it refuses to save static pages, which would otherwise create duplicate content on the old domain.

  1. A rate limiter anyone could bypass with one header The problem. Our sign-in and sign-up rate limiter identified clients by the last X-Forwarded-For entry. That's correct behind Vercel, which appends the real client address. On Cloudflare Workers nothing appends to that header: it reaches the app exactly as the client wrote it. A client could send a new made-up address with every request and never hit the limit. We confirmed this on the live site with a forged header.

What we did. On Workers, the client address now comes from CF-Connecting-IP, which Cloudflare sets itself and won't accept from a client. Everywhere else, a client-sent CF-Connecting-IP is ignored. The runtime check lives in one module, so the database layer and the rate limiter agree on where the code is running.

Lesson: "which header holds the real client IP" depends on the platform. Re-check it every time you change hosts.

  1. Five languages, and canonicals that would have hidden four of them The problem. UniExamer serves every public page in English, Italian, Turkish, Spanish and Polish (/exams/gmat, /it/exams/gmat, …). Each page set its own metadata with an English canonical, and in Next.js a page's alternates replaces the layout's. So /it/exams/gmat told search engines that /exams/gmat was the real page, and no page carried hreflang at all. The Italian, Turkish, Spanish and Polish pages would have been folded into the English ones and left out of the index.

What we did.

Every page now builds its metadata with a single helper, pageAlternates(params, path). It returns the page's own language URL as canonical, all five language versions as hreflang alternates, and English as x-default. og:url follows the canonical. The sitemap lists every page once per language (155 entries), each entry naming all its language versions. This is how Google supports hreflang in sitemaps. A unit test fails if any page sets a canonical without going through the helper. Lesson: for SEO bugs, write a test, not just a fix. A metadata regression is invisible in the browser and can stay hidden for months.

  1. When a dollar sign broke maths in 46 published fields The problem. Questions are written in Markdown with inline TeX, rendered by KaTeX. Prices inside formulas were written as $$80$. Our inline-maths scanner ended the formula at the escaped dollar, so KaTeX received a lone backslash and showed a parse error. Worse, everything up to the next dollar sign was typeset as maths, turning ordinary sentences into italics across 46 fields of published SAT, ACT, GMAT and GRE questions.

What we did. An escaped dollar inside a formula now belongs to the TeX. An escaped dollar outside a formula is still a literal dollar sign. The function that decides whether a page contains maths uses the same pattern, so the KaTeX font preload stays consistent with what's actually rendered. Validators now also check currency in maths. We had also seen KaTeX fonts loading late and shifting layouts, so pages that contain maths preload exactly the fonts they need.

  1. Making AI-assisted questions trustworthy: blind review and bias scans Our questions are original and drafted with AI assistance, so the quality question isn't whether we can write them but how we know they're right.

The pipeline.

Draft into an in_review state, following an authoring brief. Run automated scans for rendering damage, broken maths, accessibility text and answer cues. Normalise option order, so the position of the correct answer carries no signal. Export a blind batch, with the key, explanation and distractor notes removed. Two independent solvers answer each question from the blind file only. Compare their answers with the key mechanically, through the same scoring engine the app uses. Publish only if both agree with the key and neither finds a second defensible answer. Any disagreement quarantines the question; it's never silently corrected. A bias we found in our own bank. A scan showed the correct answer was the longest choice far more often than chance: 65% of LSAT items, 58% of GMAT, 55% of TOLC-E. That's a well-known test-taking cue. In the 37 worst items (key at least 30% longer than any other choice), we rewrote the distractors to match the key's length and specificity without changing any key. Then every one went back through blind review. Afterwards the rates fell to about 50% for LSAT and GMAT and 34% for TOLC-E, and the length-cue scan now runs on every new wave.

Accessibility regressions from shuffling. Normalising option order once left screen-reader descriptions listing choices in their pre-shuffle order. The fix bumped each affected question's version, and a validator rule (accessibility-claims-order) now blocks the fault from coming back.

Translation is a separate review. Italian versions of the TOLC questions are published only after a translation check of a blinded export, and each attempt records which language the student saw.

Bonus: framework upgrades on Windows Upgrading to Next.js 16 broke the Cloudflare build three times:

Turbopack's file tracing kept only pg-cloudflare's Node stub, so OpenNext couldn't bundle the Workers build of pg. Adding pg-cloudflare to serverExternalPackages made OpenNext copy the whole package. On Windows, Turbopack reached better-sqlite3 through a symlink that OpenNext recreates, and Windows refuses that outside Developer Mode (EPERM). The Cloudflare build now uses webpack; local dev and next build keep Turbopack. Every rendered page then returned 500, because Next.js 16.4 reads its preview keys from server/preview-props.json, which our OpenNext version didn't inline into the Worker. Upgrading OpenNext to 1.20.12 fixed it. Search and AI visibility (SEO and GEO) checklist we ended up with For anyone building a content-led product today, here's what uniexamer.com does for both search engines and AI assistants:

Self-referencing canonicals and hreflang in five languages, plus a per-language sitemap. Organization, WebSite, BreadcrumbList and Article structured data, describing only what the page visibly says, with alternateName for the old brand name. robots.txt that allows search, user-triggered and training AI crawlers on public pages and keeps everyone out of private areas. An llms.txt map of public pages, generated from the same data as the sitemap. IndexNow submissions after each deploy, so Bing (and therefore Copilot) picks up changes quickly. A 1200 × 630 share image for every exam and guide, with a test that fails if one is missing. Pages written for answer engines: each exam page opens with a direct summary, a facts table and the date the facts were checked against the test maker. Indexing controlled by an environment flag, so preview deployments are never indexed. FAQ What tech stack does UniExamer use? Next.js 16 and React 19 with TypeScript, Tailwind CSS, Supabase PostgreSQL (SQLite for local development), Cloudflare Workers via OpenNext with Hyperdrive, next-intl for five languages, KaTeX for maths, Stripe for billing, and Vitest and Playwright (with axe accessibility checks) for testing.

How did UniExamer get around the Cloudflare Workers Free CPU limit? By saving every public page as a static asset at deploy time. Cloudflare serves matching assets before running the Worker, so public pages cost no Worker CPU and stopped returning error 1102.

How are UniExamer's practice questions checked? Each question is solved blind by two independent solvers, without the answer key, and published only if both agree with the key. Disagreements are quarantined, and automated scans check for answer-length cues, rendering faults and accessibility errors.

Where can I see the result? At uniexamer.com. Start with the exam list, the Bocconi Online Test hub or the CISIA TOLC hub.

No comments yet. Be the first to comment.