devShakib

Google Renders Your SPA, and That's Exactly the Problem

Google renders your SPA JavaScript on a second, queued pass that fails silently. Learn why HTML first rendering, SSR, and server side metadata are SEO requirements.

A client shipped a beautiful React SPA, opened view-source, saw an empty <div id="root">, and shrugged. "Google runs JavaScript now," their agency had told them. Six weeks later organic traffic was down 40% against the old server-rendered site it replaced, and nobody in the room could say why. I got the call because the numbers had gotten bad enough to become somebody's problem, and by then the somebody was me.

Here is the uncomfortable thing I keep having to explain in meetings: "Googlebot can execute JavaScript" is technically accurate and practically ruinous. It's the kind of fact that is true in a benchmark and false in production. Treating it as permission to ship a blank HTML shell is how you quietly bleed traffic for a quarter before anyone connects the dip to the rewrite. I've stopped treating HTML-first rendering as an optimization you circle back to. On anything that lives or dies by organic search, it's a hard requirement, same tier as HTTPS.

The myth of the second wave: how Googlebot really renders JavaScript

The reassuring story goes like this: Googlebot fetches your page, sees an empty shell, notices the JavaScript, runs it, indexes the result. Every step of that is true. What the story quietly drops is when the running happens, and "when" is the entire ballgame.

Indexing a JavaScript-heavy page is a two-phase job. First, Googlebot fetches the raw HTML and parses whatever is actually sitting in the response body. Then, if the page needs JavaScript to produce its content, the URL goes into a render queue. A separate headless-Chrome service — Google calls it the Web Rendering Service — picks it up later, executes the scripts, and hands the rendered DOM back for indexing. That second phase is neither free nor instant. It can happen minutes after the first fetch; it can happen days after. You don't control the interval and you can't see it.

So every page is really a bet:

The failure modes on that second path are silent. One unhandled exception in a component, a third-party script that hangs, an API call that needs an auth cookie the crawler never sends — any of these leaves the renderer holding a half-built page, and the indexed version is whatever partial DOM existed at the moment it gave up. Nothing lights up in your dashboard. Your users see a perfect page because their browsers retry, wait, and forgive. The crawler does none of that. It takes one snapshot and moves on.

There's a subtler tax too: the renderer works from a heavily cached view of your resources and does not execute scripts the way a fresh browser session does. It won't scroll, it won't click, it typically won't fire the interactions that lazy-load your content. If your main copy only mounts after an IntersectionObserver trips or a user taps a tab, assume it isn't in the index at all.

This bites hardest on exactly the pages that pay the bills: new content and content that changes often. A news article. A product that just came back in stock. A blog post you published this morning. If it's parked in the render queue for three days, you've already missed the window where ranking it was worth anything.

Google is not your only crawler, and the others don't render JavaScript at all

Here's the part the "Google runs JS" crowd forgets completely: Google is one crawler. On a growing number of sites, it isn't even the one that matters most for reach.

Walk down the list of things that actually fetch your URLs:

I watched a well-funded product launch where the marketing site was a pure client-rendered SPA. Every link anyone shared — investors, press, the founders' own launch posts — came up as a bare gray rectangle. Their content was fine. The packaging around it was invisible to everything short of a full browser. That's not a Google problem. That's a "the entire modern web reads raw HTML first" problem, and Google is a small and shrinking part of it.

The mental model that keeps me honest: assume every consumer of your page except a human's browser reads only the initial HTML response. Write for that reader. Google's renderer is a bonus on top, never the foundation underneath.

SSR, SSG, ISR, and streaming: choose by content freshness, not by hype

Once you accept HTML-first as a requirement, the question stops being "should I render on the server" and becomes "how fresh does this content need to be, and how often does it change." That single axis picks your strategy for you. No framework's marketing page will tell you this cleanly, because every framework wants credit for all four modes.

Here's the decision I actually make:

| Content type | Changes | Strategy | Why |

|---|---|---|---|

| Marketing pages, docs, blog posts | Rarely | SSG (static site generation) | Build once, serve HTML from a CDN, near-zero cost and near-zero latency |

