How this site is built.
Most portfolios tell you what their clients got. This one also tells you how the portfolio itself is made. If something here is useful for your own project, help yourself.
Stack.
- Framework
- Next.js 16 (App Router) on React 19
- Language
- TypeScript everywhere
- Styles
- Tailwind CSS v4 with @theme tokens; light by default, dark via a data-theme attribute set by an inline bootstrap script (no flash)
- Motion
- framer-motion, throttled carefully on mobile
- Hosting
- Vercel Pro, SSR mode
- Data
- Vercel KV (Upstash Redis) for the portfolio config, rate limits + any toggle-without-deploy flags
- Chat
- Claude Haiku answering from a written knowledge base, with Voyage embeddings picking the passages each question gets
- Forms
- Own server endpoint, delivered via Resend
- Analytics
- Self-hosted Umami (no cookies, no stored IP addresses) + Vercel Speed Insights. A sample of visits is recorded as click maps and replays - the privacy page says exactly what
- Auth (admin)
- NextAuth v5 with GitHub OAuth
Typography.
Four typefaces, all self-hosted via next/font and cut down to the characters, weights and widths the site uses. Together they come to 239 KiB, down from 578:
- Geist Sans
- Body text, form fields, the chat. Variable weight, upright only. The workhorse.
- Bricolage Grotesque
- Headings. Variable wdth + wght. Headlines stack a wdth-100 line over a wdth-75 line for the editorial contrast that runs the brand.
- Fraunces (italic only)
- Editorial accents - phrase-level emphasis, sub-titles, FAQ questions. opsz axis for optical sizing. Italic only, weights 400 to 500, which is all the site sets it in.
- Geist Mono
- Marginalia, page-numbers, hung labels, domains, project indices. Treats numerals as a typographic citizen, not a fallback.
Performance.
A live PageSpeed Insights run is one click away. These are from Lighthouse run against all 21 public routes on 13 October 2026:
- Desktop perf
- 98 / 100 average, 96 to 99 across the routes
- Mobile perf
- 81 average on Lighthouse's throttled phone profile, from 75 on the home page to 90
- Accessibility
- 100 on all 22 routes, WCAG AA contrast
- Best practices
- 100 across the site
- SEO
- 100 across the site
- Homepage HTML
- 19 KB transferred on first load
- Portfolio images
- WebP q88 @ 1600w max, 80-150 KB each after a hard audit (PNG originals were 2-3MB)
- Regression guard
- Lighthouse CI runs on every PR against the Vercel preview, asserts on thresholds
- Late fonts
- Lighthouse always gets the fonts before first paint, so it cannot see a page jump when they arrive late. A script holds them back 3 seconds and measures the shift on every route; all are under 0.1
How the portfolio screenshots work.
A Puppeteer script at scripts/screenshots.mjs hits every live client site, waits for animations to settle, dismisses common cookie banners, and writes a PNG at 1280x720 @ 2x. The PNGs get piped through sharp to produce the WebP versions used in production. The same script pipes frames of a soft scroll into ffmpeg for the silent VP9 loops on the home page, one per project at desktop and phone size. A loop only plays on the front card once the wheel has come to rest, and not at all on data saver.
Theme bootstrapping.
Light is the default; the toggle is backed by CSS custom properties under a [data-theme] attribute on <html>. An inline <script> in the head reads localStorage.theme and sets the attribute before first paint, so anyone who picked dark never sees a light flash.
Accessibility.
Semantic HTML first, ARIA where it adds value. Every interactive element is keyboard-navigable with a visible focus ring. Motion-heavy sections respect prefers-reduced-motion. Text contrast is WCAG AA in both themes, verified via tokens rather than eyeballing hex values.
Bits I'd do differently.
No site is a straight line. A few retros from the build:
- I shipped a WebGL domain-warped gradient shader as the hero background because it looked premium on my desktop. Mobile Lighthouse promptly returned a 41 and 167 seconds of blocking time. I spent a session progressively throttling it before admitting two different heroes for two different viewports was a worse outcome than one editorial hero everywhere. That hero has since made way for the project wheel.
- The "Lighthouse 95+" badge in the footer is true on desktop (avg 98) and aspirational on mobile (avg 81). The PSI link is one tap away so anyone can check.
- The first portfolio data pass had V Clarke Books' headline metric reading "Author-managed via Decap CMS" - technically true, but a tool name is not an outcome. The CMS has since changed to Sveltia and the metric still names it. Still on the backlog to replace with a real figure.
- Body text was meant to be Geist Sans from the start. For months it was the visitor's system font: the font classes sat on
<body>where the stylesheet could not see them, and nothing errored. I only found out when I went to cut the font files down and saw nothing was drawn in it. - The first Lighthouse sweep surfaced a 3.5MB PNG hero photo and five portfolio screenshots totalling ~10MB. The second sweep surfaced that I had been compressing them with the wrong settings for a week. Lesson: measure the bytes on the wire, don't trust the build report.