Φροντιστήριο ΥΠΟΔΟΜΗ
A production platform for a tutoring school where the success criterion wasn't launch day — it was never being called again.
- Role
- Solo developer
- Timeline
- 2026 · shipped and handed over
- Stack
- Next.js 16React 19TypeScriptPrismaPostgreSQLNextAuth v5Vercel Blob / R2Upstash Redissharp

The problem
A tutoring school in Giannitsa with content that moves constantly: announcements, exam results published once a year, a teaching staff that changes, categories that get reorganised. On the old setup every one of those changes meant calling a developer and waiting.
So the brief wasn't really "build a website". It was: build something the owner operates alone, forever, without me. That reframes almost every decision — a feature that needs a five-minute explanation over the phone is a failed feature, and every place the interface can be misused is a place it eventually will be.
The hardest single piece of that was the exam results. They arrive once a year, in whatever format the school happened to receive them: an Excel sheet, a Word document, or — most often — a PDF. Hundreds of rows. Typing them in by hand is a day of work and a guaranteed source of typos in students' names.
The hard parts
The PDF that isn't a table
Excel is structured, so parsing it is a loop. Word keeps its tables in the HTML that mammoth produces, so a small regex pass over `<tr>` and `<td>` recovers the rows. PDF has neither. A PDF doesn't contain a table — it contains glyphs at coordinates, and the fact that they look like two columns is an accident of where they were drawn.
Standard text extraction flattens that into a stream where a student's name and their university run together with no reliable separator, and you cannot tell a line break from a column break. Splitting on whitespace guesses wrong on every name with a space in it, which is all of them.
So I stopped reading the text and started reading the geometry. Each glyph's transform matrix gives an (x, y). Group items by y within a tolerance to rebuild the visual lines. Then, per line, find the largest horizontal gap between adjacent items — that gap is almost certainly the gutter between the two columns — and take the median of those candidates across every line as the page's column boundary. One pass of medians beats any per-line heuristic, because a single short row can't drag the estimate off.
With the boundary known, each line splits into name and school by comparing x against it. And because heuristics fail, there's a fallback: if column reconstruction yields nothing usable, the parser falls back to treating the candidate ID number as a record separator and reading line by line.
The empty page that wasn't empty
Every data-access function had a `try/catch` that returned an empty array on failure. It reads like defensive programming. It is actually the bug.
That catch swallowed everything identically: a genuine "no records yet", a serverless cold-start connection delay, a query timeout, an exhausted connection pool. From the outside they were indistinguishable, and nothing was logged.
Combined with ISR, that turned into visible damage. The homepage and results pages regenerate in the background on a revalidate window. When a regeneration ran while the database was briefly slow, the query "succeeded" with an empty array — and Next.js cached that empty page over the perfectly good one it already had. A transient database hiccup became a blank public page that stayed blank until the next revalidation.
The fix was to stop conflating the two. Queries now run through a wrapper that gives each one a timeout and a single retry after a short delay — which covers the overwhelming majority of cold-start blips — and on genuine failure logs the real error before returning the fallback. The visitor still never sees a crash, but the failure is no longer invisible to me.
Designing for a user you can't train
The owner uploads photos straight from a phone. Left alone, that fills storage with 6MB images and makes the site slow for everyone. Uploads are compressed server-side on the way in — resized and converted to WebP — so the person uploading doesn't have to know or care what an image budget is.
The same logic applies everywhere: files attached to a deleted article are removed from object storage too, so deletion actually deletes rather than orphaning; the import runs as a preview that returns parsed rows for review before anything is written, because a parser working from heuristics should never silently commit hundreds of records; destructive actions sit behind confirmation dialogs; long operations show a blocking overlay so nobody double-clicks.
And the part that isn't code: a written guide, in Greek, for a non-technical administrator. Handover is a deliverable. A system only one person can run hasn't been handed over — it's been lent.
Decisions & trade-offs
Every integration is env-gated
- Why
- Rate limiting, transactional email and object storage all no-op or fall back when their keys are absent. The app boots and works with a database and nothing else.
- Cost
- More branching in the config path, and a real risk of shipping to production with rate limiting silently off — which is why the README documents each variable as required or optional.
Rate limiting fails open
- Why
- If Redis is unreachable, legitimate users still get in. For a school website, locking out the owner is worse than briefly allowing extra login attempts.
- Cost
- The wrong trade for a bank. Correct here, but only because I picked the threat model deliberately rather than by default.
Storage behind a provider-agnostic layer
- Why
- Started on Vercel Blob, later added Cloudflare R2 as costs became clearer. The layer picks a provider from env and detects the old one from the URL host, so files uploaded before the switch still delete correctly.
- Cost
- An abstraction I didn't need on day one — but the migration was an env change instead of a rewrite, and no existing file was orphaned.
Results stored as JSON per year, not normalised rows
- Why
- A year's results are read and written as one unit and never queried across. A table of individual entries would have been schema for its own sake.
- Cost
- No querying or indexing inside a year. If the school ever wants search across all graduates, this becomes a migration.
What I'd do differently
Written after shipping, not before.
Golden-file tests for the parsers
The three importers are pure functions built on heuristics — median column boundaries, ID-as-separator fallbacks, header-row detection. That's the ideal shape for tests: keep a handful of real anonymised files as fixtures, assert the exact rows they produce, and every future tweak to the parsing has a safety net. I verified them by eye against the files I happened to have, which means the next unusual PDF is discovered by the client, not by me.
On-demand revalidation instead of a time window
Content changes when the owner presses save — a known moment. Time-based ISR means he saves and then waits, unsure whether it worked, which is exactly the confusion I was trying to design out. Revalidating the affected paths on write would make the update immediate and would also have narrowed the window in which the empty-page bug could cache anything at all.
The swallowed errors should never have been written
The blank-page bug wasn't a subtle distributed-systems problem. It was a `catch` that returned `[]` because that made the types line up and the page render during development. I wrote a wrapper to fix it months later; the honest lesson is that "return empty on error" is a decision about correctness disguised as a null check, and I made it without noticing I was making it.