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.
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 — A single-page application (SPA) loads one page from the server and then uses JavaScript to switch “pages” without a full reload. The catch: many SPAs send the same near-empty starting page no matter which URL a search engine asks for — that’s a common risk of how SPAs are usually built, not a guarantee of every SPA. To fix it, make sure every route has its own real URL and its own real HTML — usually by rendering pages on the server or pre-building them.
What an SPA is
A single-page application commonly updates client-side views and routes without a full document navigation. 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 Google can render JavaScript SPAs, but crawlable URLs, links, status handling, and rendered content remain necessary. 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
A single-page application is a website built as one HTML page. When you click around — from the products page to the about page — JavaScript swaps out what’s on screen instead of asking the server for a whole new page. React (with React Router), Vue (with Vue Router), and Angular all work this way by default. It feels fast and app-like, which is why it’s popular.
The risk is what the server sends. Many SPAs use an “app shell” implementation:
the first time anyone (including Google) loads the app, the server returns a
mostly-empty shell, and the real content gets built in the browser afterward. That’s
one common way to build an SPA — not the definition of one, and not something every
SPA does. But when a site does ship a bare app shell, the failure mode is real: if
Google asks the server for /about and /products separately, it can get the exact
same blank shell for both, because nothing about the route changed what the server
sent. The way to know whether your app is in that position is to check what a direct
request to each route actually returns — not to assume it from the fact that it’s an
SPA.
Why that hurts SEO
Search engines need to see your content to rank it. With a bare SPA, three things tend to go wrong:
- Every URL looks the same to the server. Deep links, shares, and crawlers all land on the same shell.
- 404 pages still say “200 OK.” A JavaScript router can show a “not found” screen while the page technically reports success, so Google may index empty pages.
- The wrong URLs. Older SPAs used addresses like
example.com/#/products. Google can’t reliably index those.
How to fix it (the short version)
- Give each view a real URL using the browser’s History API (clean paths like
/products), not#-based ones. - Send real HTML for each URL. Render pages on the server (SSR) or pre-build them ahead of time (prerendering). If you’re starting fresh, a framework that does this for you — Next.js, Nuxt, SvelteKit, Angular’s SSR — saves you the trouble.
- Give each page its own title and description that change when the route changes.
- Make links real links (
<a href>), not clickable<div>s.
Want the mechanics — why hash routing fails, the “most restrictive directive” trap with canonicals, and how to generate a sitemap for client-side routes? Switch to the Advanced tab.
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, prerendering/SSG, or a meta-framework). Do only the first and you get a tidy sitemap 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 404s that keep a200, and every route in the sitemap 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 rendering, prerendering, or carefully implemented client rendering can expose content, but none guarantees indexing. 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 SEO guide (which covers parity, lazy-loading, infinite scroll, 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 crawling and indexing.
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 content, 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 crawler that fetches the URL once gets
identical HTML regardless of the fragment.
That’s also why Google formally deprecated its 2009 AJAX-crawling 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 code 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: redirect (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 Console 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:
- URL addressability — History API, unique per-route URLs, no fragments.
- 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 crawlers 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 SEO 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 bingbot-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 hydration 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:
| State | What it is |
|---|---|
| Server response | The bytes a fresh, JS-free HTTP request to a URL actually gets back. |
| Rendered DOM | What a browser (or Googlebot’s renderer) builds after executing JavaScript against that server response. |
| Search processing | How Google separately crawls the server response, later renders the page, and indexes based on both. |
| Browser full-document navigation | An actual new HTTP request to a URL — the only state that can change the server response or status code. |
| Browser soft navigation | A 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 description 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.
Real <a href> links between routes
Make your inter-route links real anchors with href attributes, not click handlers on
<div>s. Google discovers 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
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
noindexin 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 404s, hash routing, render timeouts, and non-Google crawlers (Bing less reliably, most AI crawlers not at all).
- “Adding the History API fixes SPA SEO.” 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 tag? 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.
AI summary
A condensed take on the Advanced version:
- SPA SEO = client-side routing specifically (React Router, Vue Router, Angular Router). An SPA is defined by loading one document and swapping views with JavaScript — an “app shell” that sends near-empty HTML for every URL is one common implementation, not a rule every SPA follows; check what a direct request actually returns rather than assuming.
- The core risk: client-side routing can change what the user sees without
changing what the server would return. On a bare app-shell SPA, fetching
/productsand/aboutdirectly can get byte-identical raw HTML. - Five states get conflated: server response, rendered DOM, Search processing, browser full-document navigation, and browser soft navigation. A History API transition changes the URL and UI but doesn’t itself create a new server response or status.
- Three symptoms of a bare shell: the app-shell problem, soft 404s (routers keep a
200for “not found” — Google’s two documented fixes are redirecting to a URL that itself 404s, or a JS-addednoindex; an initialnoindexcan cause rendering to be skipped), and shared-shell duplicates — which “render timeout” (Illyes) is one possible cause of, not an automatic diagnosis without request/render/Search evidence. - Hash routing fails mechanically: the fragment after
#never reaches the server, so the server can’t differ by route. Deprecated by Google in 2015; History API gives addressability, not a complete indexability fix. - The fix has two independent halves where a bare shell is the problem: addressability (History API, unique per-route URLs) and content availability (SSR, prerendering/SSG, or a meta-framework) — but SSR isn’t a universal requirement; base the call on whether the route needs to rank, what it already returns directly, and which crawlers need to render it.
- Per route: own canonical/title/description in the rendered DOM; watch the
“most restrictive directive” trap where a raw-HTML
noindex/canonical overrides your JS. Real<a href>links; don’t rely on client state (WRS clears storage/cookies across loads). - Sitemaps: listing a route doesn’t prove it resolves — no special format exists; every listed route must independently resolve to real HTML, and dynamic routes need a generator tied to the app’s data source.
- Dynamic rendering is a Google/Bing-accepted stopgap, not a long-term architecture.
- Test multiple routes at the raw-HTML level (direct requests), not just by navigating within the app.
Official documentation
Primary-source documentation from the search engines.
- Understand the JavaScript SEO basics — the app-shell model, “Use the History API instead of fragments,” and setting titles/descriptions via JS.
- Fix Search-related JavaScript problems — the SPA soft-404 section, “Don’t use URL fragments to load different content,” and the History API recommendation.
- Dynamic Rendering as a workaround — why dynamic rendering is a stopgap, not a long-term solution.
- Deprecating our AJAX crawling scheme (2015) — the formal end of hash-bang crawling and the pushState recommendation.
- Build and submit a sitemap — general sitemap protocol (there’s no SPA-specific format).
Bing / Microsoft
- bingbot Series: JavaScript, Dynamic Rendering, and Cloaking. Oh My! — bingbot’s JS-rendering capabilities and its dynamic-rendering stance.
Quotes from the source
On-the-record statements from Google and Bing. Each link is a deep link that jumps to the quoted passage on the source page.
Google — the app-shell problem
- “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.” Jump to quote
Google — hash/fragment routing and the History API
- “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” Jump to quote
- “We recommend using the History API to load different content based on the URL in a SPA.” Jump to quote (In the live doc “History API” is a link, so this deep link targets the lead-in clause; the full sentence is present verbatim in order.)
- “don’t use fragments to load different page content. The following example is a bad practice, because Googlebot can’t reliably resolve the URLs.” Jump to quote
Google — SPA soft 404s
- “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.” Jump to quote
- “When a SPA is using client-side JavaScript to handle errors they often report a
200HTTP status code instead of the appropriate status code.” Jump to quote
Google — meta tags via JavaScript, and stateless rendering
- “You can use JavaScript to set or change the meta description as well as the
<title>element.” Jump to quote - “WRS 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.” Jump to quote
Google — deprecating hash-bang crawling (2015)
- “In short: We are no longer recommending the AJAX crawling proposal we made back in 2009.” Jump to quote
- “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.” Jump to quote
Google — dynamic rendering is a workaround (relayed; wording matches what’s already cited in this site’s broader JavaScript SEO guide, not independently re-verified this pass)
- “Dynamic rendering was a workaround and not a long-term solution for problems with JavaScript-generated content in search engines.” Jump to quote
Bing — bingbot and JavaScript
- “bingbot is generally able to render JavaScript.” — bingbot Series, Bing Webmaster Blog. Read the post
- “bingbot does not necessarily support all the same JavaScript frameworks that are supported in the latest version of your favorite modern browser.” — same post. Read the post
Gary Illyes, Google — render timeouts create duplicates (relayed via a LinkedIn post, which resists automated verification; treat as high-confidence secondary)
- “the centerpiece took forever to load, so rendering timed out… and we were left with a bunch of pages that only had the boilerplate. With only the boilerplate, those pages are dups.” Read the post
Which rendering path should I take?
Work top-down. The question is always “does the crawler get real HTML for this route without executing my JS?” — and start by confirming the route actually needs to be indexed and checking what a direct, JS-free request already returns; not every route needs SSR, and some already return usable HTML.
1. Are you starting a new build?
- Yes → Use a meta-framework (Next.js, Nuxt, Angular with SSR, SvelteKit, Remix). SSR/SSG and per-route metadata come built in. Stop here.
- No → Continue.
2. Does your content change per user / per request?
- No (mostly static content) → Prerender / SSG at build time. Simplest robust fix — every route is real HTML on disk.
- Yes → Server-side rendering (SSR) so each request returns route-specific HTML.
3. Can’t do SSR or prerendering right now (legacy hand-rolled SPA)?
- Use dynamic rendering as a temporary bridge — serve crawlers a rendered snapshot. Plan the migration to SSR/SSG; don’t treat this as the destination.
4. Whatever path you chose, confirm all of these per route:
- Real URL via the History API (no
#!fragments). - Unique title + meta description + canonical in the rendered DOM.
- Nothing more restrictive (
noindex, wrong canonical) baked into the raw shell. - Real
<a href>links between routes. - Error routes return a real error status or a rendered
noindex(no soft 404s). - Every route in the sitemap independently resolves to real HTML.
SPA SEO checklist
Addressability
- Each view has a real URL via the History API (
/products), not a fragment (/#/products). - Inter-route links are real
<a href>anchors, not<div onClick>handlers.
Content availability
- Every route returns real, unique HTML without the crawler executing your JS (SSR, prerendering/SSG, or a meta-framework).
- Route content loads before the render window closes (avoid boilerplate-only duplicates).
Per-route indexability
- Unique
<title>and meta description per route, present in the rendered DOM. - Unique
rel=canonicalper route in the rendered DOM. - No stray
noindex/placeholder canonical in the raw shell that a more restrictive signal could lock in.
Errors
- “Not found” routes redirect to a URL whose server returns a real error status or
carry
noindexadded by JavaScript (no soft 404s at200). - If using the
noindexroute, confirm it’s added after the app decides the route is invalid, not present from the very first paint — an initialnoindexcan cause Google to skip rendering the page.
Sitemap
- Every listed route independently resolves to real HTML.
- Dynamic routes (
/product/:id) enumerated from the app’s data source, not hand-maintained.
Verification
- Raw HTML checked across multiple routes (curl), confirming unique content per URL.
- URL Inspection confirms rendered content, title, description, and canonical per route.
The mental models
1. The two halves you must not conflate. Addressability (History API, unique URLs, no fragments) and content availability (SSR, prerendering, or a meta-framework) are independent. Clean URLs with no per-route HTML = a tidy sitemap of blank shells. You need both.
2. “What would the server return, fresh?”
The whole SPA problem is that the browser view diverges from the server response. For
any route, ask what a curl with no JS execution gets back. If it’s the shell, the
crawler may see the shell too.
3. The status-code default is 200 — including for errors.
Client-side routers keep the original 200. Assume every “error” view is a soft 404
until you’ve deliberately made it return a real error status or a rendered noindex.
4. The most-restrictive-directive trap.
Google reconciles raw vs. rendered by taking the more restrictive signal. A noindex
or wrong canonical in the shell can override your JS “fix.” Audit the raw HTML, not just
the rendered DOM.
5. Meta-framework first for new builds. Hand-rolling client-side routing means owning every one of these problems. Picking a framework that does SSR/SSG and per-route metadata is choosing not to have them.
SPA SEO cheat sheet
Routing
| Approach | URL example | Reaches the server? | Google-safe? |
|---|---|---|---|
| History API | /products | Yes | Yes (recommended) |
| Hash routing | /#/products | No (fragment never sent) | No — deprecated 2015 |
Hash-bang (#!) | /#!/products | No | No — legacy AJAX scheme, deprecated |
Rendering strategies
| Strategy | Crawler gets real HTML without running JS? | Best for |
|---|---|---|
| SSR | Yes | Per-user / per-request content |
| Prerendering / SSG | Yes | Mostly-static content |
| Meta-framework (Next/Nuxt/etc.) | Yes (built in) | New builds |
| Dynamic rendering | Yes, for bots only | Temporary bridge on legacy SPAs |
| Bare CSR (client-only) | No | Nothing you want indexed reliably |
Per-route must-haves (all in the rendered DOM)
- Unique URL (History API) · unique
<title>· unique meta description · uniquerel=canonical· real<a href>links · error routes with a real status ornoindex.
Fast facts
- Fragment after
#is never sent to the server — that’s why hash routing fails. - Client-side routers keep
200for everything, including “not found” → soft 404s. - Google reconciles raw vs. rendered by taking the more restrictive signal.
- No special SPA sitemap format exists — every listed route must resolve to real HTML.
SPA SEO anti-patterns
Shipping a bare CSR SPA and submitting a full sitemap. The sitemap lists 500 routes; all 500 return the same shell to a fresh fetch. The sitemap doesn’t make content exist — rendering does.
Adding the History API and calling it done. Clean URLs fix addressability, not content. Without SSR/prerendering the server still returns the shell for every route.
Hash / hash-bang routing “as a fallback.” The fragment never reaches the server, so the server can’t differ by route. Deprecated since 2015; not a fallback, a dead end.
Navigating with <div onClick> instead of <a href>.
Google extracts hrefs to discover URLs. Click-handler navigation is invisible to link
extraction, so those routes may never be found.
A placeholder noindex or canonical in the raw shell “that JS will overwrite.”
Google takes the more restrictive of raw vs. rendered. The shell’s directive can win and
silently suppress the route.
Returning 200 for “not found” views.
Soft 404s get thin/empty pages indexed. Redirect to a real error status or add a
rendered noindex.
Loading route content slowly, after the boilerplate. Render timeouts leave only the shared shell, and every route collapses into a duplicate of every other (Illyes). Load the centerpiece content first.
Relying on client state to carry content across navigations. The renderer clears Local/Session Storage and cookies across page loads — content that only exists in client state won’t be there when Google renders the next route.
Common SPA indexing issues
Every route returns the same HTML
Symptom: /products and /about have different browser views but identical raw
responses. Likely cause: History API routing provides addressability without SSR
or prerendering. Fix: Produce route-specific HTML and confirm a direct request to
each path contains its own heading, body copy, and metadata.
Missing routes appear as successful pages
Symptom: A nonexistent route shows a not-found view while returning 200.
Likely cause: The client router owns the error after the server has already sent a
successful shell. Fix: Return the right server status; if that cannot ship yet,
redirect to a URL with a real error status or render noindex. Confirm with a fresh
direct request, not an in-app navigation.
Routes collapse into duplicates after rendering
Symptom: Search engines cluster distinct URLs or retain only shared navigation. Likely cause: Route content loads too late and rendering captures boilerplate. Fix: Prioritize primary content in the server response or earliest render path. Confirm several routes expose unique content before optional scripts finish.
Testing what the server actually returns per route
The SPA trap is that routes differ in the browser but not on the wire. Check multiple routes at the raw-HTML level, not one.
Fetch raw HTML per route (macOS / Linux)
# Fetch a few routes with a Googlebot UA and compare — they should NOT be identical
UA="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
for path in / /products /about /product/123; do
echo "=== $path ==="
curl -s -A "$UA" "https://example.com$path" | wc -c # byte counts should differ
doneDiff two routes’ raw HTML (are they the same shell?)
UA="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
diff <(curl -s -A "$UA" https://example.com/products) \
<(curl -s -A "$UA" https://example.com/about) \
&& echo "IDENTICAL — bare shell, content is client-only" \
|| echo "Different — routes return distinct HTML (good)"Check the per-route title and canonical in the raw response (grep / regex)
UA="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
curl -s -A "$UA" https://example.com/products \
| grep -Eio '<title>[^<]*</title>|<link[^>]+rel=["'"'"']canonical["'"'"'][^>]*>'Check the HTTP status of a “not found” route (soft-404 detector)
# A missing route should NOT return 200
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/this-route-does-not-existIn the browser DevTools console — inspect the rendered title/canonical
// Run on each route after client-side navigation to confirm JS set them
console.log('title:', document.title);
console.log('description:',
document.querySelector('meta[name="description"]')?.content);
console.log('canonical:',
document.querySelector('link[rel="canonical"]')?.href);Bookmarklet — flag click-handler “links” that aren’t real anchors
javascript:(()=>{const bad=[...document.querySelectorAll('[onclick],div[role="link"]')]
.filter(e=>!e.closest('a[href]'));
bad.forEach(e=>e.style.outline='3px solid red');
alert(bad.length+' non-anchor clickable(s) outlined — these are invisible to link extraction');})();XPath — find fragment/hash links a crawler can’t resolve (paste into DevTools console)
$x('//a[starts-with(@href, "#") or contains(@href, "/#/")]')
.map(a => a.getAttribute('href'));
// Any results are hash-routed links; migrate them to History API paths. Patrick's relevant free tools
- SEO Incident Simulator — Practice thirty deterministic technical SEO incident investigations — indexability, crawl controls, redirects, sitemaps, markup, caching, DNS, bot verification, rendering, hreflang, and faceted navigation — with clearly labeled fixture evidence and Find → Fix → Verify handoffs.
- Raw vs. Rendered HTML Checker — See what's in your page's initial HTML versus after JavaScript runs — headless-Chrome rendering only when the page actually needs it, a rendering-strategy verdict (SSR / prerendered / CSR / hybrid), ~15 calibrated JavaScript-SEO checks (noindex, canonicals, robots.txt blocking, links, soft 404s), a side-by-side raw-vs-rendered diff, and shareable reports.
- Google Index Checker — Check one URL’s observable indexability blockers, or reconcile sitemap, crawl, and supplied Search Console evidence across a URL set before verifying Google’s actual state in URL Inspection.
Tools for auditing an SPA
- URL Inspection (Google Search Console) — see the crawled vs. rendered HTML for a route and confirm the per-route title, description, and canonical actually land.
- Rich Results Test / URL Inspection “View crawled page” — Google’s own render of a single URL, useful for confirming content survives rendering.
curlwith a Googlebot UA — the fastest way to compare the raw HTML of several routes and catch a shared shell.- Screaming Frog SEO Spider (JavaScript rendering mode) — crawl the site with and without rendering to compare raw vs. rendered content and titles across every route.
- Ahrefs Site Audit — surfaces indexability issues, missing/duplicate titles and canonicals, and soft-404-like patterns across routes.
- DebugBear — SPA-oriented performance monitoring; render timing matters because slow routes can time out into boilerplate duplicates.
- Bing Webmaster Tools — URL Inspection — bingbot renders JS less consistently than Googlebot, so confirm your routes on Bing’s side too.
Prove an SPA route is independently indexable
Test direct-entry HTML parity
Test to run: Open representative routes in a new session and fetch the same URLs
with curl. Expected result: Each URL returns its own primary content and head
signals without prior app state. Failure interpretation: The route depends on
client navigation or stored state. Monitoring window: Immediate. Rollback
trigger: A route works only after entering through the homepage.
Test error status handling
Test to run: Request a known-invalid route directly and inspect both status and
rendered directives. Expected result: A genuine error status, or the documented
fallback of a rendered noindex/redirect to an error response. Failure
interpretation: The SPA is generating soft 404s. Monitoring window: Immediate.
Rollback trigger: Invalid paths ship as indexable 200 pages.
Test metadata isolation
Test to run: Compare the raw and rendered title, robots tag, and canonical across at least three routes. Expected result: Each route has one intended, internally consistent set. Failure interpretation: The shared shell leaks metadata between routes or JS overwrites it too late. Monitoring window: Immediate locally and after recrawl in URL Inspection. Rollback trigger: Any route inherits another route’s canonical or a restrictive shell directive.
Test yourself: SPA SEO
Five quick questions on making single-page applications crawlable and indexable. Pick an answer for each, then check.
Resources worth your time
My related writing
- JavaScript SEO Issues & Best Practices — my full JS-SEO guide, including the “don’t use fragments in URLs” and app-shell/duplicate-content sections that this article builds on.
- The Beginner’s Guide to Technical SEO — where rendering and crawling fit in the bigger picture.
My speaking
- How Search Works (SlideShare) — my walkthrough of crawling, rendering, indexing, and ranking, which is the pipeline an SPA has to survive. (My standing disclaimer applies: “This is my understanding of systems… not going to be 100% complete or accurate.”)
From around the industry
- Google — Understand the JavaScript SEO basics — the app-shell model and “Use the History API instead of fragments.”
- Google — Fix Search-related JavaScript problems — the SPA soft-404 section and the History API recommendation.
- Google — Deprecating our AJAX crawling scheme (2015) — the formal end of hash-bang crawling.
- Bing — bingbot Series: JavaScript, Dynamic Rendering, and Cloaking. Oh My! — bingbot’s JS-rendering stance.
- It’s Not Cloaking To Use A Pre-Render Service For Blank HTML Pages For SPA Development (Search Engine Roundtable, 2015) — coverage of Gary Illyes on pre-rendering SPAs not being cloaking. (SER’s paraphrase of Illyes, dated 2015 — secondary.)
- SEO for Single Page Applications (Nuxt SEO) — a framework-side walkthrough of the same problems.
- How To Optimize Single Page Applications For SEO (DebugBear) — the rendering/performance angle on SPA indexability.
- SPA (glossary) (MDN) — a neutral definition of the single-page-application pattern.
Videos
- Google Search Central (YouTube) — Martin Splitt’s JavaScript SEO series covers rendering, the History API, and the SPA failure modes discussed here. Channel
SPA SEO
SPA 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.
Related: JavaScript SEO, Rendering
SPA SEO
A single-page application (SPA) loads one HTML document once and then uses JavaScript to swap views instead of requesting a new page from the server. SPA SEO is the work of making those client-side routes visible to search engines.
The core problem is architectural: by default the server only ever returns one URL’s worth of real HTML — an almost-empty app shell — while every other “page” exists only after JavaScript runs in the browser. A crawler (or a curl, or a social share) hitting /products and /about on a bare client-side-rendered SPA can get byte-identical raw HTML for both.
The fix has two independent halves that are often conflated. Addressability: use the History API (not hash/#! fragments) so every view has a real, bookmarkable URL. Content availability: server-side rendering, static prerendering/SSG, or a meta-framework that does one of those by default, so each of those URLs actually returns different, complete HTML. Doing only the first — clean URLs with no per-route HTML — still leaves a crawlable-looking sitemap full of URLs that all render the same shell. On top of that, each route needs its own canonical tag, title, and meta description in the rendered DOM, and every listed route must independently resolve to real HTML.
Related: JavaScript SEO, Rendering
Build-time retrieval analysis plus live signals for this exact article. The automatic chunk report includes a deterministic readiness score and is ready without a model download.
Search Console
sampleGA4 traffic (28d)
sampleCloudflare traffic (7d)
sampledCrUX field data (28d, phone)
sampleGoogle NLP entities
localChangelog
Updated Jul 18, 2026.
Editorial summary and recorded change details.Summary
Corrected the article to define an SPA independently of the app-shell rendering pattern (app shell is one implementation, not the definition), added a five-state matrix distinguishing server response/rendered DOM/Search processing/full navigation/soft navigation, qualified the SSR recommendation as conditional rather than universal, qualified 'render timeout' as one possible cause of shared-shell duplicates rather than an automatic diagnosis, and added the initial-noindex-can-skip-rendering caveat to the soft-404 fix.
Change details
-
Reframed 'What an SPA is' (beginner and advanced) so the app-shell/near-empty-HTML pattern is presented as one common implementation, not something every SPA does — readers are told to check what a direct request to each route actually returns.
-
Added a 'Five states, not two' table (server response, rendered DOM, Search processing, full navigation, soft navigation) to the History API section, clarifying that a History API transition changes URL/UI without itself creating a new HTTP response.
-
Qualified the SSR/prerendering recommendation in 'The fix' and the decision tree as conditional on whether a route needs to rank, what it already returns directly, and which crawlers need to render it — not a universal requirement for every SPA route.
-
Rewrote the render-timeout section to frame Gary Illyes' explanation as one possible cause of shared-shell duplicates, not an automatic diagnosis, and added Google's own unqualified render-queue wording ('a few seconds, but it can take longer than that') in place of any assumed fixed timeout.
-
Added a caveat to the soft-404 fix and checklist that an initial noindex present before the app decides a route is invalid can cause Google to skip rendering the page.
Full comparison unavailable — no prior snapshot was archived for this revision.