Skip to content

Rebuilding my portfolio in Next.js 16: server-first, and zero flash

Jaskaran Singh5 min read

The original portfolio was a single HTML file: 780 lines of markup, 382 lines of vanilla JavaScript, and a compiled Tailwind stylesheet. It worked. Rebuilding it in Next.js was not about making the page look different — the whole point was that it should look identical while becoming maintainable.

This is the story of that port, and the three decisions that mattered: server-first rendering, a flash-free theme system, and verifying with measurements rather than vibes.

Why rebuild what already worked

The static page had grown a real maintenance problem. Every section was hand-written HTML with hand-maintained duplicated markup: the marquee listed 17 tools twice, cards repeated their chip markup, and the FAQ questions existed in three places — the accordion, the JSON-LD schema, and my memory. Changing a project meant editing markup in four spots.

A framework pays for itself the moment content becomes data. In the rebuild, projects live in one typed array:

ts
export type Project = {
  slug: string;
  title: string;
  tagline: string;
  stack: string[];
  image: { src: string; alt: string; width: number; height: number };
  href: string;
  service: "web" | "mobile" | "ai";
};

The home grid, the detail pages, the sitemap, the Open Graph metadata and the structured data all read from that one source. Nothing is typed twice.

The shape of the build

The App Router version has a small route surface:

  • / — the one-pager, all fourteen sections
  • /projects/[slug] — seven statically generated detail pages
  • /blog and /blog/[slug] — this page and its siblings
  • /sitemap.xml, /robots.txt, /rss.xml — generated from the same content files

Everything is static. There is no database and no CMS, which means the build output is a set of HTML files that can be served from anywhere.

Server-first by default

In the App Router, every component is a server component until it needs not to be. That default does most of the work: the project grid, the services blocks, the footer, and every article you read are rendered on the server. None of their markup depends on JavaScript.

The client boundary is explicit and small — eleven islands in total:

  1. The announcement banner (session storage for dismissal)
  2. Desktop navigation (dropdown state, scroll-spy)
  3. Mobile navigation (menu state)
  4. The theme toggle (next-themes)
  5. The hero terminal (WEB/MOBILE tabs, clipboard)
  6. The marquee pause control
  7. Stat counters (animate when scrolled into view)
  8. The testimonials carousel (Embla)
  9. The FAQ accordion
  10. The contact form (useActionState)
  11. Back-to-top

Each one exists because it holds state, reads a browser API, or responds to an event. Nothing else ships JavaScript. If a component can render without useState, useEffect or an event handler, it stays on the server — and the client bundle stays small.

A theme toggle with zero flash

The hard part of dark mode is not the dark palette. It is the first hundred milliseconds, when the server has already sent HTML but the browser has not run your JavaScript yet. Get this wrong and dark-mode users get a white flash on every page load.

The fix has three parts. First, a tiny inline script in the document head marks the document as JavaScript-enabled and lets next-themes resolve the stored preference before first paint:

tsx
const headScript = `
  document.documentElement.classList.add("js");
  try {
    if (sessionStorage.getItem("bannerDismissed") === "1") {
      document.documentElement.classList.add("banner-dismissed");
    }
  } catch (e) {}
`;

Second, the toggle itself never renders different markup on the server and the client. Both icons are always present, and CSS decides which one you see:

tsx
<SunIcon className="absolute transition-[opacity,transform] duration-200 dark:rotate-45 dark:scale-75 dark:opacity-0" />
<MoonIcon className="absolute -rotate-45 scale-75 opacity-0 transition-[opacity,transform] duration-200 dark:rotate-0 dark:scale-100 dark:opacity-100" />

That removes the classic hydration mismatch — there is no mounted-state dance, and the correct icon is painted on the first frame. The click handler reads the resolved theme at click time instead of during render:

tsx
function toggle() {
  setTheme(resolvedTheme === "dark" ? "light" : "dark");
}

Third, the page never hides content without JavaScript. Scroll reveals set their hidden state as inline styles during SSR, so a !important CSS guard keyed on the js class brings everything back when scripting is off:

css
html:not(.js) [data-reveal] {
  opacity: 1 !important;
  transform: none !important;
}

The result: no flash of the wrong theme, no hydration warnings, and a page that is fully readable with JavaScript disabled.

Measured, not vibed

"Fast" is not a feeling, so the rebuild ships with a verification script. Playwright drives the production build and records real numbers:

  • CLS 0 on both desktop and mobile — every image has explicit dimensions and fonts are self-hosted
  • LCP 164 ms desktop, 132 ms mobile on a local production build
  • 0 axe violations at 1440px and 390px, in both themes
  • Zero horizontal overflow at 390px, no console errors, and the no-JS fallback verified by loading the page with scripting disabled

The script also diffs every visible string against the original page, which is how I know the port did not silently change copy.

What I would do differently

Two things. First, I would define the theme tokens before porting any markup — I built the light theme, then retrofitted semantic tokens for dark mode, and had to re-check every hard-coded colour twice. Second, I would write the verification script on day one. Building it at the end meant re-fixing bugs I had already "fixed" by eye.

The rebuild did not change how the site looks. It changed how it is maintained: content is data, the server does the rendering, and claims about performance come with numbers attached.

More posts.