I started rebuilding my portfolio this summer with a short brief: it should feel like mine, it should open fast, and I should be able to change any of it without a deploy. The first two pull against each other. The third turns what could have been a static site into one with a backend.

This is a write-up of how the pieces fit together, the problems that took longest, and what I settled on. Most of it applies to any content site that wants to be quick without being plain.

The shape of the system

Almost every request is someone reading, so I optimised for reads and accepted a bit more work on writes. Pages are React Server Components rendered on Vercel and cached as static HTML. A visitor's browser never talks to the database. It gets HTML, a few small interactive islands, and images from a separate storage domain.

Content lives in Convex: my profile, projects, posts, likes and the game leaderboard. Images and files live in Cloudflare R2 on their own domain. A private editor, which I call the Studio, writes to both. Product analytics end up in PostHog, but only after passing through the site's own pipeline.

System overview: the visitor, Next.js on Vercel with static pages and client islands, Convex, PostHog, Cloudflare R2 and the Studio
Reads come from cached HTML, writes come from the Studio, and events pass through an outbox before they leave.

A rich design on a light page

The design leans on texture: brushed silver plates, a monochrome portrait that picks up colour on hover, and a holographic glint on project cards. Each of those could easily have cost a library and a few hundred kilobytes.

The rule I followed was simple. If something can be HTML and CSS, it is. JavaScript runs only in small client components that need state: the header, the command palette, the live status strip and the terminal. Each game is its own chunk and loads when you start it. Everything else ships as markup.

Design system board: silver, ink and green colour tokens, the rainbow holo foil palette, the Geist type scale, components and corner radii
The tokens behind the look: eight colours, the holo foil that only appears on hover, one type family and three radii.

The rainbow foil shows how to keep an effect cheap. It's a conic gradient seen through a striped mask, and hovering only moves one pre-drawn layer with a transform, so nothing repaints while the pointer moves. On touch screens, where there's no hover, the same layers play as a slow pulse.

Scrolling was the subtle part. A few sections animate as they scroll into view, which means the browser marks styles dirty on every frame. Early on, the scroll rail read element positions inside its scroll handler, which forced a full layout each frame and made the top of the page stutter. The fix was to measure once on load and on resize, cache the numbers, and only ever animate opacity and transforms so the compositor does the work.

Order matters as much as size. The hero portrait and the status request are preloaded so they download alongside the HTML, and anything optional, like analytics, waits until the browser is idle. On a cold load from India, the first byte arrives in 83 ms and the largest paint lands at about 0.3 seconds.

Request waterfall for one cold load of the homepage, with first paint at 276 ms and largest paint at 304 ms
One cold load in Edge with an empty cache, in milliseconds from the start of navigation.

Editing without redeploying

I wanted to write and fix things from a browser, but I didn't want page views to depend on a database call. The answer is a cache that the editor is allowed to break.

All public reads go through one small content layer. Each query is cached under a shared tag and revalidated hourly. When I save something, the save clears that tag, so the next request renders fresh HTML while every other request keeps coming from cache. If the database is unreachable, the profile and projects fall back to copies that ship with the code, so the site stays up even when the backend doesn't.

Publishing flow in four steps: the editor, a server action that checks the session and sanitizes HTML, a Convex transaction, and the site clearing its content cache
What one save does: sanitize once, write once, and invalidate only the cached content that changed.

Posts and project write-ups use a Tiptap editor. It stores two things: the editor's structured document, which is what I edit, and HTML, which is what the site renders. The HTML is sanitized on the server against an allowlist on every save, so the site can render it directly without trusting the editor.

Charts, metrics, flow diagrams and galleries were harder. They're React components, and I didn't want stored HTML to be able to render arbitrary components. In storage each one is an empty placeholder carrying its type and a small JSON payload. When a page renders, the HTML is split at those placeholders, the payload is validated, and the real component goes in its place. A malformed block renders nothing instead of breaking the page.

Previews that never leak

The Studio shows the real page next to the editor, not a lookalike. That uses the framework's draft mode, which skips the cache and lets pages read unpublished content. The risk is obvious: draft mode is carried by the browser, and anything a browser carries can be copied.

So draft mode alone isn't enough. A page only reads drafts when the request also carries a valid editor session, and the public queries never return unpublished posts at all. Previews are also scoped to the tab that asked for one, so opening the site in another tab after an editing session shows exactly what visitors see.

The Studio's Site editor with the homepage sections on the left and the live page on the right
The Site editor: each section's controls on the left, the real page in draft mode on the right.

A terminal that owns the keyboard

The site has a terminal. Pages behave like folders, so cd projects takes you there, and it has six games: Snake, Space Defender, Type Racer, 2048, Minesweeper and a daily 2048 challenge.

The terminal's game picker showing six games
The game picker. Each card acts out its game on hover.

The hardest bug came from a browser extension. Some copy-paste "unlocker" extensions stop every key press before the page sees it. In Edge that meant Space scrolled the page and the games ignored the arrow keys. Listening on the window in the capture phase fixed it, because those listeners run before anything attached to the document. Focus needed the same care: games take focus without scrolling, and the terminal never calls scrollIntoView, which moves the page as well as the container.

Scores taught me about leaving. A run used to count only when it ended, so closing the tab mid-game threw it away. Now each game saves its score so far when you exit, switch tabs or close the window. Posting lives in a module that outlives the terminal, keeps a verification token ready while you play, and sends the last score with a keepalive request so it survives the page closing. The server still checks every score against the game's rules.

Analytics without tracking people

I want to know which pages get read and where people give up, not who they are. That rules out most off-the-shelf setups, so the site runs its own small pipeline and only forwards anonymised events.

Analytics pipeline: the browser, a same-origin endpoint that hashes the visitor, a Convex transaction, and delivery to PostHog
From a page view to a chart. Nothing that identifies a person is stored along the way.

The browser sends a page view once the page is idle, then the time the page was actually on screen and how far down it was read. Read depth comes from four invisible markers watched by an IntersectionObserver, so nothing measures the layout while you scroll. On the way out, a beacon carries the last update, so closing the tab doesn't lose it.

On the server a visitor is a one-way hash. By default it's built from values that change every day, so it can count visits without following anyone from one day to the next, and the inputs themselves are never stored. Visitors who choose to be remembered get a random id instead, which is what makes returning-visitor numbers possible. Each event is written to the database together with the counters it affects, in one transaction. A scheduled job then delivers it to PostHog and backs off and retries if PostHog is having a bad day.

Counting correctly took more thought than collecting. A reload shouldn't add a reader, so each post keeps one row per reader per day, and later updates only add the difference. The number shown under a post counts each reader once. Shares count when someone actually opens the shared link rather than when a button is pressed, because copying a link isn't sharing it.

What I'd keep

Most of the speed came from deciding what not to do on the hot path: no database on reads, no layout work while scrolling, nothing optional before the first paint. Most of the reliability came from writing things down before acting on them: content into a cache that's cleared on purpose, events into an outbox before they leave.

If I started again I'd keep the same order: settle a few visual rules, make reading fast, then add the fun parts where they don't get in the way of either.

You can browse the projects or open the terminal.