| Catalogs, listings, semi-fresh feeds | Periodically | ISR (incremental static regeneration) | Static speed, but pages rebuild in the background on an interval or on demand |

| Dashboards, personalized, per-request | Every request | SSR (server-side rendering) | Content depends on who's asking; render fresh HTML per request |

| Long pages with slow data | Mixed | Streaming SSR | Ship the shell and above-the-fold HTML instantly, stream the slow sections in |

The reflex to reach for SSR on everything is expensive and usually wrong. SSR runs your rendering on every single request — that's a server bill and a latency floor you pay forever, per visitor and per crawler hit. Most of a typical site, and specifically the pages that earn search traffic, is content that changes rarely and belongs in static files. My default is SSG or ISR for anything public and cacheable, and I reserve SSR for the genuinely per-request stuff.

A worked example makes the tradeoff concrete. Say you have a documentation site with 800 pages that change maybe weekly. SSG builds all 800 into flat HTML at deploy time; every request — user or crawler — is a CDN cache hit with no server in the loop. Now imagine those same 800 pages behind SSR: every crawl of every URL spins up a render on your origin. When Googlebot decides to recrawl the whole site in a burst, you're rendering 800 pages on demand instead of serving 800 files. Same content, wildly different bill and blast radius.

I lean hard on cheap infrastructure, so this part is personal: static HTML on a CDN is about as close to free and as close to bulletproof as web serving gets. No cold start, no render timeout, no origin server to fall over under a crawl spike. When Googlebot hammers 5,000 URLs in an hour, a CDN yawns. A small SSR instance starts sweating around request 200 and the p99 goes with it.

One caveat worth stating plainly: hydration is not free either. Server-rendering the HTML gets you indexed, but the JavaScript still ships and still boots on the client. Watch your bundle size and your Core Web Vitals — a heavy hydration pass can wreck INP and Largest Contentful Paint even though the raw HTML is perfect. HTML-first fixes the crawlability problem; it doesn't absolve you of shipping less JavaScript.

Metadata, canonical tags, and the sins of client-side <head> mutation

This is the most common self-inflicted wound I see, and it hides in plain sight precisely because it works perfectly in your browser.

The setup: you use a client-side head manager to set the title, description, canonical, and Open Graph tags per route. In the browser it's flawless — click around, watch the tab title update, every value correct. But the initial HTML response, the one every non-rendering crawler reads, carries whatever generic defaults got baked into index.html at build time. So every URL ships the same title and the same description to social unfurlers, to AI crawlers, and to Google's first pass. A thousand pages wearing one nametag.

Canonical tags are the sharpest edge in this whole area. A canonical injected by JavaScript after load is a coin flip on whether Google honors it, and every non-rendering consumer ignores it outright. Get canonicals wrong on a site with URL parameters or pagination and you invite duplicate-content dilution across thousands of URLs. I once watched a faceted catalog leak crawl budget across tens of thousands of parameter permutations — ?color=&sort=&page= in every combination — because the real canonical never made it into the raw HTML and Google happily indexed the noise.

The rules I treat as non-negotiable:

The verification is trivial and I run it constantly:

# What EVERY non-rendering crawler and unfurler actually seescurl -sL https://example.com/some/page | grep -iE '<title>|canonical|og:|twitter:'

If that command doesn't return the right per-page values, no amount of "but Google renders JS" saves you. You've just watched exactly what Slack, Bing, and half the AI crawlers on the internet get handed. Send the same URL through a social debugger — the link preview inspectors that Facebook, LinkedIn, and X provide — and you'll see the naked-rectangle problem before your launch tweet does.

Structured data is the cheapest ranking lever you're ignoring

Structured data — JSON-LD in a <script type="application/ld+json"> — is the highest-leverage, lowest-effort SEO work available, and most teams skip it because it's invisible in the UI. It doesn't move a pixel on the page. It moves how your result looks in search: star ratings, prices, FAQ accordions, breadcrumbs, article bylines and dates. Rich results get clicked more than plain blue links, and the entire cost is a block of JSON you generate once.

The catch, predictably, is the same one as everywhere else: it has to be in the server-rendered HTML. JSON-LD injected client-side is the identical gamble — sometimes read by Google, often not, and dead on arrival for every non-Google consumer.

