Skip to content
All posts

13 min read

Building This Portfolio: The Hard Parts

Next.js, i18n, Framer Motion, Three.js, AI

TL;DR: I rebuilt my portfolio from a hand-maintained bilingual HTML site into a Next.js 16 app, and the design was never the hard part. The time went into the invisible stuff: a framework that had quietly gone async, keeping the English and German copy from drifting apart, a loading screen that waits for a 3D globe to be ready, a self-typing terminal that turned out to be the fussiest thing on the page, and a deploy that built perfectly and then served nothing. The thread running through all of it: "it builds" is not the same as "it works."

This is my portfolio site, and this post is about building it, specifically the parts that turned out to be harder than they looked.

First, some context, because I think it explains most of the decisions that come later. The previous version of this site was plain HTML, CSS, and a little JavaScript. Honestly, nothing was wrong with it: it loaded fast and it did the job. But it had one flaw that grated a little more every time I touched it. The site is bilingual (English and German), and I'd built both versions by hand. Change a sentence in English, and I had to go find its German twin and change that too. Add a section, and I was copy-pasting markup and hoping I hadn't dropped a closing tag somewhere. Small edits were fine. Anything structural was a slog.

So I rebuilt it from scratch, and I went in wanting three things. It had to be properly bilingual, with one place to edit each piece of text instead of two. It had to feel a little alive (some motion, some personality) without turning into one of those sites that hijacks your scroll to show off. And it had to be something I'd actually enjoy coming back to and tinkering with. I built it on Next.js 16.

Here's the honest part, though: the design and the fun bits came together quickly. Almost everything that cost me real time was invisible: plumbing, tooling, and platform quirks that never show up in a screenshot. So think of this less as a tutorial and more as a build log: what I chose, why I chose it, and every wall I hit on the way.

The framework surprised me before I'd written anything

Before I could get to a single thing I'd actually planned, Next.js 16 changed a rule I'd taken for granted. Route params used to be a plain object you could read straight away. In this version they're a Promise. You have to await them. Which meant every page and every metadata function had to change shape:

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ locale: string; slug: string }>;
}) {
  const { locale, slug } = await params;
  // ...
}

The nasty part is what happens if you forget the await: no error, no warning, just undefined surfacing much later, deep inside some data lookup where the real cause is nowhere in sight. Once it clicked, the fix was mechanical, but it set the tone for the whole build. Read the docs that ship with the version you're on, not the ones you remember from last year.

Two languages, one source of truth

This was the whole reason for the rebuild, so I thought about it before writing much else. The thing I really wanted to design out was drift: the two language versions slowly falling out of agreement because I'd edited one and forgotten the other.

The rule I landed on is simple. Anything that's a fact (a name, a project link, a date) never gets translated, so it lives once in typed TypeScript, in content/data/*.ts. Anything a human actually reads as a sentence lives in the message catalogues, messages/en.json and messages/de.json. Structure in code, prose in the catalogues.

Routing is locale-prefixed with next-intl (/ for English, /de for German), and I used an as-needed prefix so the English URLs stay clean:

import { defineRouting } from "next-intl/routing";
 
export const routing = defineRouting({
  locales: ["en", "de"],
  defaultLocale: "en",
  localePrefix: "as-needed",
});

None of that fully saved me from myself, though. More than once I rewrote an English sentence and left its German twin stale, and nothing ever complained, because both strings are perfectly valid. They just no longer say the same thing. No test catches that. In the end the only fix was a habit: never touch one catalogue without opening the other in the next tab. (I'm aware of the irony of writing that in a post that keeps warning about drift.)

shadcn/ui, but not the one I remembered

I reached for shadcn/ui components out of pure muscle memory and walked straight into a wall. This setup is built on Base UI, not Radix, and the way you compose things is different. The Radix trick I lean on constantly, dropping my own element into a primitive with asChild, just isn't here. Base UI uses a render prop instead. So every place I wanted a custom element inside a component (a link inside a menu item, a button that's really an anchor) had to be rewritten. Each change was tiny; there were simply a lot of them, and copying the old pattern from memory only ever produced runtime errors. Another vote for reading first.

A globe that holds the door open

Here's a decision I want to explain, because on its own it sounds like over-engineering. The About section has an interactive globe of the cities I've lived and worked in, built with three.js and react-three-fiber. WebGL takes a beat to warm up, and I really didn't want someone's first impression of the site to be a half-drawn, stuttering globe.

So instead of hiding that wait, I leaned into it. The site opens with a little boot-up terminal loader (more on that in a second) and the trick is that the loader doesn't leave until the globe reports it's actually ready. The entrance covers the setup.

Under the hood it's just a small shared context. The globe calls markReady() when it first renders, and the loader waits on two things: its own typing animation and that signal. The obvious danger with "wait for X" is the day X never comes: a blocked WebGL context, a chunk that fails to load, a graphics driver from another decade. I didn't want a single visitor stuck staring at a loader forever, so there's a hard backstop:

// If the globe never fires onReady (WebGL blocked, slow device, chunk
// failed to load), don't leave visitors stuck behind the loader forever.
const FALLBACK_READY_MS = 8000;

After eight seconds the loader lets go regardless of what the globe is doing. Nobody gets trapped behind a spinner because of their GPU.

The terminal that types itself

The loader isn't a spinner. It's a fake terminal that types out real-looking commands, "runs" them, and streams module loaded lines like a boot sequence. I built it because it suited the personality I wanted, and because it turns a dead loading moment into something worth watching for a couple of seconds. What I did not expect was for it to become the single fussiest component on the whole page.

