For anyone nosey about the wiring
Under the hood
I keep this site small on purpose. If you like reading commit messages and READMEs more than marketing pages, you're in the right place. Here's how the pieces fit, what I'd repeat, and what I'd tweak if I started again tomorrow.
What this thing actually is
I run this on Next.js 15 (App Router) and React 19, hosted on Vercel. Each URL is a thin file in app/ that drops content into PublicLayout, tabs up top, profile header, then whatever lives in src/views. Nothing fancy: I wanted a single place to edit copy without spinning up Sanity or Contentful for a personal site.
Almost everything readable here starts as TypeScript defaults, then gets merged with whatever I've saved in data/site-content.json (or Blob in prod, more on that below). getMergedSiteConfig does that merge on the server and I cache it with unstable_cache so I'm not hammering storage on every request. Sitemap, robots, and manifest read the same merged object so I don't end up arguing with Google because my meta tags disagreed with my JSON.
First diagram is a normal flowchart bird's eye view; the block under it is the same vendor icons in a 2×2 grid with no connector lines; then merge and save sequence sketches.
Editing without shipping a redeploy
I added a tiny studio under /s/[cmsSlug] so I can fix a typo from my phone without opening a laptop. It's password-only, sets an httpOnly cookie, and PUT /api/cms/content only accepts partial JSON. I never write the whole merged tree back; that way the file stays small and I'm less likely to clobber something by accident.
On my machine I can still write data/site-content.json directly. On Vercel the filesystem isn't yours to keep, so I persist to Vercel Blob with BLOB_READ_WRITE_TOKEN. I burned an afternoon once on public vs private Blob URLs; now BLOB_STORE_ACCESS matches how the store is configured so reads don't 404 for silly reasons.
Contact, mail, and the usual extras
The form just POSTs to a route handler; Resend sends the email. I didn't want an API key living in the browser. Analytics and Speed Insights are the stock Vercel toggles, good enough for me to see whether I broke something, not a full observability story.
Calls I'm at peace with
- JSON instead of a real CMS. I like that I can diff my own content in git and I'm the only editor. I don't get a fancy media library or roles. Honestly I don't need them here.
- Saving patches, not full dumps. The API stores only what changed. That keeps the Blob object small, but it also means I can't hand-edit nonsense and expect the app to fix it. TypeScript and a careful eye are the only validation I really trust.
- Client components where it feels good. Theme toggle, motion, the scramble footer text, the studio. Those are client-side because the UX would suffer otherwise. The rest I try to keep boring and server-first so the bundle doesn't sprawl.
- Cached reads, not live Blob on every hit. I'd rather a short cache window plus revalidate after a save than pay latency to hit storage on every page view. It's a portfolio, not a stock ticker.
How I got here
- Shipped it from a Vite + React Router version first. I moved to the App Router to support server side logic, like sending mail through Resend, without needing a separate backend. Theme and
localStoragewere the SSR footguns I remember swearing at. Tailwind 4 and Framer mostly came along for the ride. - Pulled copy into one typed file. I got tired of hunting strings across components, so the baseline now lives in
src/constants/site.tsand robots/sitemap/manifest read from the same merged config instead of drifting apart. - Blob when I deploy, file when I'm home. The studio landed because I wanted to edit production without a redeploy, but I still like committing
data/site-content.jsonwhen I'm iterating on layout or content in a PR.
