The stack behind empeeryal.com, the decisions that actually mattered, and the ones I'd make differently. Written while building it solo, with AI in the loop and real numbers attached.
This site is the portfolio piece. Not in the "here's a mockup" sense — in the sense that everything I'd build for a client is running here first, in production, where I have to live with it.
That makes it the most honest thing I can show you. It also means I can tell you what went wrong, which is the part of a build log worth reading.
Nothing exotic. The interesting decisions were about what not to add.
The site originally used a JavaScript animation library. Removing it and rebuilding every animation in CSS — plus one small IntersectionObserver for scroll reveals — cut mobile Total Blocking Time from around 670 ms to around 170 ms.
That's the largest single performance win in the project's history, and it came from deleting a dependency rather than adding one.
The lesson generalised: on a content site, the main thread is the scarce resource, and every client-side library spends it. The current state is Total Blocking Time in the low tens of milliseconds and Cumulative Layout Shift at zero — both effectively perfect — on a throttled mid-range Android over a slow connection.
Because a rule in a document stops nobody, that decision is now enforced by a test: adding an animation runtime or a heavy client library to the project's dependencies fails the build. Alongside it sits a byte budget that measures what each public page actually downloads and fails when a route grows past its recorded baseline.
Within a day of existing, the byte budget found a page carrying about 80 kB more JavaScript than every other route — and it was the client intake form, i.e. the single page on the site where a slow load costs money.
The cause was almost funny. The form imported some plain lists of strings — project types, budget bands — from the module that holds the validation schemas. That module imports a validation library at the top. So importing one array of strings pulled the entire validation runtime into the browser, to render a dropdown.
Moving those lists into their own dependency-free file took the page from about 353 kB to about 285 kB compressed — roughly 68 kB, a 19% cut — with no functional change whatsoever.
Two things I took from it. First: a measurement you don't have is a problem you can't see, and "it feels fast" is not a measurement. Second: the cost of an import isn't the thing you imported, it's everything that thing imports.
Caching that's boring. Public pages are statically generated and revalidate on a timer; every admin action that changes content explicitly purges the exact pages affected. Not the whole catalogue — just the changed page and the small, bounded set of pages that embed it. Purging everything looks safer and is actually worse: dozens of simultaneous regenerations race each other, and you can end up serving stale content longer than if you'd done nothing.
One place to change an affiliate link. Links in posts point at an internal redirect, and the destination lives in the database. Change it once, every post that references it updates instantly. The alternative — raw URLs pasted into fifty posts — is a maintenance debt that comes due at the worst possible time.
Payments that can only settle once. Stripe can and will deliver the same webhook twice, and the success page can arrive before the webhook does. Both paths run through a single atomic update, exactly one of them wins, and only the winner sends the email and grants access. Money code is the one place to be genuinely paranoid.
Semantic search without a vector database. Related-post matching uses embeddings, but the vectors live in an ordinary column and the similarity maths runs in plain code at write time. At this scale it's microseconds, and it means the site runs on stock Postgres everywhere including a laptop. The specialised tool becomes worth its complexity much later than you'd think.
I let the tag vocabulary sprawl. At one point there were 53 tags across 14 posts — which is not a taxonomy, it's noise. Tag pages only work as secondary hubs when they're dense, and dense requires discipline from post one. It took a consolidation pass and a tooling change to fix.
I under-invested in guardrails early. Several of the rules I care about most existed only as sentences in a document for months. Sentences don't survive contact with a refactor. Nearly everything load-bearing here now has a test whose failure message explains why the rule exists, because future-me is a different developer with no memory.
I added AI features before AI observability. Multi-provider text and image generation shipped before there was any log of what was called, what it cost, or whether it worked. Retrofitting that was more work than building it in would have been, and for a while I genuinely didn't know what the thing was spending.
Worth being precise, because the discourse is useless in both directions.
AI moved the bottleneck. It didn't remove it. Writing code stopped being the slow part; deciding what should exist, and verifying it actually does what it claims, became almost all of the work. A project like this generates far more plausible-looking code than one person can carefully review, and plausible-looking is exactly the failure mode.
What made it workable was unglamorous: tests that encode the reasoning rather than just the behaviour, a written record of why each decision went the way it did, and a habit of measuring instead of assuming. Those were good practices before. They're load-bearing now.
If you're considering hiring me to build something, this is the evidence rather than the pitch. Every technique above is running in production on the site you're reading, including the mistakes.
The guides below go into the individual pieces in detail.