SPA SEO

How to make single-page applications (React Router, Vue Router, Angular Router) crawlable and indexable — the app-shell and soft-404 problems, History API vs hash routing, per-route HTML via SSR/prerendering, per-route canonicals and titles, and sitemap generation for client-side routes.

First published: Jul 3, 2026 · Last updated: Jul 18, 2026 · Advanced

A single-page application loads one document and swaps views with JavaScript instead of requesting a new page from the server. Many SPAs implement that with an 'app shell' — the server returns one URL's worth of real HTML, a near-empty shell, until JS renders each route — but that's a common implementation choice, not a rule every SPA follows; check what a direct request to each route actually returns before assuming it. The fix, where a bare app-shell setup is the problem, has two independent halves: addressability (History API, not hash/#! fragments, so each view has a real URL — a browser soft navigation via the History API changes the URL and UI but doesn't itself create a new server response) and content availability (SSR, prerendering, or a meta-framework so each of those URLs can return unique HTML on request). On top of that, verify each route's title, canonical, and robots state both on direct entry and after rendering, handle soft 404s (routers tend to keep a 200 status for 'not found' views — redirect to a URL that itself 404s, or add a rendered noindex, though an initial noindex can cause rendering to be skipped), and make sure every route in your sitemap actually resolves to real, indexable HTML — there's no special SPA sitemap format.

TL;DR — An SPA’s core SEO risk isn’t “JavaScript” in the abstract — it’s that client-side routing changes what the user sees without changing what the server would return. The fix has two independent halves people constantly conflate: addressability (History API, unique per-route URLs, no #! fragments) and content availability (SSR, prerenderingTurning HTML, CSS, and JavaScript into the final visual page and DOM./SSG, or a meta-framework). Do only the first and you get a tidy sitemapA sitemap is a file that lists the pages, images, videos, and other files on your site so search engines can discover them. It helps discovery, but submitting a sitemap doesn't guarantee crawling or indexing. of URLs that all render the same shell. On top of both, each route needs its own canonical/title/description in the rendered DOM, you have to handle soft 404sA soft 404 is a URL that returns a success status code (usually 200 OK) even though the page is empty, missing, or shows a 'not found' message. It isn't a status code a server sends — it's a label search engines apply after comparing the response code against the rendered content, and they treat the page like a 404 for indexing. that keep a 200, and every route in the sitemapA sitemap is a file that lists the pages, images, videos, and other files on your site so search engines can discover them. It helps discovery, but submitting a sitemap doesn't guarantee crawling or indexing. has to independently resolve to real HTML.

What SPA SEO actually is

SPA is an application architecture, not a guaranteed SEO failure state. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: MDN: Single-page application Server renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM., prerendering, or carefully implemented client rendering can expose content, but none guarantees indexingStoring a crawled page in the search index so it can appear in results. Crawled is not the same as indexed — Google selects what to keep, and indexing isn't guaranteed.. Evidence for this claim Primary standard or official documentation supporting the adjacent article claim. Scope: Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Confidence: high · Verified: Google: JavaScript SEO basics

This is the deep dive on single-page applications specifically — client-side routing with React Router, Vue Router, or Angular Router, where after the first load the server never sends another full page. It sits alongside the broader JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. guide (which covers parity, lazy-loading, infinite scrollInfinite scroll is a loading pattern where content appears automatically as a user scrolls, instead of via numbered pages. Because Googlebot doesn't scroll or click, indexable infinite scroll needs real per-chunk URLs (paginated loading) that update via the History API — otherwise deep content may never be crawled, or worse, get merged into the wrong page., and JS rendering generally) and the per-framework write-ups for React, Next.js, Nuxt, Angular, Vue, Svelte, and Astro. Here I’m only interested in the routing layer and what it does to crawlingCrawling is how search engines use automated bots (like Googlebot and Bingbot) to discover URLs and download pages. A page has to be crawlable to be indexed, but crawling on its own isn't a ranking factor. and indexingStoring a crawled page in the search index so it can appear in results. Crawled is not the same as indexed — Google selects what to keep, and indexing isn't guaranteed..

Why bare client-side-routed SPAs fail at SEO

One URL, one HTML response — the app-shell problem

Google describes the failure mode precisely: “Some JavaScript sites may use the app shell model where the initial HTML does not contain the actual content and Google needs to execute JavaScript before being able to see the actual page content.” In a bare SPA, the “app shell” is all the server ever returns. Fetch /products fresh and you get the same near-empty document you’d get for /about. The content only diverges after the browser runs your JavaScript and your router decides what to show.

That’s the whole problem in one sentence: client-side routing changes what the user sees without changing what the server would return. Everything downstream — soft 404s, duplicate contentThe same or very similar primary content reachable at more than one URL. There's no general duplicate content penalty — the real costs are possible signal dilution, the wrong URL getting chosen, and less-efficient crawling., missing titles — is a symptom of that.

The server never sees which “page” was requested (why hash routing fails)

Older SPAs load views from URL fragments — example.com/#/products. Google’s exact words: “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” This fails for a specific, mechanical reason: browsers never send the fragment (anything after #) to the server in the HTTP request. The server literally cannot know which “page” was requested, so it can’t return different content or a different status code for it. A crawlerA crawler — also called a spider or bot — is an automated program that fetches web pages, extracts their links, and queues new URLs to visit. Search engines use crawlers to discover and download content for their index. that fetches the URL once gets identical HTML regardless of the fragment.

That’s also why Google formally deprecated its 2009 AJAX-crawlingCrawling is how search engines use automated bots (like Googlebot and Bingbot) to discover URLs and download pages. A page has to be crawlable to be indexed, but crawling on its own isn't a ranking factor. scheme back in October 2015: “In short: We are no longer recommending the AJAX crawling proposal we made back in 2009.” The old _escaped_fragment_ workaround let servers pre-render fragment routes on request — but it patched around the routing problem rather than fixing it. Google’s own recommendation replacing it is the History API, covered below.

Soft 404s — client-side routers keep a 200 for everything

This one is close to inevitable in a bare SPA. Client-side routers, by design, keep the original page’s 200 status for every virtual navigation — including “not found” states. Google flags it explicitly: “In a single-page application (SPA), this can be especially difficult. To prevent error pages from being indexed, you can use one or both of the following strategies.” And the why: “When a SPA is using client-side JavaScript to handle errors they often report a 200 HTTP status codeAn HTTP status code is the three-digit number a server returns with every response to tell a browser or crawler what happened to its request — success, redirect, client error, or server error. For SEO the code matters as much as the content: it tells Google and Bing whether to index a page, follow a redirect, retry later, or drop the URL from the index. instead of the appropriate status code.”

The result is empty or error views getting indexed as thin 200 pages. Google documents exactly two strategies here, precisely: redirectA redirect sends browsers and crawlers from a requested URL to a different one. An HTTP redirect specifically is a 3xx status code paired with a Location header; meta refresh and JavaScript redirects achieve a similar navigation without being a 3xx response themselves. Permanent redirects (301/308) are Google's signal the target should be canonical; temporary ones (302/303/307) aren't. (or make a full request) to a URL whose server returns a real 404/error status, or add a noindex tag to the error view with JavaScript. Watch the second one: if that noindex is present from the very first paint rather than added after the app decides the route is invalid, it can cause Google to skip rendering the page altogether — so verify what a direct request returns before any JS runs, not only what shows up in the rendered DOM afterward.

Shared-shell duplicates — a possible cause, not an automatic diagnosis

There’s a second, sneakier failure mode: distinct routes get indexed as duplicates of each other because they render down to the same shared header/nav/footer boilerplate. Gary Illyes described one way this happens: “I have a bunch of emails in my inbox where the issue is that the centerpiece took forever to load, so rendering timed out (my most likely explanation) and we were left with a bunch of pages that only had the boilerplate. With only the boilerplate, those pages are dups.” His fix: “Try to restructure the js calls such that the content (including marginal boilerplate) loads first.” (Relayed via a LinkedIn post, which resists automated verification; treat as high-confidence secondary.)

Notice Illyes frames it as “my most likely explanation,” not a confirmed diagnosis — that’s worth taking literally. “Render timeout” isn’t something you can diagnose from symptoms alone; you need request-level evidence (what a direct fetch actually returns), rendered-output evidence (whether the centerpiece content is present after rendering), and Search ConsoleGoogle's free tool for monitoring crawling, indexing, and search performance. evidence (duplicate/indexing signals) before attributing a route’s duplicate-content problem to a timeout specifically, rather than to a bug, a blocked resource, or a genuinely identical shell. There’s also no fixed, published timeout to design around — Google’s own basics doc says a page “may stay on this queue for a few seconds, but it can take longer than that,” without committing to a number. Don’t plan around an assumed render-queue duration; instead, make sure primary content loads as early as possible regardless of how long rendering ends up taking.

The fix: get real HTML per route

The full fix has two independent parts that people conflate constantly:

  1. URL addressability — History API, unique per-route URLs, no fragments.
  2. Content availability — SSR, static prerendering/SSG, or a meta-framework.

Do only #1 and you get clean, shareable URLs that all still return the same blank shell. For a route you actually want indexed, you need both.

That’s not a universal mandate to add SSR everywhere, though. SSR and prerendering reduce how much you depend on a crawler successfully executing your JavaScript — they don’t become mandatory the instant a site is technically an SPA. Before picking an architecture for a given route, check three things: whether that route needs to rank at all (an internal admin panel doesn’t), what a direct, JS-free request to it already returns (some setups already send meaningful HTML), and which crawlers you actually need to satisfy (Google renders JS fairly reliably; Bing and most AI crawlersAI crawlers are bots from AI companies that fetch web pages to train language models, build AI-search indexes, or answer live user questions. They come in three categories, each with its own user-agent tokens and its own robots.txt controls. less so — see below). CSR that already passes those checks doesn’t need to become SSR just because the site is an SPA.

Server-side rendering (SSR)

The server runs your app for each request and returns fully-formed HTML for that route, then the client “hydrates” it into a live SPA. This is the most robust option because the crawler gets complete content on the first fetch, no JS execution required.

Static prerendering / SSG

Instead of rendering per request, you build every route’s HTML ahead of time at deploy. Perfect for content that doesn’t change per user. From my own JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. writing: any kind of SSR, static rendering, and prerendering setup is going to be fine for search engines — the thing to avoid is leaving content locked behind client-only rendering.

Or: don’t hand-roll it — use a meta-framework

For a new build, the honest recommendation is to not hand-roll React-Router-only or Vue-Router-only client-side routing at all. A meta-framework — Next.js, Nuxt, Angular with its SSR package, SvelteKit, Remix — gives you SSR/SSG and route-based metadata out of the box, which sidesteps this entire category of problem. (Each of those has its own deep dive on this site.) Be honest about the flip side: retrofitting SSR onto an existing hand-rolled SPA is real engineering work — a migration project, not a config toggle.

Dynamic rendering as a stopgap, not a destination

You can serve crawlers a separately-rendered HTML snapshot (dynamic rendering). Both Google and Bing accept it — Bing “recommend[s] dynamic rendering as a great alternative for websites relying heavily on JavaScript” (relayed from Bing’s 2018 blog; the two short bingbotBingbot is Microsoft Bing's primary web crawler — the bot that discovers, fetches, and renders pages to build the Bing index. That index also powers Yahoo, DuckDuckGo, Ecosia, and Microsoft Copilot, so Bingbot's reach is far wider than Bing's own search-market share.-capability quotes below are directly verified, this longer one is not independently re-checked) — but Google is clear it’s a workaround: “Dynamic rendering was a workaround and not a long-term solution… Instead, we recommend that you use server-side rendering, static rendering, or hydrationActivating server-rendered HTML in the browser by attaching JavaScript handlers. as a solution.” (Relayed from Google’s dynamic-rendering doc; wording matches what’s already cited in this site’s broader JavaScript SEO guide.) It’s a bridge, not an architecture.

History API vs. hash routing (#!)

Five states, not two

Testing an SPA route gets confusing because “does it work” actually spans five different, separately-verifiable states:

StateWhat it is
Server responseThe bytes a fresh, JS-free HTTP request to a URL actually gets back.
Rendered DOMWhat a browser (or GooglebotGooglebot is Google's web crawler — the software that fetches pages so Google can index and rank them. It comes in two variants, Googlebot Smartphone (primary, under mobile-first indexing) and Googlebot Desktop, and runs an evergreen Chromium renderer.’s renderer) builds after executing JavaScript against that server response.
Search processingHow Google separately crawls the server response, later renders the page, and indexes based on both.
Browser full-document navigationAn actual new HTTP request to a URL — the only state that can change the server response or status code.
Browser soft navigationA History API transition (pushState/replaceState) that changes the visible URL, browser history, and on-screen UI.

The one everyone conflates: a soft navigation changes the URL and the UI, but it does not by itself create a new HTTP response or status — that only happens on a full navigation (or an equivalent direct request, like curl). Testing a route by clicking through the app from the homepage exercises soft navigation; testing it by requesting the URL directly exercises the server response. Both matter, and they can disagree.

What the History API gives you

Google’s recommendation is unambiguous: “We recommend using the History API to load different content based on the URL in a SPA.” (In the live doc “History API” is a link, so this deep link targets the lead-in clause.) The History API (pushState/replaceState) lets your router change the visible URL to a real, bookmarkable path — /products, not /#/products — without a full reload. That fixes the addressability half: every view now has a URL a server could respond to differently.

Why fragment/hash URLs are invisible to the server — and to Google

Because the fragment never reaches the server (see above), the History API is the only way to give each route a URL the server can actually serve. Google puts a floor under it in its basics doc: “don’t use fragments to load different page content. The following example is a bad practice, because Googlebot can’t reliably resolve the URLs.” This is the same guidance I’ve written elsewhere — use normal-looking URLs like /products, not hash URLs like /#/products, because Google can’t reliably index the hash ones.

The 2015 deprecation, briefly

The hash-bang (#!) era ended with Google’s 2015 post: “Times have changed. Today, as long as you’re not blocking Googlebot from crawling your JavaScript or CSS files, we are generally able to render and understand your web pages like modern browsers,” and “you can use the History API pushState() to ensure accessibility for a wider range of browsers (and our systems).” One nuance worth keeping: Google didn’t instantly deindex old hash-bang sites — “we’ll generally crawl, render, and index the #! URLs” — but “we can still try” is not “you should still do this.”

Making each route independently indexable

Clean URLs and real HTML get you crawled. To get indexed correctly, each route needs its own signals.

Per-route canonical tags — and the “most restrictive directive” trap

Every route needs its own rel=canonical in the rendered DOM. The trap: if a placeholder canonical (or a noindex) ships in the raw HTML shell and JavaScript is supposed to overwrite it later, you can get a conflict. Google resolves conflicts between the raw and rendered versions by taking the more restrictive signal — as I’ve put it before, Google will choose the most restrictive statements between the HTML and the rendered version of a page. A stray noindex or wrong canonical baked into the shell can silently suppress the whole route even after your JS “fixes” it.

Per-route titles and meta descriptions via JavaScript

Setting these in JS is fine — Google says so directly: “You can use JavaScript to set or change the meta descriptionThe meta description is an HTML head tag — `<meta name=\"description\" content=\"…\">` — that suggests a short summary of the page for the search snippet. It's not a Google ranking factor, and Google rewrites it the majority of the time, but a good one can still lift click-through. as well as the <title> element.” The requirement is that they land in the rendered DOM Google evaluates, not just flash briefly. Give each page its own title and description that update when the route changes.

Make your inter-route links real anchors with href attributes, not click handlers on <div>s. Google discoversGoogle Discover is a personalized, mobile-first content feed built into the Google app, Chrome's mobile New Tab page, and google.com that surfaces articles and videos based on a user's interests and activity — not a response to a search query. There's nothing to 'rank' for in the traditional sense; eligibility is governed by Discover's content policies plus the same helpful-content, image, and page-experience signals Google Search already uses. URLs by extracting hrefs; a <div onClick> that navigates via the router is invisible to the crawler’s link extraction. And don’t lean on client-side state to carry content across those navigations — Google’s renderer “does not retain state across page loads: Local Storage and Session Storage data are cleared across page loads. HTTP Cookies are cleared across page loads.”

Sitemap generation for SPA routes

There’s no special “SPA sitemap” format — it’s an accounting problem

The sitemap protocol is unchanged for SPAs. The actual work is making sure every route you list independently resolves to real, unique, rendered HTML. A sitemap of 500 client-side routes is worthless if those routes all return the same shell. So the sitemap is downstream of your rendering strategy, not a substitute for it.

Handling dynamic/parameterized routes (/product/:id)

For apps with parameterized routes, you can’t hand-maintain the list. The sitemap generator has to run against the same data source the app uses — a build script or a server endpoint that enumerates every id — so the sitemap and the app never drift apart. Those enumerated URLs are only worth listing once they’re SSR’d or prerendered.

Keeping the sitemap in sync with what’s server-resolvable

Regenerate the sitemap as part of your build or on a schedule tied to your content source. A route that 404s (or worse, soft-404s at 200) but sits in your sitemap is a crawl-budget and quality signal you don’t want.

Testing what Google actually sees

TIP Compare a deep SPA route before and after JavaScript

This result shows that most content was absent from the initial HTML. It does not say Google never renders it; it exposes the dependency and what non-rendering crawlers miss.

Run several independently addressable routes through my Render Gap Analyzer to compare their raw and rendered output. Render Gap Analyzer Free

  1. Test deep routes directly, without navigating from the home page first.
  2. Compare content, links, metadata, canonicals, robots directives, and the not-found route.
  3. Add SSR or prerendering and real server status handling, then verify every route independently.
A route is independently indexable only when its URL and output stand on their own.

Don’t spot-check one URL. The whole point of the SPA problem is that routes can differ in the browser but not on the wire, so test multiple routes at the raw-HTML level:

  • Fetch raw HTML per route with curl (a Googlebot user-agent where relevant) and confirm the content is unique per URL, not the shared shell.
  • URL Inspection in Search Console — compare the crawled/rendered HTML for several routes, and confirm the per-route title, description, and canonical are present.
  • Confirm error routes return the right signal — a “not found” view should either redirect to a real error status or carry noindex in the rendered DOM.

Common myths about SPA SEO

  • “Google can’t index SPAs at all.” Outdated. Google runs evergreen Chromium and generally renders SPA content. The real risks are specific: soft 404sA soft 404 is a URL that returns a success status code (usually 200 OK) even though the page is empty, missing, or shows a 'not found' message. It isn't a status code a server sends — it's a label search engines apply after comparing the response code against the rendered content, and they treat the page like a 404 for indexing., hash routing, render timeouts, and non-Google crawlers (Bing less reliably, most AI crawlers not at all).
  • “Adding the History API fixes SPA SEOSPA SEO is the practice of making a client-side-routed single-page application — one that loads a single HTML document and then swaps views with JavaScript (React Router, Vue Router, Angular Router) — crawlable and indexable, so every route resolves to real, unique HTML with its own URL, canonical, and metadata..” It fixes addressability only. If the server still returns the same shell for every route, Google still has to execute JS to see anything.
  • “Hash routing still works as a fallback.” Deprecated since 2015 and recommended against ever since.
  • “Client-side rendering is a ranking penalty.” No direct CSR penalty exists. The damage is indirect — failed/delayed rendering, soft 404s, and duplicate clustering from render timeouts reduce what gets indexed.
  • “Pre-rendering for bots is cloaking.” Not when the content matches what users eventually see — only the when/where of rendering differs, not the content.
  • “You need a special SPA sitemap generator.” No special format exists; the work is making every listed route resolve to real HTML.

FAQs

Can Google index a single-page application? Yes, if each route resolves to real, unique HTML (via SSR/prerendering) at a real URL. Bare client-only SPAs often don’t.

Do I need SSR, or is client-side rendering ever okay? CSR can work for content that doesn’t need to rank, but for anything you want indexed reliably, prerender or SSR it — don’t bet on the renderer executing your JS in time.

Is hash (#!) routing bad for SEO? Yes — the fragment never reaches the server, so Google can’t reliably resolve those URLs. Use the History API.

Does every route need its own canonical tagA rel=\"canonical\" annotation — in the HTML <head> or an HTTP Link header — that tells search engines which URL is the preferred version of duplicate or near-duplicate content.? Yes, in the rendered DOM — and make sure nothing more restrictive (a stray noindex or wrong canonical) ships in the raw shell.

Is a prerendering service cloaking? No, provided the content served to bots matches what users see.

Should I use a meta-framework instead of building routing myself? For a new build, usually yes — Next.js/Nuxt/Angular-SSR/SvelteKit give you SSR/SSG and per-route metadata for free.

Why does my SPA return 200 for pages that don’t exist? Because the client-side router keeps the original 200 status for virtual navigations. Fix it with a JS redirect to a real error status or a rendered noindex.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin a quote first.