A portfolio is a rewarding project: small enough to build in a few evenings, and large enough to trip over things no tutorial mentions. This site has a status bar instead of a menu, a score that counts up as you scroll, and a contact form. Something got stuck at each of those three places.

None of the bugs were spectacular. But each took longer than it should have, and those are the ones worth writing down once.

The status bar is the navigation

The header shows PLAYER 1, LVL 04 and five hearts. At first there was a regular link bar next to it. That was one row too many: on medium screens it wrapped, and the progress bar ended up above the level display.

The fix was to drop the link bar. Every element of the status bar is now a link itself and swaps its label for the section name on hover. To keep things from jumping, both labels sit in the same grid cell on top of each other:

HudLink.tsxTSX
<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>

The box is always as wide as the longer of the two labels. Screen readers only get the aria-label, because "LVL 04" says nothing as a link target.

A progress bar without React state

The bar in the header follows the scroll position. The obvious implementation is useState plus a scroll listener. The result visibly lagged: every scroll step triggered a full render pass.

A CSS transition on the width makes it worse, not better. It interpolates between two values that arrive fresh every frame anyway. What helped was taking React out of this spot entirely:

ScoreHud.tsxTS
const onScroll = () => {
  // scroll fires more often than the browser paints
  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)}%`;
};

The value goes straight to the DOM node, at most once per frame. It is not a pattern for everywhere, but for a display that changes sixty times a second it is the right one.

Not everything that changes on screen has to pass through application state.

What a Server Action may export

The contact form runs through a Server Action. The same file also held the form's initial state, as an exported object. The build passed, the types checked, the page looked fine. The first submit threw an error.

A file marked 'use server' may only export async functions. An object next to them gets past both the compiler and the build and only fails at runtime. The fix is unspectacular: the initial state now lives in its own file without the directive.

Two more small things from the same corner:

  • middleware.ts is called proxy.ts in Next.js 16. The signature is the same.
  • If a locale switch sits in front of all routes, its matcher has to exclude opengraph-image. Otherwise it redirects exactly the URL that every OG tag points to.

Checklist

  • Jump links in shared headers as /#anchor
  • Scroll indicators via requestAnimationFrame, straight to the DOM
  • 'use server' files export async functions only
  • Submit the form for real once before it goes live

None of this is deep architecture. These are the spots where a project is "almost done" and still costs another evening.