The sounds were the first fight. Each simulated keypress plays a slice of a mechanical-keyboard sample through the Web Audio API. Two problems showed up almost immediately. First, browsers won't start an AudioContext without a real user gesture, and a script typing on its own is very much not a gesture. So the terminal now starts muted, with a small speaker toggle; sound only wakes up once you click it yourself. Which is the right call anyway. Nobody wants a website making noise at them unprompted.

The second problem was stranger: the sounds started stuttering and re-playing during renders that had nothing to do with typing. This one took me a while to pin down. The cause was referential stability. The functions that play a key were dependencies of the typing effect, and every re-render handed them brand-new identities, which cancelled and rescheduled the pending keystroke and re-triggered its sound. Wrapping them so they only ever touch refs made them stable:

// Only refs are touched here, so these callbacks stay referentially stable
// across renders. That matters: they're dependencies of the typing effect,
// and unstable identities made every unrelated re-render cancel and
// re-schedule the pending keystroke (replaying the key-down sound).
const playSound = useCallback((sound) => { /* ... */ }, []);

Then there was the cost of all that re-rendering. In my first version, every single character typed re-rendered and re-tokenized the entire scrollback. Once I saw it, the fix was obvious: lines that have already printed never change, so memoizing them means typing a new line only re-renders that one line. The blinking cursor got the same treatment: it started as a setInterval flipping React state every 530ms, re-rendering the whole terminal just to flash one box, and became a plain CSS animation instead. Those two changes were the whole difference between a janky loader and a smooth one.

One small accessibility detail I'm quietly pleased with: while the loader is up, it uses inert rather than aria-hidden. The sound toggle is focusable, and aria-hidden on something that contains a focused element trips a browser warning. inert sidesteps it by releasing focus entirely instead of just hiding a node that still has it.

Motion everywhere, without the jank

By this point the page was doing a lot at once: an aurora glow behind the hero, a scroll-linked streak effect, a rippling grid in the footer, skill cards that tilt, a pixelated portrait, an animated border around the contact form. Each one was fine on its own. Put together, they dropped frames.

So I did a deliberate performance pass with one rule I kept coming back to: the compositor only ever gets transform and opacity, and nothing animates while it's off-screen. In practice that came down to a lot of small, specific fixes:

  • The aurora drifts a gradient strip with transform instead of animating background-position, so that heavily-blurred layer paints once instead of every frame.
  • The footer ripple dropped will-change-transform from its 200-plus cells (it was costing more GPU memory than it saved) and moved its glow off box-shadow onto an opacity-animated pseudo-element, so the cells stop repainting.
  • The globe caps devicePixelRatio at 2 and freezes its render loop entirely (frameloop="never") whenever it's scrolled out of view.
  • The contact border caches its SVG path length instead of re-measuring it every frame, and stops its loop when it's off-screen.
  • The pixel avatar stops redrawing its ~1,400 dots once the hover distortion settles back into a still image.

And a MotionConfig provider makes the motion/react animations respect prefers-reduced-motion, not just the CSS ones. If your system is set to reduce motion, the site quiets down to match.

The blog, and a bug that wasn't there

The post you're reading is MDX, compiled with next-mdx-remote/rsc and highlighted by rehype-pretty-code. Posts share one filename across both locales, and a missing translation just falls back to English, the same fallback idea running through the rest of the site.

The one genuinely maddening moment here: the article typography (Tailwind's prose classes) rendered completely unstyled under npm run dev. I very nearly tore out and rewrote the entire typography layer chasing a bug that didn't actually exist. It's a Turbopack dev-mode quirk in how the Typography plugin gets loaded, and the production build emits the styles just fine. That one's filed away permanently: check against npm run build before you panic.

"Ask my résumé": a chatbot version of me

Down in the bottom-right corner there's a small AI assistant you can ask about my experience. I added it partly because it's genuinely useful and partly because it's a fun thing to have on a developer's site. It streams answers from Gemini 2.5 Flash through the Vercel AI SDK (cheap and fast), grounded in a system prompt built from my actual résumé data:

const result = streamText({
  model: google("gemini-2.5-flash"),
  system: SYSTEM_PROMPT,
  messages: await convertToModelMessages(messages),
  temperature: 0.3,
  maxOutputTokens: 600,
});
return result.toUIMessageStreamResponse();

One thing I cared about: the site shouldn't break if the API key isn't set. So the route checks for a missing key and returns a clean 503 instead of throwing, and the chat just says, politely, that it isn't configured yet. Everything else on the page carries on exactly as before.

The deploy that built fine and served nothing

This was the last wall, and the most humbling, because there was nothing wrong with the code. I pushed to Vercel, the build went green, and every single route came back 404. A completely successful build serving absolutely nothing.

It took me a while to find, mostly because I kept looking in the code, which is exactly where the problem wasn't. The project had somehow been created with its framework set to null, so Vercel was serving my output as plain static files with no Next.js routing at all. One API patch to set the framework to nextjs and the whole site came alive. Then a second surprise right behind it: the team's default "Vercel Authentication" had quietly dropped a login page in front of the entire public site, which had to be switched off. And a third, smaller one: the email on the git commit has to match a verified GitHub address, or the deploy is rejected outright.

None of these were in my code. All of them were in the platform. That's the part of "shipping" the tutorials tend to skip right over.

What I'd tell myself at the start

A few things kept coming up, so those are the ones I'd hand to past me. Read the docs that ship with your version. The async-params change alone would have saved an afternoon. Treat every animation as free until it isn't, and measure before adding the next one. And keep in mind that "it builds" and "it works" are two different claims: the globe fallback, the muted-by-default sound, and the Vercel 404 were all things that only went wrong the moment I stepped off the happy path.

What I ended up with is a site I genuinely like maintaining, which was the whole point of the rebuild. Next on the list is making that résumé chatbot a little sharper, and, apparently, writing more of these. Thanks for reading.

All posts