Migrating my portfolio from Vite to Next.js: the SEO before and after

  • SEO
  • Next.js
  • React

For months my portfolio failed almost every SEO audit I ran it through. No h1. No h2. Zero internal links. No images. "Thin content."

None of that was true. The page had all of it — a heading, sections, a portfolio, an FAQ. I could see it in the browser.

The audits weren't wrong either. They just weren't looking at the same page I was.

One bug, every symptom

The site was a React app built with Vite. Vite ships an HTML file that looks roughly like this:

<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

That's the whole document. Every heading, paragraph and link is created by JavaScript after the browser downloads, parses and runs the bundle.

A browser does all of that happily. A crawler that doesn't execute JavaScript sees exactly what's in the file: an empty div.

So the report saying "no h1" was accurate. There was no h1 in the HTML. There was no anything.

That single fact explained every finding at once. Once I understood it, the list of a dozen "issues" collapsed into one.

Why "Google renders JavaScript" wasn't the reassurance I wanted

Google does render JavaScript. That's true, and it's the reason people wave this problem away.

But rendering is a second pass. Google crawls the HTML first, queues the page for rendering, and comes back when it has capacity. For a small site with no authority, "when it has capacity" is doing a lot of work in that sentence.

And Google is not the only reader. Social preview scrapers, Bing, and the AI answer engines that increasingly sit between people and search results are far less patient. Several don't execute JavaScript at all.

I was betting my entire visibility on the most forgiving possible reader. That's a bad bet when the fix is available.

What I actually changed

I moved the site to Next.js and turned on static export:

const nextConfig = {
  output: 'export',
  images: { unoptimized: true },
  trailingSlash: true,
};

output: 'export' renders every route to real HTML at build time. The result is a folder of plain files — no server, no runtime, no rendering step for a crawler to wait on. Netlify serves them directly.

This is worth being precise about, because it's the part people get wrong: this is not server-side rendering. Nothing runs per request. The HTML is generated once, when I build, and shipped as static files. Hosting stays as simple as it was with Vite.

The React stays React. Components that need state and effects keep them behind 'use client'. What changes is that the first response already contains the content, and JavaScript takes over from there instead of starting from nothing.

Before and after

The measurement that mattered was simple: fetch the page the way a crawler does, strip the tags, count what's left.

curl -s https://ikbalhussain.com/ | python3 -c "
import re, sys
h = sys.stdin.read()
b = re.sub(r'(?s)<script.*?</script>', '', h)
t = re.sub(r'<[^>]+>', ' ', b)
print(len(re.sub(r'\s+', ' ', t).strip()))
"

Before: 0 characters.

After: 7,498 characters, 1,043 words — every heading, every service description, every FAQ answer, present in the initial response.

The audit findings disappeared as a group, because they had always been one finding wearing twelve hats.

Lighthouse afterwards: SEO 100, Accessibility 100, Best Practices 100, Performance 98, with LCP at 2.1s and CLS at 0. The migration didn't cost performance — static files are hard to beat.

What I'd tell you to check

If you have a React site you care about being found, don't run an SEO tool first. Run this:

curl -s https://yoursite.com/ | grep -c "<h1"

If that returns 0, no amount of meta-tag tuning will help you. Every other fix is downstream of the page having content in it.

Two things I'd flag from doing this:

Static export isn't always the answer. It fits a portfolio or a marketing site, where content changes when I deploy. If your pages depend on per-request data, you want SSR or ISR instead. The principle holds either way — the HTML should arrive with content in it.

Check the built output, not the build log. More than once during this migration something compiled cleanly and still did the wrong thing in the generated HTML. A green build tells you the code ran. It doesn't tell you what shipped.

The part that isn't a code problem

Being crawlable got the site into the index properly. It didn't make it rank.

I'm trying to rank for my own name, and I share it with a couple of politicians who have news coverage behind them. No amount of schema fixes that. That's an authority problem, and authority comes from other sites linking to you — which is slower, more manual work than anything described here.

Worth being honest about the split: the technical work is necessary and finite. You can finish it. What comes after isn't code.

If you want to see the end state, it's this site — and the privacy page if you want to see how little I collect.