13 Min. Lesezeit
Dieses Portfolio bauen: Die schwierigen Stellen
Next.js, i18n, Framer Motion, Three.js, AI
TL;DR: Ich habe mein Portfolio von einer handgepflegten, zweisprachigen HTML-Seite zu einer Next.js-16-App neu gebaut, und das Design war nie das Schwierige. Die Zeit ging ins Unsichtbare: ein Framework, das klammheimlich asynchron geworden war, die englischen und deutschen Texte davon abhalten, auseinanderzudriften, ein Ladebildschirm, der auf einen 3D-Globus wartet, ein selbsttippendes Terminal, das sich als das Kniffligste auf der ganzen Seite entpuppte, und ein Deploy, der perfekt baute und dann nichts auslieferte. Der rote Faden durch alles: „Es baut" ist nicht dasselbe wie „Es funktioniert".
Das hier ist meine Portfolio-Seite, und in diesem Beitrag geht es darum, sie zu bauen, genauer gesagt um die Stellen, die sich als schwieriger herausstellten, als sie aussahen.
Zuerst etwas Kontext, denn ich glaube, er erklärt die meisten Entscheidungen, die später kommen. Die vorige Version dieser Seite war reines HTML, CSS und ein bisschen JavaScript. Ehrlich gesagt war daran nichts falsch: Sie lud schnell und erfüllte ihren Zweck. Aber sie hatte einen Makel, der mich mit jeder Änderung ein bisschen mehr nervte. Die Seite ist zweisprachig (Englisch und Deutsch) und ich hatte beide Versionen von Hand gepflegt. Ändere ich einen englischen Satz, musste ich sein deutsches Pendant suchen und ebenfalls ändern. Füge ich einen Abschnitt hinzu, kopierte ich Markup hin und her und hoffte, kein schließendes Tag verloren zu haben. Kleine Änderungen waren in Ordnung. Alles Strukturelle war eine Plackerei.
Also habe ich sie von Grund auf neu gebaut, und ich ging mit drei Wünschen hinein. Sie sollte richtig zweisprachig sein, mit einer einzigen Stelle, an der man jeden Textbaustein bearbeitet, statt zwei. Sie sollte sich ein bisschen lebendig anfühlen (etwas Bewegung, etwas Persönlichkeit) ohne zu einer dieser Seiten zu werden, die dein Scrollen kapern, um anzugeben. Und sie sollte etwas sein, zu dem ich gerne zurückkehre und herumbastle. Gebaut habe ich sie auf Next.js 16.
Der ehrliche Teil aber ist: Das Design und die schönen Spielereien waren schnell fertig. Fast alles, was mich wirklich Zeit kostete, war unsichtbar: Klempnerarbeit, Tooling und Plattform-Eigenheiten, die auf keinem Screenshot auftauchen. Betrachte das hier also weniger als Tutorial und mehr als Baubericht: was ich gewählt habe, warum ich es gewählt habe, und jede Wand, gegen die ich unterwegs gelaufen bin.
Das Framework überraschte mich, bevor ich irgendetwas geschrieben hatte
Bevor ich auch nur zu einer einzigen Sache kam, die ich tatsächlich geplant hatte, änderte Next.js 16 eine Regel, die ich als selbstverständlich hingenommen hatte. Route-Parameter waren früher ein einfaches Objekt, das man direkt auslesen konnte. In dieser Version sind sie ein Promise. Man muss sie erst auflösen. Das bedeutete, dass jede Seite und jede Metadaten-Funktion ihre Form ändern musste:
export default async function BlogPostPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
// ...
}Das Fiese daran ist, was passiert, wenn man das await vergisst: kein Fehler,
keine Warnung, nur ein undefined, das viel später auftaucht, tief in
irgendeinem Datenzugriff, wo die eigentliche Ursache nirgends zu sehen ist.
Sobald es Klick gemacht hatte, war die Korrektur mechanisch, aber sie gab den
Ton für den ganzen Bau vor. Lies die Doku, die mit der Version ausgeliefert
wird, die du benutzt, nicht die, an die du dich von letztem Jahr erinnerst.
Zwei Sprachen, eine Quelle der Wahrheit
Das war der ganze Grund für den Neubau, also habe ich darüber nachgedacht, bevor ich viel anderes schrieb. Das, was ich wirklich ausschließen wollte, war Drift: dass die beiden Sprachversionen langsam auseinanderlaufen, weil ich eine bearbeitet und die andere vergessen habe.
Die Regel, auf die ich mich festlegte, ist simpel. Alles, was ein Fakt ist (ein
Name, ein Projekt-Link, ein Datum) wird nie übersetzt und lebt daher genau
einmal in typisiertem TypeScript, in content/data/*.ts. Alles, was ein Mensch
als Satz liest, lebt in den Message-Katalogen, messages/en.json und
messages/de.json. Struktur im Code, Fließtext in den Katalogen.
Das Routing ist sprachpräfixiert mit next-intl (/ für Englisch, /de für
Deutsch), und ich habe ein as-needed-Präfix genutzt, damit die englischen URLs
sauber bleiben:
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "de"],
defaultLocale: "en",
localePrefix: "as-needed",
});Ganz vor mir selbst hat mich das trotzdem nicht bewahrt. Mehr als einmal habe ich einen englischen Satz umgeschrieben und sein deutsches Pendant veraltet stehen lassen, und nie hat sich etwas beschwert, weil beide Strings vollkommen gültig sind. Sie sagen nur nicht mehr dasselbe. Kein Test fängt das. Am Ende war die einzige Lösung eine Gewohnheit: nie einen Katalog anfassen, ohne den anderen im nächsten Tab zu öffnen. (Mir ist die Ironie bewusst, das in einem Beitrag zu schreiben, der ständig vor Drift warnt.)
shadcn/ui, aber nicht das, an das ich mich erinnerte
Aus reinem Muskelgedächtnis griff ich zu den shadcn/ui-Komponenten und lief
geradewegs gegen eine Wand. Dieses Setup basiert auf Base UI, nicht auf
Radix, und die Art, Dinge zu komponieren, ist eine andere. Der Radix-Kniff, auf
den ich mich ständig verlasse, mein eigenes Element per asChild in ein
Primitive zu stecken, gibt es hier schlicht nicht. Base UI nutzt stattdessen ein
render-Prop. Also musste jede Stelle, an der ich ein eigenes Element in einer
Komponente wollte (ein Link in einem Menüpunkt, ein Button, der eigentlich ein
Anker ist), neu geschrieben werden. Jede Änderung war winzig; es waren nur eben
viele davon, und das alte Muster aus dem Gedächtnis zu kopieren erzeugte immer
nur Laufzeitfehler. Noch eine Stimme fürs Zuerst-Lesen.
Ein Globus, der die Tür aufhält
Hier ist eine Entscheidung, die ich erklären möchte, weil sie für sich genommen
nach Over-Engineering klingt. Der Über-mich-Bereich hat einen interaktiven
Globus der Städte, in denen ich gelebt und gearbeitet habe, gebaut mit three.js
und react-three-fiber. WebGL braucht einen Moment zum Hochfahren, und ich
wollte wirklich nicht, dass jemandes erster Eindruck der Seite ein halb
gezeichneter, ruckelnder Globus ist.
Statt diese Wartezeit zu verstecken, habe ich mich also darauf eingelassen. Die Seite öffnet mit einem kleinen Boot-Terminal-Loader (dazu gleich mehr) und der Kniff ist, dass der Loader nicht verschwindet, bis der Globus meldet, dass er tatsächlich bereit ist. Der Auftritt überdeckt das Hochfahren.
Unter der Haube ist das nur ein kleiner geteilter Context. Der Globus ruft beim
ersten Rendern markReady() auf, und der Loader wartet auf zwei Dinge: seine
eigene Tipp-Animation und dieses Signal. Die offensichtliche Gefahr bei „warte
auf X" ist der Tag, an dem X nie kommt: ein blockierter WebGL-Context, ein
Chunk, der nicht lädt, ein Grafiktreiber aus einem anderen Jahrzehnt. Ich wollte
nicht, dass ein einziger Besucher für immer auf einen Loader starrt, also gibt es
ein hartes Sicherheitsnetz:
// Falls der Globus onReady nie auslöst (WebGL blockiert, langsames Gerät,
// Chunk konnte nicht geladen werden), sollen Besucher nicht für immer hinter
// dem Loader festhängen.
const FALLBACK_READY_MS = 8000;Nach acht Sekunden lässt der Loader los, egal was der Globus gerade tut. Niemand bleibt wegen seiner GPU hinter einem Spinner gefangen.
Das Terminal, das sich selbst tippt
Der Loader ist kein Spinner. Er ist ein falsches Terminal, das echt wirkende
Befehle tippt, sie „ausführt" und module loaded-Zeilen wie eine Boot-Sequenz
ausgibt. Ich habe ihn gebaut, weil er zu der Persönlichkeit passte, die ich
wollte, und weil er einen toten Lademoment in etwas verwandelt, das ein paar
Sekunden lang sehenswert ist. Womit ich nicht gerechnet hatte: dass er zur mit
Abstand kniffligsten Komponente der ganzen Seite werden würde.
Die Töne waren der erste Kampf. Jeder simulierte Tastendruck spielt über die Web
Audio API einen Ausschnitt eines mechanischen Tastatur-Samples ab. Zwei Probleme
tauchten fast sofort auf. Erstens starten Browser keinen AudioContext ohne
echte Nutzergeste, und ein Skript, das von allein tippt, ist ganz sicher keine
Geste. Also startet das Terminal jetzt stummgeschaltet, mit einem kleinen
Lautsprecher-Schalter; der Ton erwacht erst, wenn du selbst darauf klickst. Was
ohnehin die richtige Entscheidung ist. Niemand will eine Website, die einen
ungefragt anlärmt.
Das zweite Problem war seltsamer: Die Töne begannen zu stottern und sich zu wiederholen, bei Renderings, die nichts mit dem Tippen zu tun hatten. Das zu orten hat gedauert. Die Ursache war die referentielle Stabilität. Die Funktionen, die eine Taste abspielen, waren Abhängigkeiten des Tipp-Effekts, und jedes Re-Rendering gab ihnen brandneue Identitäten, was den anstehenden Tastenanschlag abbrach, neu ansetzte und dessen Ton erneut auslöste. Sie so zu kapseln, dass sie nur Refs berühren, machte sie stabil:
// Hier werden nur Refs berührt, also bleiben diese Callbacks über Renderings
// hinweg referentiell stabil. Das ist wichtig: sie sind Abhängigkeiten des
// Tipp-Effekts, und instabile Identitäten ließen jedes unbeteiligte
// Re-Rendering den anstehenden Tastenanschlag abbrechen und neu ansetzen
// (und den Key-down-Ton erneut abspielen).
const playSound = useCallback((sound) => { /* ... */ }, []);Dann war da noch der Preis all dieser Re-Renderings. In meiner ersten Version
renderte jeder einzelne getippte Buchstabe den gesamten Scrollback neu und
tokenisierte ihn neu. Sobald ich es sah, war die Lösung offensichtlich: Zeilen,
die bereits ausgegeben sind, ändern sich nie mehr, also bedeutet ihre
Memoisierung, dass das Tippen einer neuen Zeile nur diese eine Zeile neu rendert.
Der blinkende Cursor bekam dieselbe Behandlung: er begann als setInterval, das
alle 530 ms React-State umschaltete und das ganze Terminal neu renderte, nur um
ein Kästchen aufblitzen zu lassen, und wurde stattdessen zu einer reinen
CSS-Animation. Diese beiden Änderungen waren der ganze Unterschied zwischen einem
ruckelnden und einem flüssigen Loader.
Ein kleines Barrierefreiheits-Detail, über das ich mich still freue: Solange der
Loader oben ist, nutzt er inert statt aria-hidden. Der Ton-Schalter ist
fokussierbar, und aria-hidden auf etwas, das ein fokussiertes Element enthält,
löst eine Browser-Warnung aus. inert umgeht das, indem es den Fokus ganz
freigibt, statt einen Knoten zu verstecken, der ihn noch hat.
Überall Bewegung, ohne das Ruckeln
Inzwischen tat die Seite eine Menge gleichzeitig: ein Aurora-Leuchten hinter dem Hero, ein scroll-gekoppelter Streifeneffekt, ein rippelndes Raster im Footer, kippende Skill-Karten, ein pixeliges Porträt, ein animierter Rahmen um das Kontaktformular. Jedes für sich war in Ordnung. Zusammengenommen ließen sie Frames fallen.
Also machte ich einen bewussten Performance-Durchgang mit einer Regel, zu der ich
immer wieder zurückkam: Der Compositor bekommt nur transform und opacity, und
außerhalb des Bildschirms wird nichts animiert. In der Praxis lief das auf viele
kleine, konkrete Korrekturen hinaus:
- Die Aurora treibt einen Farbverlaufsstreifen per
transforman, stattbackground-positionzu animieren, sodass diese stark verwaschene Schicht einmal gezeichnet wird statt jeden Frame. - Das Footer-Ripple entfernte
will-change-transformvon seinen über 200 Zellen (es kostete mehr GPU-Speicher, als es einsparte) und verlegte sein Leuchten vonbox-shadowauf ein opacity-animiertes Pseudo-Element, sodass die Zellen nicht mehr neu zeichnen. - Der Globus deckelt
devicePixelRatiobei 2 und friert seine Render-Schleife komplett ein (frameloop="never"), sobald er aus dem Blick gescrollt ist. - Der Kontakt-Rahmen cached seine SVG-Pfadlänge, statt sie jeden Frame neu zu messen, und stoppt seine Schleife, wenn er außerhalb des Bildschirms ist.
- Der Pixel-Avatar hört auf, seine ~1.400 Punkte neu zu zeichnen, sobald sich die Hover-Verzerrung wieder zu einem stehenden Bild beruhigt hat.
Und ein MotionConfig-Provider sorgt dafür, dass die motion/react-Animationen
prefers-reduced-motion respektieren, nicht nur die aus CSS. Wenn dein System
auf reduzierte Bewegung eingestellt ist, wird die Seite passend leiser.
Der Blog, und ein Bug, der gar nicht da war
Der Beitrag, den du liest, ist MDX, kompiliert mit next-mdx-remote/rsc und
hervorgehoben von rehype-pretty-code. Beiträge teilen sich einen Dateinamen
über beide Sprachen hinweg, und eine fehlende Übersetzung fällt einfach auf
Englisch zurück, dieselbe Fallback-Idee, die sich durch die restliche Seite
zieht.
Der eine wirklich zermürbende Moment hier: Die Artikel-Typografie (Tailwinds
prose-Klassen) wurde unter npm run dev völlig ohne Stil gerendert. Ich
hätte fast die gesamte Typografie-Schicht herausgerissen und neu geschrieben, um
einen Bug zu jagen, den es gar nicht gab. Es ist eine
Turbopack-Dev-Modus-Eigenheit damit, wie das Typography-Plugin geladen wird, und
der Produktions-Build gibt die Stile einwandfrei aus. Das ist dauerhaft abgelegt:
gegen npm run build prüfen, bevor man in Panik verfällt.
„Frag meinen Lebenslauf": eine Chatbot-Version von mir
Unten rechts in der Ecke sitzt ein kleiner KI-Assistent, den du zu meiner Erfahrung befragen kannst. Ich habe ihn teils hinzugefügt, weil er wirklich nützlich ist, und teils, weil er eine spaßige Sache auf der Seite eines Entwicklers ist. Er streamt Antworten von Gemini 2.5 Flash über das Vercel AI SDK (günstig und schnell), geerdet in einem System-Prompt, der aus meinen echten Lebenslaufdaten gebaut ist:
const result = streamText({
model: google("gemini-2.5-flash"),
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
temperature: 0.3,
maxOutputTokens: 600,
});
return result.toUIMessageStreamResponse();Eine Sache war mir wichtig: Die Seite soll nicht kaputtgehen, wenn der API-Key
nicht gesetzt ist. Also prüft die Route auf einen fehlenden Key und gibt ein
sauberes 503 zurück, statt zu werfen, und der Chat sagt dann einfach höflich,
dass er noch nicht konfiguriert ist. Alles andere auf der Seite läuft genau wie
zuvor weiter.
Der Deploy, der sauber baute und nichts auslieferte
Das war die letzte Wand, und die demütigendste, denn am Code war nichts falsch. Ich pushte zu Vercel, der Build wurde grün, und jede einzelne Route kam mit 404 zurück. Ein vollkommen erfolgreicher Build, der absolut nichts auslieferte.
Es dauerte, bis ich es fand, vor allem weil ich immer wieder im Code suchte, also
genau dort, wo das Problem nicht war. Das Projekt war irgendwie mit dem Framework
auf null angelegt worden, sodass Vercel meine Ausgabe als reine statische
Dateien ganz ohne Next.js-Routing auslieferte. Ein API-Patch, um das Framework
auf nextjs zu setzen, und die ganze Seite erwachte zum Leben. Dann eine zweite
Überraschung gleich dahinter: die Standard-„Vercel Authentication" des Teams
hatte klammheimlich eine Login-Seite vor die gesamte öffentliche Seite gesetzt,
die abgeschaltet werden musste. Und eine dritte, kleinere: die E-Mail des
Git-Commits muss zu einer verifizierten GitHub-Adresse passen, sonst wird der
Deploy rundweg abgelehnt.
Nichts davon steckte in meinem Code. Alles davon steckte in der Plattform. Das ist der Teil vom „Ausliefern", den die Tutorials gern einfach überspringen.
Was ich meinem früheren Ich sagen würde
Ein paar Dinge kamen immer wieder, also sind das die, die ich meinem früheren Ich mitgeben würde. Lies die Doku, die mit deiner Version kommt. Allein die Async-Params-Änderung hätte einen Nachmittag gespart. Behandle jede Animation als gratis, bis sie es nicht mehr ist, und miss, bevor du die nächste hinzufügst. Und behalte im Kopf, dass „es baut" und „es funktioniert" zwei verschiedene Aussagen sind: das Globus-Fallback, der standardmäßig stumme Ton und das Vercel-404 waren allesamt Dinge, die erst in dem Moment schiefgingen, als ich den Happy Path verließ.
Herausgekommen ist eine Seite, die ich wirklich gerne pflege, was der ganze Sinn des Neubaus war. Als Nächstes auf der Liste: diesen Lebenslauf-Chatbot ein bisschen schärfer machen, und, offensichtlich, mehr von diesen Texten schreiben. Danke fürs Lesen.