How this site is built, and what I rebuilt to fix
React 19, Vite, TanStack Router, Supabase and a click dummy that is the visual specification. Including the outlet bug that made nine service pages render…
This site is a portfolio, which sounds like it should be a static page. It is not: sixty-nine project pages, nine service pages, a blog, a pricing page, an account area, twenty legal documents and a sixteen-screen admin panel — all reading from a database, all editable without a deploy.
The slug on this post says Vite 7. The tree is on Vite 8. The URL is indexed and frozen, and its history is worth more than the version number in it, so it stays — but I would rather say that plainly than quietly write around it.
The stack
React 19 with the compiler, TypeScript, Vite, TanStack Router for file-based routing with typed search params, TanStack Query for server state, Zustand for the little client state that is left, Tailwind v4 with CSS-first tokens, React Aria for headless primitives, and Supabase for the database with row-level security doing the authorization.
The rule underneath all of it is the same one that governs everything I run: it costs nothing to operate. That is not an afterthought — it is why there is no server rendering, why the read layer is built the way it is, and why storage and email go to my own platform.
The bug that justified a rebuild
The previous version had nine service pages at /services/<id>. Every one of them rendered the services list. The three-hundred-line detail component never mounted.
The cause was a layout route rendering the list directly instead of an outlet, with the detail route nested underneath it. Two lines.
What made it survive is the part worth learning from: the prerendered HTML was correct, because it was generated from data rather than from the rendered app. Every crawler saw nine distinct pages with the right titles and descriptions. Every human saw the same list nine times. Monitoring was clean. Search console was clean.
The layout route now renders nothing but its outlet, with a comment saying why, and every subsequent layout route in the codebase does the same.
The design is a specification, not a mood board
The biggest process change: before any application code existed, the entire interface was built as static HTML and Tailwind — every page, every state, wired together so it could be clicked through. Sixty-one screens.
Then the important part. It is not a reference somebody glances at; it ships a manifest declaring each page's band sequence, and a test parses that manifest and asserts the real application matches. A page that quietly restructures fails a gate instead of shipping.
That has already caught things. When the assertion was first written it went red on fifteen routes that had been approved on their own screenshots — pages rendering fewer sections than the approved design, which nobody had noticed because each one looked fine on its own. That debt is now a two-way ratchet: a route not on the list that mismatches fails, and a route on the list that starts matching also fails, so the baseline has to be struck off as it is fixed and cannot rot into a permanent exemption.
Three gates that exist because of a specific defect
- Every class is styled. An early wave invented a component-class vocabulary that typechecked, linted, built and rendered as unstyled markup. It looked deliberate. Now every class in the markup must have a rule in the stylesheets or appear in the generated utility output.
- Reachability. Every route must have at least one inbound link, and the check prints its denominator. A link checker cannot find this defect — a page nobody links has no dead href to detect.
- No unbounded read. Every function that reads rows must bound them. This started as a rule people were supposed to remember, until an unbounded query on another of my products exhausted a daily quota and took it offline for real users until midnight.
Every one of them gets watched failing against a planted defect before it is trusted. I have shipped a typecheck script that exited zero on genuinely broken code, because in a project-references layout the root config compiles nothing and --noEmit reports success over an empty set. A gate nobody has seen go red is a gate nobody has verified.
The database does the authorization
Row-level security on every table, and the client holds a publishable key that is public by design — the key is meant to be in the browser, and the policies are the boundary.
The five plan-administration capabilities are database functions rather than client updates, each checking admin status inside the function and writing its audit row in the same transaction, so there is no path that mutates without one. The columns those functions touch are revoked from the client role entirely.
And the trap that makes all of this need real verification: a policy filters. It does not refuse. A forbidden update returns 200 having changed nothing, so every refusal has to be proven by reading the row back rather than by looking at a status code.
The content model, and why almost nothing is hardcoded
A portfolio is usually a repository full of markdown, which is fine until you want to reorder something from a phone. Here everything a visitor reads is a row: projects, services, skills, the home page copy, the payment options, the legal documents.
The consequence I did not anticipate is how much it changes what "publishing" means. Adding a project is not a deploy, so the friction of keeping it current disappeared — and the previous version of this site had a projects list that was eight months stale, entirely because updating it meant opening an editor.
The exception is interesting. Interface text — labels, buttons, empty states, validation, error messages — is not in the database. It is in a translation catalogue in the repository, keyed and typed, so a missing key is a compile error rather than a blank space on a page. The line between the two is whether the words describe the product or are content in the product, and getting it wrong in either direction hurts: catalogue text in the database cannot be translated, and content in the catalogue cannot be edited without a deploy.
English only, and the mechanism complete anyway
There is one language, and there will be one language for a while. The layer underneath is finished regardless: typed keys, interpolation rather than concatenation, plurals through the platform's own rules, dates and numbers formatted for the locale, and the language and direction attributes applied before first paint.
The acceptance test is a single sentence — is adding a second language one new file and nothing else? If it needs a component edited, a string extracted or a plural form invented, the foundation is not built, however complete it looks.
I know this needs enforcing rather than intending, because another of my products shipped three complete waves with a correct translation layer sitting in the tree and not one call to it. A hardcoded string passes typecheck, lint, build, and every review that reads for correctness. So this project carries a lint rule that fails the build on user-visible text that never reached the translation layer — landed as a warning over three hundred and twenty-eight findings, swept to zero, then switched to an error and watched rejecting a planted string before I trusted it.
What is still ahead
Prerendering and the machine-readable files, the Android build, and the cutover itself. The site you are reading this on is still served by the previous tree until that last step — which is its own kind of honesty: writing about a rebuild while the thing being rebuilt is still the thing serving you.
https://aoneahsan.com/blog/portfolio-architecture-react-19-vite-7