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
transforminstead of animatingbackground-position, so that heavily-blurred layer paints once instead of every frame. - The footer ripple dropped
will-change-transformfrom its 200-plus cells (it was costing more GPU memory than it saved) and moved its glow offbox-shadowonto an opacity-animated pseudo-element, so the cells stop repainting. - The globe caps
devicePixelRatioat 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.