This site is a portfolio, and it is also the thing that edits the portfolio. Every word on it: the poems, the essays, the CV, the photographs in the exhibition, everything is written and arranged in a studio.
Nothing here needs a deploy to change a job title.
That one decision is responsible for most of what follows.
The shape of the problem
A personal site is usually one of two things: a folder of Markdown you rebuild when you write something, or it's a CMS you rent (thought about using Sanity from the beginning).
The first stops being fun the moment you want to reorder photographs from your phone. The second means your writing lives in someone else's product, laid out to someone else's taste.
And what’s fun in that?
I wanted the third thing instead: a site that looks hand-built because it is, with an editor behind it good enough that I actually use it (even right now).
That means the site is a full-stack application with an admin surface, an auth story, a file-upload story and a migration story; all for an audience of one writer and a few thousand readers.
It also has to run on Vercel, where the filesystem is read-only. So "just write to disk" was off the table from the start, which shaped the data layer more than any other constraint.
The stack
Next.js 16 (App Router), React 19, TypeScript, Tailwind v4.
Server components do the reading, client components do the editing, and the boundary between them maps almost exactly onto the boundary between the public site and the studio.
Supabase for Postgres, auth and object storage.
All of it plain SQL in the repo — no ORM. Queries are small and specific enough that an ORM would have been a layer to learn rather than a layer to lean on.
TipTap for writing
Over a dozen extensions plus custom nodes for the things this site has that a generic editor doesn't: stanzas for poetry, pull-quotes, figures with captions, callouts, and code blocks highlighted by Shiki when rendered and Lowlight while being typed.
Three.js, via react-three-fiber, drei and rapier, for the exhibition
A hallway of rooms you walk through, with the photographs hung on the walls, and for the lanyard that swings on the “Through my Lens“ page.
The Fonts powering the entire thing
Geist Sans and Geist Mono for everything structural, Caveat where the site is speaking in the author's handwriting, and Mrs Saint Delafield for signatures on the guestbook wall. Type is the only ornament; the whole site is black, white and hairlines.
Two stores behind one interface
Every kind of content is read through a store interface with two implementations: one that talks to Postgres, one that reads and writes JSON files under content/.
publicStore() → supabaseConfigured ? databaseStore(client) : fileStore
The file store isn't a toy. It's what makes git clone && pnpm dev produce a complete, working, populated site with no account anywhere. And because the studio also writes through that interface, you can edit locally with no Supabase at all and the changes land in JSON files you can commit.
In production, where the filesystem is read-only, the database store takes over. The site never has to know which one it's talking to.
Underneath both is a third fallback: the CV as written in src/content/profile.ts. Empty tables fall back to it, so a fresh copy of the site is never blank, and the first write in the studio copies it into Postgres.
That copy is more interesting than it sounds. Next prefetches the pages linked from the studio's overview, so several server renders are in flight at once, and a check-then-insert had every one of them find the tables empty and fill them. The CV seeded three times over.
The fix is to claim the job with an atomic upsert of a marker row first: whoever gets the row does the seeding, everyone else gets nothing back and leaves it alone.
What the studio edits
Four surfaces, four shapes of data:
Entries: essays, poems and journal stories, one table with a
collectioncolumn, each a TipTap document stored as JSONB.The profile: jobs, projects, and the short lists beside them (education, honours, leadership). Ordered by an integer
position.Memories: the exhibition: rooms, and the photographs hung in them. A room carries a wing of the hallway, a wall colour, a date, a place and a featured photograph.
The signing wall: what visitors write in the guestbook, and the ability to take any of it off.
A project carries three pictures: a thumbnail for its card, and two captures of the page itself. One taken on a desktop, one on a phone, because the showcase stands a browser window and a phone side by side with the real page scrolling inside both.
Saving, which is where the work actually was
The first version of the studio had three different saving models, because three surfaces were built at three different times (thanks to Claude for helping to map everythign together).
The editor saved on ⌘S.
The profile held a draft and saved a section at a time.
The gallery saved every action the moment you took it.
Each was defensible; together they were a mess, and the mess hid a bug: saving one thing silently discarded everything else you'd typed on the page, because the server's response replaced the draft wholesale.
They now share two pieces.
A save queue. Requests go out one after another so they land in the order they were made. Each response carries the version the data is now at. Each request can carry the version it was made against, and if the server finds the page has moved on, it refuses the write and hands back the newer copy instead of writing over it. That's how two open tabs stop silently overwriting each other.
A draft. It knows what's unsaved, warns before you leave, and keeps itself in browser storage as you type, so a closed tab or a crash doesn't take the work with it. Crucially, when a newer copy arrives from the server it's rebased underneath the edits still on screen: fields you've changed win, everything else takes the server's value.
Underneath, three things became single statements in Postgres rather than loops in TypeScript:
Putting a list in order was one round trip per row, executed serially. It's now one
update … from unnest(ids) with ordinality.Moving photographs between rooms is one statement that sets room and position together, and releases any room that just lost its featured photograph.
A section's worth of edits is one function call inside one transaction. It lands whole or not at all; before, a failure halfway left the tables half-written.
And reordering; five different lists across two surfaces, each with its own arrow buttons, became one primitive. Drag the grip, or take hold of it with space and use the arrow keys, Home and End for the ends, escape to put it back, each move announced. A run of key presses debounces into a single save.
Deleting things you might want back
Deleting a photograph used to remove the file from storage immediately, so a misplaced click was final. Now a delete marks the row, row-level security hides it from visitors, the studio offers to put it back, and a purge function removes anything that's been in the bin for thirty days, rows and files together.
Uploads have limits, and the point is that you're told about them beforehand rather than after a long upload fails: a room holds 200 photographs, a hundred can go up at once, and the room's heading says how many more will fit. Photographs are resized in the browser to a 2400px long edge before they're sent, which also drops their EXIF data (location included, which matters for photographs taken at home if you took a photo with your iphone and it saves your location metadata as id for the photo).
Images
Uploads go to a storage bucket and are served through Next's image optimizer, restricted to that bucket by remotePatterns. One quality for every image, AVIF and WebP, and a month-long cache because an upload never changes once it's saved.
Oversized files are redrawn in the browser before they're sent. A full-page phone capture is a tall PNG of several megabytes (comfortably over what a hosted request will carry), and the limit the route names was above that, so the file never reached the route to be turned away politely.
Anything too wide or too heavy is re-encoded client-side, where its size is already known: a seven megabyte capture goes up as about one.
Two things I got wrong
The interesting part of any build is where it broke.
A cleanup routine that deleted the wrong files.
When a project's picture is replaced, the old file should be removed from storage. The function that worked out which files were orphaned was handed the change where it needed the record. A change carries only the field you edited, so uploading a desktop capture looked like a project that had let go of its thumbnail and its phone capture, and both files were deleted.
Every upload destroyed the other two; exactly one file survived per project, the last one uploaded.
The lesson isn't "be careful with deletes", it's narrower and more useful: a partial update and a complete record are different types and should never be interchangeable. They had the same shape, so nothing complained. The fix reads the record back after the write and compares it with how it was, which doesn't depend on reasoning about deltas at all.
An interaction rule that was right in the small and wrong in the whole.
The captures in the project showcase scroll on their own. To stay out of the way, the drift paused when the pointer entered the frame (sensible in isolation, and wrong in place), because the showcase sits in the middle of the page where the cursor rests most of the time. The feature looked broken. It now watches for the scroll position moving further than the drift moved it, which is the actual signal; a person scrolling, rather than a proxy for it.
