SPA SEO: Why Crawlers Get an Empty Shell From Your Single-Page App
SEOLatest

SPA SEO: Why Crawlers Get an Empty Shell From Your Single-Page App

A single-page application (SPA) returns the same near-empty index.html for every URL. Googlebot renders that shell, but in a December 2024 study ChatGPT's crawler fetched JavaScript in 11.50% of its requests and ran none of it. SPA SEO starts with content in the first HTML.

Sercan Gökpınar
11 min read

Crawlers get an empty shell because a client-side routed app sends the same HTML file to every address and builds the page afterward in JavaScript. Google runs that JavaScript and sees the finished page. Clients that never run it read the shell as it arrives, with the same title, the same preview tags, and no content.

We often see this treated as one problem with one fix, usually "switch frameworks." In practice SPA SEO splits into three kinds of problem. Some survive Google's rendering, some affect only crawlers that never render, and some come from the fixes most often recommended for SPAs.

Why an SPA's First HTML Arrives Empty

In a client-side routed SPA, the server does not know which view a URL stands for. It returns one entry file, usually index.html, and the router in the browser decides what to draw. Google's JavaScript SEO guide calls this the app shell model.

The App Shell: A Container and a Script Tag

Open view-source on a strict SPA and you usually find little more than <div id="app"></div> and a bundle reference. The Next.js guide to single-page applications describes the same shape: one HTML file, with every route and data fetch handled in the browser. The script tag itself is ordinary, since W3Techs counts JavaScript on 98.9% of the sites it tracks in September 2026. What matters for SPA SEO is whether the content already sits in the response.

Every Route Ships the Same Head

Because every URL returns the same file, every URL also returns the same <head>. The title, description, Open Graph tags, and canonical in the first response are identical on /pricing and /blog/any-post until JavaScript rewrites them.

Google reads the rewritten head after rendering, but its meta tag documentation asks you to avoid changing meta tags with JavaScript whenever possible. A shared head also turns template choices into site-wide ones. A noindex in the shell applies to every route. A fixed canonical in the shell, plus a second one added by JavaScript, leaves two conflicting tags on the page.

Google Renders the Shell, and Three SPA Problems Remain

Google queues every page that returns a 200 status code for rendering. In a 2024 study by Vercel and MERJ on nextjs.org, the median wait between crawl and finished render was 10 seconds. Rendering still costs more than fetching HTML, which matters on large sites where crawl budget is a real limit.

Two conditions stop rendering from helping. A noindex in the first HTML may make Google skip rendering, so JavaScript never gets to remove it. Google also renders only what it may fetch, so blocking a framework's script folder leaves a client-rendered page empty.

Hash Routes Are One URL to Google

A view at /#/products is, for Google, the same document as /, because the fragment never reaches the server. Google asks you not to load different content through fragments, and notes that the AJAX crawling scheme has been deprecated since 2015. Use the History API with real paths such as /products, and make the server answer those paths directly.

Links Without an href May Not Be Followed

Google follows a link reliably when it is an <a> element with an href, including links inserted by JavaScript. The patterns below are the ones it lists as unreliable:

<a href="/products">Products</a>          <!-- crawlable -->
<a routerLink="products/category">…</a>   <!-- no href -->
<span href="/products">…</span>           <!-- not an <a> element -->
<a onclick="goto('/products')">…</a>      <!-- address only in a handler -->

Google may still attempt to parse them, which falls short of a promise. Check the rendered HTML, because your router component decides the final markup.

Every Missing URL Returns 200, a Soft 404

An SPA that handles errors in the browser has already sent 200 by the time it learns a product does not exist. Google renders that page, can report a soft 404, and warns that such error pages may get indexed. Its two exits are a JavaScript redirect to a URL that returns a real 404, or a noindex tag added by JavaScript. The second exit works because the tag is added after load rather than removed.

Who Reads the Empty Shell: Clients That Skip Rendering

The clients that request an SPA behave differently, so "search engines struggle with JavaScript" is too coarse to act on. The table sorts them by what they read.

