Ein Portfolio ist ein dankbares Projekt: klein genug, um es an ein paar Abenden zu bauen, und groß genug, um unterwegs über Dinge zu stolpern, die in keinem Tutorial stehen. Diese Seite hat ein HUD statt einer Navbar, einen Score, der beim Scrollen hochzählt, und ein Kontaktformular. An jeder dieser drei Stellen hat etwas gehakt.
Keiner der Bugs war spektakulär. Aber jeder hat länger gedauert, als er sollte, und genau solche merkt man sich am besten, wenn man sie einmal aufschreibt.
Das HUD ist die Navigation
Der Header zeigt PLAYER 1, LVL 04 und fünf Herzen. Daneben stand anfangs eine normale Navbar. Das war eine Zeile zu viel: Auf mittleren Viewports gab es einen Line Wrap, und die Progress Bar landete über der Level-Anzeige.
Die Lösung war, die Navbar zu streichen. Jedes Element im HUD ist jetzt selbst ein Link und tauscht bei Hover sein Label gegen den Namen der Section. Damit dabei kein Layout Shift entsteht, liegen beide Labels in derselben Grid Cell übereinander:
<Link href={`/#${anchor}`} aria-label={label} className="group grid">
<span aria-hidden="true" className="col-start-1 row-start-1 group-hover:opacity-0">
{children}
</span>
<span aria-hidden="true" className="col-start-1 row-start-1 opacity-0 group-hover:opacity-100">
{label}
</span>
</Link>Die Box ist immer so breit wie das längere der beiden Labels. Für Screen Reader zählt nur das aria-label, denn „LVL 04“ sagt als Link-Ziel nichts aus.
Eine Progress Bar ohne React State
Die Progress Bar im Header folgt der Scroll Position. Die naheliegende Umsetzung ist ein useState und ein Scroll Listener. Das Ergebnis zog sichtbar nach: Jedes Scroll Event löste ein komplettes Re-Rendering aus.
Eine CSS Transition auf der Width macht es schlimmer statt besser. Sie interpoliert zwischen zwei Werten, die ohnehin jeden Frame neu kommen. Geholfen hat, React an dieser Stelle ganz herauszunehmen:
const onScroll = () => {
// scroll feuert häufiger, als der Browser malt
if (!frame) frame = requestAnimationFrame(paint);
};
const paint = () => {
frame = 0;
const max = document.documentElement.scrollHeight - window.innerHeight;
bar.style.width = `${Math.round((window.scrollY / max) * 100)}%`;
};Der Wert geht direkt auf den DOM Node, höchstens einmal pro Frame. Das ist kein Pattern für überall, aber für eine Anzeige, die sich sechzigmal pro Sekunde ändert, ist es das richtige.
Nicht alles, was sich auf dem Screen ändert, muss durch den Application State laufen.
Was eine Server Action exportieren darf
Das Kontaktformular läuft über eine Server Action. Im selben File lag auch der Initial State des Formulars, als exportiertes Object. Der Build lief durch, der Type Check war grün, die Seite sah gut aus. Beim ersten Submit kam ein Error.
Ein File mit 'use server' darf nur Async Functions exportieren. Ein Object daneben fällt weder dem Compiler noch dem Build auf, sondern erst zur Runtime. Die Lösung ist unspektakulär: Der Initial State liegt jetzt in einem eigenen File ohne die Directive.
Zwei weitere Kleinigkeiten aus derselben Ecke:
middleware.tsheißt in Next.js 16proxy.ts. Die Signature ist dieselbe.- Liegt ein Locale Redirect vor allen Routes, muss sein Matcher
opengraph-imageausnehmen. Sonst leitet er genau die URL um, die in jedem OG Tag steht.
Checkliste
- Anchor Links in geteilten Headern als
/#anchor - Scroll-Anzeigen per
requestAnimationFramedirekt ins DOM - Files mit
'use server'exportieren nur Async Functions - Formular einmal wirklich submitten, bevor es live geht
Nichts davon ist tiefe Architektur. Es sind die Stellen, an denen ein Projekt „fast fertig“ ist und trotzdem noch einen Abend kostet.