SSG MIGRATION — PART 2
FRONTEND / SEO

Why I Migrated to Static Rendering: SEO, GEO, and the Empty-Body Problem

The bug wasn't that Google couldn't find my pages. It's that almost nothing that reads a page without running its JS could — part 2 of 2.

Author
Neel Ratn
Published
Aug 27, 2026
Read Time
7 min read
Category
FRONTEND / SEO
Views

Part 1 covered how I moved this blog and my portfolio to static prerendering, and where the real bugs turned out to be. This one is about why I bothered — and it started with a much smaller finding than "SEO is bad." I fetched my own live pages the way a bot would, without executing any JavaScript, and got a title tag and an empty body. That's it. That single observation is the reason everything in part 1 happened.

It's Not Just Google

The instinct is to frame this as a Google problem — will the crawler index my pages. Google's crawler does execute JavaScript these days, on a second, deferred rendering pass. So in the narrowest sense, an empty-body SPA can eventually get indexed. But "eventually, on a second pass, maybe" was never the actual constraint I cared about, because Google isn't the only thing reading these pages without running their JS.

Link previews don't run JavaScript at all. Paste a link into LinkedIn, Slack, or X, and whatever service generates that preview card fetches the raw HTML once and reads whatever's in <head> — no rendering, no waiting for a script to populate anything. An empty-shell SPA gives every one of those a blank or generic card, every single time, not "sometimes, on a slow crawl."

"GEO" is the same problem with a newer name. Generative-engine optimization — showing up correctly when an LLM-based answer engine fetches your page to summarize or cite it — leans even harder on the same constraint. I don't have deep visibility into how every one of these tools fetches pages internally, and I'm not going to overclaim expertise I don't have here. What I do know is the safe assumption: treat "this fetch will not execute my JavaScript" as the default, not the edge case, for anything that isn't specifically Googlebot's second pass. Real content has to exist in the HTML byte stream itself, because betting on a JS-execution step you don't control and can't verify is a worse bet than just not needing it.

One Title, Repeated Everywhere

The second half of the original problem was smaller but just as real: every route — home, individual project pages, individual posts — served the exact same <title> and no per-page description at all. "Neel Ratn — Software Engineer" on the homepage, the about page, and every single case study. A reader with forty tabs open couldn't tell them apart from the tab bar. Neither could anything reading those tabs' metadata instead of their content.

Static prerendering made this a non-issue by construction, not by discipline — every route now renders its own <Head> with a real title, description, Open Graph tags, and canonical URL, because that's what actually lives in the page's HTML now, not something layered on afterward and hoping it survives.

The Sitemap Was Already Making a Promise

I'd already built sitemaps and robots.txt for both apps before any of this — separately, as their own piece of work. Prerendering is what makes that earlier work actually honest. A sitemap entry is a promise: "here's a real page, here's roughly how important it is." Before today, following that link with JS disabled — or as almost any non-browser fetch — landed on an empty shell. The sitemap was correct. What it pointed at wasn't. Listing a URL that resolves to nothing meaningful is arguably worse than not listing it at all, since it spends crawl budget on a page that, from the fetcher's point of view, doesn't have anything on it.

A Real Complication: Prerendering Made "Hidden" Weaker

This is the part of the migration I didn't see coming until I was already in it. This blog has a /journal section — a personal, ongoing log I deliberately keep off the main nav and out of the sitemap. Before today, "hidden" meant something specific: the page didn't exist as a real resource until client-side JavaScript decided to render it. No JS execution, no page.

Prerendering breaks that mechanism, not as a side effect I could ignore but as something structurally true of what prerendering is. Every route — including /journal and every post in it — now gets a real, static HTML file at a real, guessable URL, because that's the entire point of the migration for every other page. "Hidden because it's client-only" quietly became "hidden because nobody's linked to it yet," which is a meaningfully weaker guarantee — it survives a casual visitor clicking around, not URL-guessing or an archive crawler.

The fix was to stop relying on the sitemap exclusion to carry that weight alone, and add an explicit <meta name="robots" content="noindex,nofollow"> to those pages' own <Head> — the actual mechanism that keeps a real, crawlable page out of search results, rather than leaning on it simply not being advertised anywhere. I hadn't needed to think about the difference between "unlisted" and "excluded" until making the pages real forced the distinction.

One more piece, worth a mention rather than its own section: this blog's YouTube embeds used to auto-load the IFrame Player API and start fetching video assets the moment an embed scrolled near the viewport — real bandwidth and largest-contentful-paint cost, whether or not anyone actually watched. Part of the same push toward "make the page real" was replacing that with a static poster image that only loads the actual player on click. Not an SEO change directly, but the same underlying instinct: don't make the page do more work than the visitor actually asked for.

Where This Doesn't Do the Work For You

Static rendering is a prerequisite, not a strategy. It makes the content that exists actually reachable — it doesn't make thin content substantive, and it doesn't replace real backlinks, real writing, or a real reason for someone to link to a page in the first place. I'd put it this way: before today, good content on this site had a real chance of not mattering to anything that couldn't run JavaScript. Now that ceiling is gone. Whether anything underneath it is actually worth reading is still a separate question, and always was.

Next Steps

The honest state of this right now: the theory is sound, the mechanism is in place, and I haven't yet gone and verified most of it against the real world. That's the actual next step, more than any new feature:

  • Structured data. No JSON-LD Article schema on posts yet — a real, concrete addition now that per-page metadata exists to hang it off of.
  • Verify, don't just reason. Everything in this article is architecturally sound reasoning about how crawlers and link previews should behave. I haven't checked Google Search Console for actual indexing behavior, or pasted a real post link into LinkedIn/Slack to confirm the OG card renders the way I expect. That's next, before I trust any of this fully.
  • A feed, now that there's real data to build one from. The posts manifest already has everything an RSS/Atom feed needs — title, date, tagline, slug — for the engineering section specifically. Journal stays out of it, same reasoning as the sitemap.
  • Extend the noindex policy deliberately, not per-incident. The journal's noindex tag came from noticing a specific problem, not from a written policy for what any future hidden section should do by default. Worth writing that down once, rather than re-deriving it the next time this happens.

Key Takeaways

The empty-body problem was never really "will Google index this eventually." It was that link previews, most non-browser fetches, and — as best I can tell without overclaiming — GEO-relevant tools all share one assumption: the content is in the HTML, or it doesn't count. Static prerendering makes that assumption true. Everything else — the sitemap actually meaning something, the journal needing an explicit noindex instead of an implicit one, per-page titles that are actually per-page — falls out of that one change, not from a dozen separate SEO tweaks.

Comments

Loading comments…