A table of seven clients and what each reads on a single-page app: the first HTML it reads, whether it runs JavaScript, and the basis. The user's browser, Googlebot (every page that returns 200), Bingbot (which asks sites not to hide critical content behind client-side rendering) and Applebot (which may render) read the shell and run JavaScript. Link preview bots from Meta, Slack and LinkedIn read tags in the fetched HTML, within the first 1 MB for Meta, and their documentation does not say whether they run JavaScript. GPTBot, ClaudeBot and PerplexityBot read only the first HTML and did not run JavaScript when Vercel and MERJ observed them in December 2024. Google AI Overviews and Gemini rely on Googlebot's index.
A table of seven clients and what each reads on a single-page app: the first HTML it reads, whether it runs JavaScript, and the basis. The user's browser, Googlebot (every page that returns 200), Bingbot (which asks sites not to hide critical content behind client-side rendering) and Applebot (which may render) read the shell and run JavaScript. Link preview bots from Meta, Slack and LinkedIn read tags in the fetched HTML, within the first 1 MB for Meta, and their documentation does not say whether they run JavaScript. GPTBot, ClaudeBot and PerplexityBot read only the first HTML and did not run JavaScript when Vercel and MERJ observed them in December 2024. Google AI Overviews and Gemini rely on Googlebot's index.

Link Previews and Open Graph Tags

Link preview bots read Open Graph tags from the HTML they fetch, and each platform limits that fetch. Meta wants the tags before the first 1 MB and shows no preview if the crawl takes more than a few seconds. Slack's bot fetches as little of the page as it can, and LinkedIn requires four og: tags. None of them documents whether its bot runs JavaScript, so we check what each one read in Meta's Sharing Debugger and LinkedIn's Post Inspector.

AI Crawlers Read the First Response

In December 2024, Vercel and MERJ observed that none of the major AI crawlers rendered JavaScript, GPTBot and ClaudeBot included. Google's AI Overviews have no additional requirements beyond being indexed and eligible for a snippet. On Google's side, the question comes down to Googlebot's rendering.

Bing Warns Against Critical Content Behind Client-Side Rendering

Bingbot renders pages, yet Bing's Webmaster Guidelines list hiding critical content behind client-side rendering as a practice to avoid. Content that cannot be reliably rendered may miss the index and Copilot's grounding results. DuckDuckGo takes most of its traditional links from Bing, so we expect the same limit to carry over.

Common SPA Fixes Often Hide the Problem From Bots Only

The fixes most often recommended for SPAs detect the bot and give it something else. We describe two published developer setups without naming them, because the mechanisms matter more than the authors.

Redirecting Bots by User-Agent With a 301

One setup matches crawler user-agents in .htaccess and sends them with a 301 to a lightweight /api/meta.php page. Google's redirect documentation treats a redirect as a signal that the target should be canonical. The rule's Google(.*) pattern catches Googlebot too, so Google keeps landing on the API address while users stay on the article. By our reading, the setup trades a preview problem for a canonical problem, though we have not measured it on a live site.

Bot-Only Prerendering: Stale Snapshots, Short Lists, Wrong Files

A second setup saves each listed route as HTML at build time, and nginx serves those files only to user-agents on a bot list. Build-time HTML is static rendering, which Google recommends. Serving it only to bots makes it dynamic rendering, which Google calls a workaround and not a recommended solution. Reading the code, we found three weaknesses:

  • Stale snapshots. Bots keep the build-time version while users get updates, which strains Google's "similar content" limit.
  • A hand-kept bot list. The list misses Google-InspectionTool, which Google's testing tools send, and Lighthouse dropped the identifier the list matched in version 10.0.0.
  • A file the server never finds. The route /about is saved as about.html, but the nginx rule tries only $uri and $uri/ before falling back to /index.html.
Sequence diagram of a bot-only prerender setup in nginx. A crawler matched as a bot requests /about, and nginx switches its root to the prerendered folder. It tries /about and /about/, finds neither, and falls back to /index.html, the prerendered home page, which it returns with 200 OK and the home page's title, meta tags, and body. A note says the prerender step saved the route as about.html, but the rule never tries $uri.html. A fix box quotes the nginx documentation example, try_files $uri $uri/index.html $uri.html =404, after which unlisted routes return 404. Labeled as Beaked's analysis of published code, not tested on a live site.
Sequence diagram of a bot-only prerender setup in nginx. A crawler matched as a bot requests /about, and nginx switches its root to the prerendered folder. It tries /about and /about/, finds neither, and falls back to /index.html, the prerendered home page, which it returns with 200 OK and the home page's title, meta tags, and body. A note says the prerender step saved the route as about.html, but the rule never tries $uri.html. A fix box quotes the nginx documentation example, try_files $uri $uri/index.html $uri.html =404, after which unlisted routes return 404. Labeled as Beaked's analysis of published code, not tested on a live site.