A minimal article block, server-rendered into the page:

<script type="application/ld+json">{  "@context": "https://schema.org",  "@type": "Article",  "headline": "Google Renders Your SPA, and That's Exactly the Problem",  "datePublished": "2026-07-01",  "dateModified": "2026-07-01",  "author": { "@type": "Person", "name": "K M Shahriar Hossain" }}</script>

Match the schema type to the page: Product with offers and AggregateRating for a product page, FAQPage for a support article, BreadcrumbList for navigation, Organization on your home page. A slightly richer product block shows how the pieces fit:

<script type="application/ld+json">{  "@context": "https://schema.org",  "@type": "Product",  "name": "Framed Pro License",  "offers": {    "@type": "Offer",    "price": "0.00",    "priceCurrency": "USD",    "availability": "https://schema.org/InStock"  },  "aggregateRating": {    "@type": "AggregateRating",    "ratingValue": "4.8",    "reviewCount": "112"  }}</script>

Keep it honest — the structured data must describe content that's actually visible on the page, or you're one manual action away from losing the rich result entirely and maybe more. Validate against Google's Rich Results Test and the schema.org validator before you ship, and re-check the day your data model changes, because it will drift.

I've dropped JSON-LD onto pages that were already ranking and watched click-through climb while the position didn't move at all — the result simply got bigger, richer, harder to scroll past. That's free traffic pulled out of HTML you were already serving.

Debugging invisible SPA SEO bugs: rendered-HTML diffing and log-file analysis

The reason SPA SEO problems fester for months is that they're invisible from where you sit. Your browser lies to you — kindly, but it lies. To debug this you have to go look at what the machines actually receive.

Two techniques do most of the work.

Diff the raw HTML against the rendered DOM. Fetch the page with no JavaScript, then fetch it the way a full browser would, and compare the two. The gap between them is precisely your at-risk content: everything that only exists after JS runs.

# Raw response: what a non-rendering crawler indexescurl -sL https://example.com/page > raw.html# Rendered DOM: roughly what Google's renderer sees after JS# (any headless-Chrome tool that dumps the post-JS HTML works)chrome --headless --dump-dom https://example.com/page > rendered.html# The diff is your risk surfacediff <(sort raw.html) <(sort rendered.html)

If your <title>, canonical, main content, or structured data show up only in rendered.html, that's the bug staring back at you. Google's URL Inspection tool in Search Console shows the actual rendered HTML Google captured for a given URL — always confirm against it, because that's the real crawler and not an approximation of it. The "Crawled" vs "Indexed" states there, and the coverage report's "Discovered — currently not indexed" bucket, are where render-queue starvation shows up first.

Read your server logs. Crawler behavior is written down in your access logs and almost nobody opens them. Filter for the genuine Googlebot and Bingbot user agents — verify by reverse DNS, since plenty of junk traffic fakes the UA string — and you learn things no dashboard will tell you:

On that 40%-down project, the logs were the whole tell. Googlebot was crawling the URLs fine and receiving a near-empty shell every single time, then requesting a JS bundle that — in the crawler's session, with no auth cookie — returned data for an empty state. Real users never touched that path because they were logged in. The crawler hit it on every request. You cannot debug what you refuse to look at, and the answer had been written down in the access logs the entire time we were staring at analytics.

When a SPA is genuinely fine, and when it quietly kills your traffic

I'm not anti-SPA. The client-side app model is the right tool for a whole class of products, and I ship them happily. The mistake is pointing it at pages whose entire job is to be found.

A SPA is genuinely fine when:

A SPA quietly kills your traffic when:

The clean resolution isn't "SPA or not." It's a boundary. Render the content that needs to be found as HTML on the server, then hydrate it into a rich client app after the fact. Every serious meta-framework — Next.js, Nuxt, SvelteKit, Remix, Astro — does exactly this now, which is why the old "SSR is too much work" excuse has quietly expired. You get the app experience and the HTML-first guarantee out of the same codebase. There's no real tradeoff left to agonize over — only a default to choose, and for anything public the correct default is server-rendered HTML.

Key takeaways