Score A-Star
A family publishing business, rebuilt as four Next.js apps sharing one account and one design system — including a secure library that marks every page with the identity of the reader who opened it.
- Role: Solo — every line of the frontend, backend, data model, design system, forensic marking, integrations and deployment.
- Stack: Next.js 16, React 19, TypeScript, Firestore, Firebase Auth, Cloudflare R2, Turborepo + pnpm workspaces, Tailwind 4.
- Status: All four apps live in production.
The problem
Score A-Star is a family publishing and tutoring business in Egypt. Before this, the whole thing ran on tools that weren't built to run a business:
Orders arrived as messages. Customers ordered over WhatsApp and by phone. Someone wrote the order down, worked out shipping by hand, and coordinated delivery in the same thread.
The catalogue was a Facebook page. Books existed as posts and photos; questions and orders came back as comments and DMs, mixed in with everything else.
Digital books were just files. PDFs were sent directly to whoever paid. Once a file left, it was gone — no way to tell one copy from another, and nothing to stop it being forwarded.
The records were spreadsheets. Orders, students and readers tracked by hand, which meant the answer to "has this been paid for?" lived in someone's memory as often as in a cell.
Every one of those is fine at five orders a week and breaks at fifty. And none of them can answer the question that actually mattered to a publisher selling digital books: if this file ends up in a group chat, whose copy was it?
What I built
Four Next.js apps in one monorepo, built and shipped solo over about a year. All four are live.
The store — the public bookstore. Catalogue by subject and school year, cart, guest or signed-in checkout, Egypt-specific shipping priced by city and parcel weight, order tracking, free resources, a PDF flipbook reader, token-gated audio, and shareable links that can open even a deactivated book.
A-Practice — the learning platform. Roles for students, parents and teachers; generated quizzes across multiple-choice, short-answer and matching questions; block-based lesson notes; two live games (a timed math rush with a leaderboard, and a multiplayer battle-royale with rooms, matchmaking and three lives each); and a parent–child linking flow with approval, so a parent sees their own child's scores and nobody else's.
The secure library — a reader signs in, sees only the books granted to them, and reads them in a flipbook. One account is bound to one device. Every page is marked, per reader, on first view.
The admin console — one sidebar that runs all three. Catalogue and bundles, order editing and fulfilment, question banks and taxonomy, a note builder, the library's books and readers, and a forensic bench for reading a mark back off a leaked screenshot. It's an installable PWA with no API routes at all.
Underneath: Firestore and Firebase Auth, Cloudflare R2 for every asset, a shared component library, plus integrations with a courier's tracking system and Meta's WhatsApp Cloud API. The store runs on Firebase App Hosting, the console on Vercel.
Architecture: how four apps stay one codebase
The monorepo is the part that made four apps survivable for one person. One package holds the shared component library and design tokens; six more hold everything else, and each has a rule for what's allowed in it:
- Service setup — Firebase admin, R2, SMTP. Every export is a lazy getter with no module-level side effects, because a client constructed at import time throws in whichever app lacks those credentials, even when that app never uses the service.
- Three domain packages, one per product, each split into a server-side Firestore data layer and an isomorphic layer of types a client component may safely import.
- A vocabulary package — the terms two or more products must agree on: subject slugs, default authors, academic-year labels. It has zero dependencies by charter, which is exactly what lets a server data layer and a client form import the same list.
- An auth package — one account across three apps: the cookie contract, the password rule, the session minting, and the sign-in forms all three render.
A constant earns a place in the vocabulary package only when a second product needs it. Egypt's shipping areas stay in the store's domain package because they're the index of a rate table; the practice app's grade codes stay in its own because nothing else files anything under them. Having written rules for where things go is what kept the shared layer from becoming a junk drawer.
The admin console has no API routes. Every backend operation is a Server Action. That's a deliberate constraint with a sharp edge: every exported Server Action is a public POST endpoint that no layout or proxy can gate, so they all run through one wrapper that applies the auth check before the body runs — and the file holding that check is marked server-only and must never carry the 'use server' directive, which would publish the check itself as an endpoint. Anything that needs to serve bytes to an <img> returns a presigned R2 URL instead of a route.
There is exactly one exception in the whole console, and it's earned — see the courier section below.
Design decisions
Doing the work once
Four decisions share a throughline: the cheapest request is the one that never happens.
The store used to read its catalogue from Firestore on every render — a single book page scanned both book collections three times over, through its metadata, its own lookup, and its siblings, and a scan bills a read per document. Now the whole catalogue is read at most once per ten-minute window and every view is a filter over that one copy — subject pages, year hubs, a book's bundles, order weights. The trade is stated plainly in the code: an edit in the console reaches the store within ten minutes, and on this hosting nothing could reliably shorten that, because the cache lives per instance and a revalidation call only ever reaches the instance that takes it.
The crucial carve-out: access checks never use the cache. Whether a PDF may be served or a book is deactivated is read straight from Firestore, so locking something takes effect immediately.
The console's analytics screen got the same treatment from the other direction: instead of aggregating every order on each page load, it keeps a small ledger and rolls it forward from a single range query over what changed. Because an order's contribution has to be taken back out when it's edited, the ledger keeps each order's prior state — which is also the only reason the stat cards and the trend chart can't drift apart, since both derive from one shared split rather than two copies of the same arithmetic.
The part I'm happiest with is how this is defended. There are end-to-end specs that do nothing but audit network traffic: load a catalogue page, record every request the browser makes, and assert that the per-book thumbnail routes were never called at all. It's a test for a performance regression that would otherwise be invisible — the page would still look perfect while quietly costing a request per card.
Never trusting a number that came from a browser
Every API route is a public POST, so anything in the body is a suggestion, not a fact.
The cart computes a parcel's weight so it can show the customer an estimate — and the order route throws that number away and recomputes it from the catalogue. Editing an order's items in the console works the same way: existing lines keep their checkout snapshot and additions are priced server-side from the catalogue, so no price ever crosses the wire.
The same principle shows up in the WhatsApp integration, where it's subtler. An order id travels in the reply button's payload, because nothing else in a WhatsApp reply says which order it's about — but decoding that payload is not authorization. It's the store's own string echoed back. What makes a reply actionable is that Meta authenticated the sender's number, so the write matches that number against the order's stored phone before touching anything.
Editing an order without re-pricing it
Operators can edit the lines on an order that's still pending or processing. Two invariants hold it together, and the second took a second attempt to get right.
Editing items genuinely changes what the courier carries, so the weight surcharge is recomputed — but the city band the customer was quoted stays theirs. The band is recovered by subtracting the old surcharge from the stored shipping cost, rather than re-reading the rate table, so a later change to city rates still cannot re-price an old order. That preserves what the original blunt "shipping is never recomputed" rule was actually protecting, while letting the part that should move, move.
Cancelling: one door, two sides
Cancellation is the one status both sides can reach, and it's shaped differently for each. For an operator it's just another status in the same dropdown, saved by the same button, with no confirmation dialog — because a status the console can always set back isn't worth a second door. For a customer it's allowed only while the order is pending and no payment has been confirmed, because the business then holds money and there's no refund flow.
Both paths go through one function that re-checks the rule inside a transaction, so a customer's cancel can't land on an order an operator has just started packing. The caller's own check exists only so there's a sensible message to show.
A design language, not a theme system
One brutalist design system across all four apps, defined once: 2px borders applied globally, hard offset shadows with zero blur, a generous radius, and a blue-and-yellow palette defined on :root as the only theme. No app sets a dark class — but the dark: variant declaration is deliberately kept, pinned to a class, so that stray dark utilities in shared components can't fire off a visitor's OS preference. That's a one-line decision that prevents a whole category of "why is this page half-dark on my phone" bug report.
Deep dive: making a leak attributable
This is the piece the whole secure library exists for.
The constraint: you cannot stop someone screenshotting a page. There is no screenshot event on the web to hook, and the OS-level paths — Win+Shift+S, PrintScreen, Cmd+Shift+3 — never reach the page at all. Anything built to "block" capture would be a deterrent dressed up as a control. So the app makes no such claim. It makes exactly one: if a page leaks, we can say whose copy it was.
The approach. Every page a reader opens is marked with their account identity, once, on first view — then cached as their copy. The mark is tiled diagonally across the page and deliberately unobtrusive rather than invisible. That was the key call, and it's counterintuitive: chasing true invisibility forces low-amplitude pseudorandom patterns that a screenshot or a re-encode erases, and which mean nothing without a correlation detector to read them back. A mark that's merely hard to notice while reading can run at an amplitude that survives screenshots, recompression and cropping — and stays plainly legible once contrast-stretched, so no decoder is needed.
The sign follows the background. A fixed dark-on-light mark disappears entirely on a dark page or a full-bleed photograph. So the delta is subtracted from light pixels and added to dark ones, decided per pixel. The two sides carry deliberately different magnitudes, because a fixed step isn't equally visible across the tone range — the same delta that vanishes into paper is loud on a near-black fill. The dark side is bought recoverable at the cost of being conspicuous, on the reasoning that a mark a reader can see is also a mark that deters.
The font bug that taught me the most. The mark used to be drawn by asking the system for a font family. In a container whose fonts can't draw Latin, the renderer substitutes something that draws every character as an empty box — the mask fills with ink, every internal check passes, and the page is written spelling nothing. Permanently, because a marked page is never re-marked. The fix was to ship the typeface with the source as bytes and hand the renderer a file path, removing the font search entirely. Everything downstream of that now refuses rather than guesses: if the mask renders no glyphs, or if every glyph lands at zero strength, the operation throws — because an unmarked page sitting in a reader's cache is unattributable forever and nothing else would ever notice.
Every number was tuned on a bench, not reasoned about. There's an operator screen in the console that sweeps the amplitude, glyph size, tile pitch and rotation against a sample page and the standard attack. Beside it is the reveal tool: upload a suspect screenshot, and it stretches the image three different ways to bring the mark back into view. The default mode is a high-pass residual, because it's the only one that doesn't care which direction the mark went — either single-ended stretch can only ever show you half a page.
What the mark spells is a decision too. It's the reader's account id and nothing else. Deliberately not their email: a leaked page is by definition in someone else's hands, and printing an address on it would hand a leak recipient the identity of the person who leaked it. An account id is meaningless without the readers collection, so the same page that identifies a reader to the operator tells a stranger nothing.
And the cost is paid once. Marking is three round trips to storage plus a per-pixel composite, so a book's first open runs them through a small bounded worker pool rather than one page at a time. The concurrency limit was measured rather than picked — past a small fan-out the per-pixel pass is synchronous, so there's no CPU left to win and only peak memory grows. Every open after the first is a single existence check and a short-lived presigned URL, so page bytes never stream through the server.
The other half: one device per account
A session cookie can be copied. So the binding isn't a cookie — it's an ECDSA P-256 keypair generated non-extractable and kept in IndexedDB. A non-extractable key can't be exported by script or lifted out of devtools, so moving an account to a second machine costs more than copying a cookie value: the cookie alone cannot answer the server's challenge.
The check runs on every action and every page render, not just at sign-in — a cookie copied to another machine never touches the login path again, so checking only there would leave the whole rule bypassable. First bind wins; a reader arriving from a second device gets a dead end naming the operator, not another binding opportunity, because otherwise "one device" would quietly mean "one device at a time," which is a much weaker rule.
The limits are honest and designed for: clearing site data destroys the key, and Safari evicts IndexedDB after about a week of no visits. So rebinding is an ordinary support action an operator performs, not an exception — and the count of rebinds is a number the operator can watch, because a reader who needs unbinding every week isn't switching laptops.
Invitations that survive an email scanner
Reader accounts are operator-provisioned, which meant sending invite links. Firebase's password-reset codes expire about an hour after they're minted — when the operator pressed the button, not when the reader opened their mail — and there's no expiry option. So invites carry their own token: 32 random bytes, stored only as a hash, with a configurable lifetime in days.
The detail I like: the setup page reads the token; the form's action spends it. Mail gateways and link previewers issue a GET to every URL in an email, so consuming the token on page load would kill an invite before the reader ever clicked it.
Deep dive: a payment method that finishes somewhere else
Egypt's InstaPay is the country's instant bank-transfer network. What's available to a business this size is a payment link — the customer opens their own banking app, sends the money, and comes back. Nothing calls the store to say a transfer landed. There is no webhook, no redirect back with a status, no reconciliation feed.
So the checkout has a hole in the middle of it that no amount of code can close. The design question isn't "how do I confirm the payment" — it's "what does the customer see during the part I can't observe?"
What I built is a stepped drawer that owns the gap:
- Copy your order reference — displayed in large monospace, with a copy button, because the reference typed into the transaction notes field is the only thing that will later connect a bank transfer to an order.
- Send the payment — a button out to the payment link, with the exact amount shown, and an account number as a fallback for anyone whose app doesn't take links.
- Send a screenshot on WhatsApp — a button into the business's WhatsApp, because a screenshot is what an operator actually reconciles against.
Then a single button: "I've completed the payment steps" — carefully worded. It doesn't claim the payment arrived, because the store doesn't know that. It moves the order to pending confirmation, and an operator confirms it after checking the transfer, which records the amount actually paid.
Three things make this hold together rather than just being a nice screen:
It's a wizard on mobile and a checklist on desktop. Same content, two layouts. On a phone, the steps involve leaving the browser — into a banking app, then into WhatsApp — and coming back. A three-step wizard with a pinned footer means there's an unambiguous "where was I" when they return. On desktop, where the whole flow is visible at once and nobody is app-switching, a numbered list is faster and a wizard would be friction for its own sake.
It re-checks on open. The drawer asks the server for the order's current state every time it opens, because the customer may have already confirmed on another device, or the order may have been cancelled since. Reopening an old tab shouldn't show them step one of a payment they've already made.
The amount paid is a real field, not a flag. Because an operator can also edit an order's items afterwards, the difference between what was collected and what the order now costs is a number the console carries through to the analytics — and it goes negative when items are cut back after payment, because that's a refund due, and carrying it signed is what keeps collected-plus-owed equal to the order's price.
Deep dive: the courier who never calls back
Orders ship with a local courier that has no published API and no credentials — just a public tracking page. The endpoint behind that page takes a list of waybills, so the whole working set of orders in flight costs a handful of requests rather than one per order.
The real problem wasn't fetching, though. It was that an operator marks an order "delivering" when they hand it to the courier and then has no reason to ever look at it again. The final status change never happens on its own. Customers get no delivery confirmation, and the numbers on the dashboard slowly stop meaning anything.
So: a nightly job that reconciles. And this is the single exception to the console's "no API routes" rule — which is exactly why the rule is worth stating rather than assuming. The scheduler is the one caller that isn't the front end. A cron trigger is a plain HTTP GET; it can't invoke a Server Action, and there's no session for the auth check to read. So this one is a route handler, a shared secret is the whole of its authorisation, and a missing secret returns a 503 rather than an open door — because treating unconfigured as unauthenticated would publish an endpoint that rewrites order statuses to anyone who finds the URL.
The job only ever moves an order forward, and only on an actual signature scan. A waybill the courier has no record of, a parcel still in transit, one heading back to the sender, a courier outage — every one of those is left exactly as it was. That's also what makes it safe under a best-effort scheduler that may drop a run or repeat one: it reconciles the orders still sitting in "delivering" rather than a window of time, so a missed sweep costs a day and a duplicated one announces nothing twice. The update itself re-checks the status inside a transaction, so two overlapping runs can't both announce the same delivery.
My favourite detail is the failure mode. If every courier request fails, the job returns a 502 rather than reporting a clean sweep that found no deliveries — because a run that announces nothing reads as a healthy one in the cron log, and that one was not healthy.
And the schedule is a timezone problem in one line: cron expressions are UTC with no timezone option, and Egypt observes DST, so one fixed hour can only ever be right for half the year. The chosen hour is midnight in Cairo in winter and 1am in summer — picked one hour later than the obvious value specifically so the run lands on the Cairo day it's named for, rather than firing at 11pm the previous evening all winter.
Deep dive: one account across three apps
The store, the learning platform and the library all pointed at the same Firebase project from day one, so a person's identity was never the problem. The session was: three different cookie names, none of them scoped to a domain. Signing in to one app proved nothing to the next, and there was no way to tell a customer "yes, that's the same login."
The migration was mostly about the things that break quietly.
Sign-out was the trap. Deleting a cookie only works when the name, path and domain all match how it was set. A bare delete leaves a domain-scoped cookie sitting there and the user stays signed in — and nothing errors. So setting and clearing now live in one module that owns both, and sign-out revokes the refresh token so every app's verification fails on its next request. Sign out anywhere, signed out everywhere.
The cookie got a new name, not a new scope. Reusing the old name would have meant a host-only cookie on a subdomain and a domain-scoped one on the apex existing simultaneously, both sent on every request, with the cookie API returning an unspecified one of them. Renaming sidesteps the ambiguity completely and leaves the old cookies inert.
Three things deliberately didn't change. The admin console stays out of it entirely, on its own cookie and its own allowlist — a leaked customer session must never be an operator session. The library's device cookie stays host-only. And authorization stays per-app: roles, claims, onboarding, the reader check, the admin check. Only authentication is shared. That separation is the whole reason the migration was safe to do at all.
Two consequences worth naming. Sign-in now refuses an unverified email, and that's load-bearing rather than cosmetic: the store matches a customer's order history on their account email, doing the job a one-time-code flow used to do. And because Firebase treats Google as a trusted provider that can take precedence over a password account for the same unverified address — removing the password — the shared form links credentials explicitly rather than trusting that precedence. There's a script in the repo that audits the real data to prove no account is in that vulnerable state, and it's meant to pass before the relevant button ships.
Where it pays off: a signed-in account's menu offers the other products it uses, worked out on every render from records that already exist — a role document for the learning platform, an active reader record for the library. No flag to store, so accounts that predate the feature need no backfill and a revoked reader loses the link immediately. Signed-out visitors get no links at all, by choice: a cookie remembering them would outlive sign-out and tell the next person on a shared computer which apps the last one used.
Deep dive: work that outlives the screen that started it
Ingesting a book into the secure library means rasterizing every page of a PDF in the operator's browser, because the original file is never stored server-side — that's what makes "we don't keep your original" a property of the system rather than a promise.
Two things follow, and both broke the obvious implementation:
- It can't be replayed server-side. There's no stored PDF, so a page that fails can only be retried while that browser tab still holds the file.
- It must not belong to a dialog. An operator queues a 300-page book and walks away. If the work lives in a modal, closing the modal kills it.
So there's a small task store: a task is a name, an async function that reports progress, an optional lane, and a disposal hook. Tasks in the same lane run one at a time. One progress dock in the root layout draws all of them.
Two properties are the entire reason it exists. The work is held by the store, not a component, so changing screens can't interrupt it. And the store is an external one that only the dock subscribes to, so a long upload ticking once per page re-renders one small card instead of the whole page underneath it.
The ingest closure is where the nice bits are. Retry just calls the same function again — so "retry only the pages that failed" is the closure's own memory rather than a case the store has to know about. The parsed PDF is kept across retries so a four-page retry doesn't re-read the document. Render concurrency and upload concurrency are tuned separately, because one is CPU on the operator's machine and the other is their uplink, and doing them strictly in turn leaves whichever is idle idle. And finalising, the toast and the cache invalidation all happen inside the task, because by the time a long book lands, the screen that started it may be long gone.
The thing it can't outlive is the tab — which is why the dock guards the browser's unload event while anything is running.
One more, smaller: the flipbook that wouldn't update
The reader's flipbook uses a page-flip library that copies its children into internal state and — with the flag that keeps it from rebuilding on every render — only refreshes that copy when the number of children changes. Every page mounts up front so the spine is correct, so the count never changes again, and a parent re-render can never deliver a newly loaded image.
Turning the flag off isn't the answer either; rebuilding the copy on every render is exactly what it exists to prevent. So pages stream into a small store that each page subscribes to itself and re-renders independently. The frozen elements stay valid because they hold components rather than markup, and the DOM nodes the library owns get updated in place.
Where it's still rough
The learning platform is the oldest code and shows it. It predates the shared auth package and still carries a small adapter shim in places where session objects are reshaped to match an older interface. It works, but it's the part I'd refactor next.
There's no unit test runner. Coverage is end-to-end specs, and those are deliberately pointed at regressions that are invisible by eye — network-request audits rather than "does the button click."
Quiz generation reads questions one document at a time, which is fine at the current question-bank size and is the next thing to batch.
The catalogue's ten-minute staleness window is a real trade, not a free win. It's the right call on this hosting setup, and it's documented as such rather than papered over.
Results
All four apps are live in production and run a real business. Customers place and track orders through the store; operators fulfil them, edit them, and reconcile payments through the console; students and parents use the learning platform; readers sign in to the secure library on bound devices and read books marked with their own identity. The nightly courier sweep closes out deliveries without anyone touching them.
The business moved from WhatsApp threads, Facebook DMs, loose PDFs and spreadsheets onto software where the catalogue is the source of truth, an order has a status rather than a memory, and a digital book that leaks can be traced back to the account it was issued to.
Built and shipped solo.