The bot therefore receives the prerendered home page, with a 200, on every route. The example in the nginx documentation adds the missing piece and makes unlisted routes return 404:

try_files $uri $uri/index.html $uri.html =404;

These findings come from reading the code and the nginx documentation. We have not run the setup ourselves.

The Lasting Fix: Content and Head in Every Route's First HTML

One principle covers every client in the table: send everyone the same HTML at the same URL, with the route's content and head inside it. Google recommends server-side rendering, static rendering, or hydration. Part of its reasoning is that not all bots can run JavaScript. With that in place, the bot list and the redirect have nothing left to do.

Three Paths: SSR, Static Generation, and Prerendering for Everyone

Server-side rendering (SSR) builds the HTML on each request. Static site generation (SSG) builds it before any request, and web.dev's Rendering on the Web covers the trade-offs between them. A build-time prerender belongs with SSG as long as the same files go to every visitor. We suggest SSR for request-dependent pages, and SSG or prerendering for pages that change only when you publish.

Where React and Next.js Now Point

The React team deprecated Create React App for new apps on February 14, 2025, and now recommends starting with a framework. The same post calls it "a common misunderstanding" that server rendering is only for SEO. In the Next.js App Router, a direct visit to a route receives server-rendered HTML, and Googlebot requests every URL that way.

A 'use client' directive does not empty that HTML, because Next.js prerenders client components on the server. Fetching content in the browser, or setting ssr: false, does empty it. For static hosting, output: 'export' writes one HTML file per route. The Next.js docs frame all of this as speed, and the search consequences are our reading.

How to Test What Bots See: A Five-Step SPA SEO Check

Each check stands in for a different client, so we run all five on a route other than the home page:

  1. Open view-source or run curl on the URL. The response is what a client that never renders receives, head tags included.
  2. In Google Search Console, open URL Inspection and select View crawled page to see what Googlebot fetched and rendered.
  3. Run the live test knowing that it arrives as Google-InspectionTool. On a site that varies content by user-agent, it can differ from what Googlebot gets.
  4. Paste the URL into Meta's Sharing Debugger and LinkedIn's Post Inspector to see the tags each preview bot read.
  5. Request an address that does not exist with curl -I and confirm that the status code is 404.

A site: search with a quoted sentence from the page is a useful positive signal, but an empty result proves nothing. Google's site: operator documentation says indexed URLs are not guaranteed to appear. The Mobile-Friendly Test is gone too, retired by Google on December 1, 2023. If a route passes the first check and still sits outside the index, look at the gap between crawling and indexing next.

Frequently Asked Questions

Can Google index a single-page application?

Yes. Google renders SPA pages that return a 200 status code and indexes the rendered content. Hash-based routes, links without an href, and error views that return 200 still need fixing.

Do I need server-side rendering for SPA SEO?

Not for Google, which renders client-side content. For clients that never render, and for route-specific head tags in the first response, you need SSR, static generation, or prerendering served to every visitor.

Why does every link from my SPA show the same preview?

Every route returns the same index.html, so the first response carries the same Open Graph tags on every URL. Meta's Sharing Debugger shows which tags its crawler actually read.

Is dynamic rendering still a good fix for an SPA?

No. Google's documentation calls dynamic rendering a workaround and not a recommended solution, because it adds complexity and resource requirements. Serving the same prerendered HTML to every visitor removes the need for it.

Why does Google Analytics 4 undercount page views on my SPA?

Route changes in an SPA do not reload the page, so a tag that fires on load counts only the first view. Turn on the GA4 enhanced measurement setting that also sends page_view on browser history events: pushState, popState, and replaceState.

Sources and Further Reading