Rebuilding My Portfolio with Next.js 16 and React Three Fiber
- Next.js
- React Three Fiber
- Three.js
- Tailwind CSS
- WebGL
- portfolio
This site used to be a Vite SPA. It worked, but a client-rendered portfolio is invisible to crawlers without workarounds, and I wanted something with real presence — a site that demonstrates the 3D and motion work I do instead of just listing it. So I rebuilt it from scratch: Next.js 16 App Router, Tailwind CSS v4, React Three Fiber, GSAP ScrollTrigger, and Lenis, fully statically generated.
Design system: monochrome plus one violence of color
The aesthetic is architectural monochrome: pure black base, charcoal cards (#0e0e0e–#1b1b1b), #292929 hairline borders, and exactly one accent — a deep purple (#a21caf) used like punctuation, not decoration. Typography is Geist Sans at extreme weight contrast (extralight headlines against medium emphasis) with Geist Mono for uppercase index labels — /01-style section numbers, category tags, status badges.
Tailwind v4 made the token system pleasant: everything lives in a single @theme block in CSS, so design tokens are real CSS custom properties, not a JS config file:
@theme {
--color-base: #000;
--color-line: #292929;
--color-accent: #a21caf;
--color-accent-bright: #d946ef;
}
The discipline is the design. With one accent color, every use of purple is a decision.
The 3D hero on a performance budget
The hero is an R3F canvas: a chrome torus-knot rotating inside a wireframe icosahedron shell with a handful of purple sparkles. The reflections come from a procedural Environment built from Lightformer planes — white key light plus two purple fills — which matters more than it sounds: no HDR file fetch, so the scene needs zero external assets and renders on first paint.
WebGL on a portfolio is a liability unless you budget it:
- DPR clamped to
[1, 1.75]— full retina DPR doubles fragment work for reflections nobody can distinguish. frameloopswitches between"always"and"never"via an IntersectionObserver — the moment the hero scrolls out of view, rendering stops entirely. No idle GPU burn while you read.- No post-processing chain. A
meshPhysicalMaterialat metalness 0.92 with a good environment gets you "expensive chrome" for free. prefers-reduced-motionswaps the canvas for a static gradient — the dynamic import (ssr: false) means those users never even download Three.js.
Lenis + GSAP, and the hash-link trap
Smooth scroll is Lenis driven by GSAP's ticker — the canonical integration — with ScrollTrigger reveals on every section. The non-obvious bug: native anchor navigation fights Lenis. Click a #work link and the browser jumps instantly while Lenis's internal scroll position still points at the old location; the page visibly tears.
The fix is routing hash links through Lenis itself. The instance is exposed as window.__lenis, and the navbar intercepts same-page hash clicks:
const handleNavClick = (href: string) => (e: MouseEvent) => {
if (href.startsWith("#") && pathname === "/") {
e.preventDefault();
window.__lenis?.scrollTo(href, { offset: -64 });
}
};
Same instance handles the mobile-menu scroll lock (lenis.stop()/start()) — once you adopt a scroll virtualizer, every scroll interaction on the page has to go through it.
Content as data, markdown as content
Everything renderable is data: projects, experience, and skills live in typed files under src/data/, so adding a project never touches a component. Blog posts are markdown with frontmatter, parsed at build time with gray-matter and unified/remark/rehype. One migration constraint was non-negotiable: every existing blog slug survives unchanged. URLs are the one part of a rebuild you don't get to redesign.
The whole site is SSG — generateStaticParams for posts, force-static route handlers for the RSS feed, generated sitemap.ts and robots.ts. Hosting is Netlify; the only deploy gotcha was that a site predating Netlify's Next.js auto-detection serves .next raw (hello, 404s) until you explicitly declare @netlify/plugin-nextjs in netlify.toml.
A second model as code reviewer
Before shipping I ran the whole codebase through an adversarial review by a different model family (Gemini, via agy-bridge) with instructions to find flaws, not give praise. It surfaced seven issues worth fixing, including two I'd genuinely missed:
- JSON-LD XSS — structured data was serialized with plain
JSON.stringifyinto a<script>tag; a<in any field could break out of it. Fixed by escaping every<as\u003cin the serializer. - Layout thrash — magnetic hover effects called
getBoundingClientRect()on everymousemove, forcing sync layout ~60×/second. The rect is now cached onmouseenter. - Timezone date drift —
toLocaleDateStringwithout an explicittimeZone: "UTC"shifts a post's date by a day for readers west of Greenwich.
Different model families have different blind spots; an adversarial pass from a second one is the cheapest code review you'll ever get.
The result is the site you're reading. Source conventions, design tokens, and the full project list live right here — black, charcoal, one purple.