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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 JavaScriptMaking sure search engines can crawl, render, and index content that depends on 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 crawlersA 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. all land on the same shell.
- 404 pages404 Not Found is the HTTP client-error status code a server returns when it can't find the requested URL — RFC 9110 defines it as no current representation, or unwillingness to disclose one. A \"hard 404\" actually returns the 404 status; a \"soft 404\" returns a success code (like 200) for a page that's really gone. 404s are normal and expected: the fact that some URLs 404 doesn't affect your site's other, successful pages, and Google de-indexes 404'd URLs over time (probably retrying for some period, less and less often). still say “200 OKHTTP 200 OK is the standard 2xx success status code, meaning the server received, understood, and fulfilled the request and is returning the resource. It's the code every page you want indexed should return — but a 200 alone doesn't guarantee Google will index the page..” A JavaScript router can show a “not foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore.” screen while the page technically reports success, so Google may indexStoring 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. 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 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. 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, 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 a200, 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:
- 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 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:
| 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 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 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 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.
Real <a href> links between routes
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
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
- Test deep routes directly, without navigating from the home page first.
- Compare content, links, metadata, canonicals, robots directives, and the not-found route.
- Add SSR or prerendering and real server status handling, then verify every route independently.
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 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.
AI summary
A condensed take on the Advanced version:
- 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. = 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 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. (routers keep a
200for “not foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore.” — Google’s two documented fixes are redirecting to a URL that itself 404s, or a JS-addednoindex; an initialnoindexcan cause renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 crawlersA 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. 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). - SitemapsA 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.: 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. is a stopgap, not a long-term solution.
- Deprecating our AJAX crawling scheme (2015) — the formal end of hash-bang 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 the pushState recommendation.
- Build and submit a sitemap — general 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. protocol (there’s no SPA-specific format).
Bing / Microsoft
- bingbot Series: JavaScript, Dynamic Rendering, and Cloaking. Oh My! — 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.’s JS-renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 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. can’t reliably resolve the URLs.” Jump to quote
Google — SPA 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.
- “In a single-page application (SPA), this can be especially difficult. To prevent error pages from being indexedStoring 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., 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 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.” Jump to quote
Google — meta tagsMeta tags are HTML elements in a page's head that pass metadata about the page to search engines and browsers. For SEO only a few matter — the title element, the meta description, and the robots meta tag — while meta keywords and most others are ignored. via JavaScript, and stateless renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM.
- “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.” 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 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. (2015)
- “In short: We are no longer recommending the 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. proposal we made back in 2009.” Jump to quote
- “Times have changed. Today, as long as you’re not blocking 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. 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. is a workaround (relayed; wording matches what’s already cited in this site’s broader JavaScript SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. 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 — 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. 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 frameworksJavaScript frameworks — React, Vue, Angular, Next.js, Nuxt, Svelte, Astro — are libraries or meta-frameworks for building web UIs. Their SEO impact depends on rendering mode: SSR and SSG deliver pre-rendered HTML; CSR-only apps require Googlebot to execute JavaScript before it can index content. 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 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. get real HTML for this route without executing my JS?” — and start by confirming the route actually needs to be indexedStoring 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. 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) → PrerenderThe Speculation Rules API is a Chromium browser API (Chrome/Edge 109+) that lets a site tell the browser which same-site pages to prefetch (download the HTML document) or prerender (fully load and render in an invisible tab) before a visitor clicks — so the next navigation can be near-instant. It's a browser-side performance feature for real users, not a crawling, indexing, or ranking signal. / SSG at build time. Simplest robust fix — every route is real HTML on disk.
- Yes → Server-side renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. (SSR) so each request returns route-specific HTML.
3. Can’t do SSR or prerendering right now (legacy hand-rolled SPA)?
- Use dynamic renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. as a temporary bridge — serve crawlersA 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. 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 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. + 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 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.). - 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. 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 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. executing your JS (SSR, prerenderingTurning HTML, CSS, and JavaScript into the final visual page and DOM./SSG, or a meta-framework).
- Route content loads before the render window closes (avoid boilerplate-only duplicates).
Per-route indexability
- Unique
<title>and 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. 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 foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore.” routes 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. to a URL whose server returns a real error status or
carry
noindexadded by JavaScript (no 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. 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. the page.
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.
- 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 InspectionA Google Search Console feature that reports how Google sees one specific URL on a property you own. By default it shows the last-indexed snapshot; a separate \"Test live URL\" mode fetches the current version. 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, prerenderingTurning HTML, CSS, and JavaScript into the final visual page and DOM., or a meta-framework) are independent. Clean URLs with no per-route HTML = 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 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
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. 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 404A 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.
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 |
RenderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. strategies
| Strategy | 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. 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. | Yes, for bots only | Temporary bridge on legacy SPAs |
| Bare CSR (client-only) | No | Nothing you want indexedStoring 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. reliably |
Per-route must-haves (all in the rendered DOM)
- Unique URL (History API) · unique
<title>· unique 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. · 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 foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore.” → 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.. - Google reconciles raw vs. rendered by taking the more restrictive signal.
- No special SPA 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. format exists — every listed route must resolve to real HTML.
SPA SEO anti-patterns
Shipping a bare CSR SPA and submitting a full 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.. 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. lists 500 routes; all 500 return the same shell to a fresh fetch. The sitemap doesn’t make content exist — renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore..
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 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. get thin/empty pages indexedStoring 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.. 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. 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 prerenderingTurning HTML, CSS, and JavaScript into the final visual page and DOM.. 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-foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore. 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,
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. 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 foundA 302 (\"Found\") is a temporary redirect: it forwards users to a new URL while telling search engines the original URL should stay in the index. It's a weak canonicalization signal, not the zero-equity dead end of SEO folklore.” 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 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. 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 InspectionA Google Search Console feature that reports how Google sees one specific URL on a property you own. By default it shows the last-indexed snapshot; a separate \"Test live URL\" mode fetches the current version. (Google Search ConsoleA free Google service that reports how a site performs in Google Search and surfaces problems with how Google crawls, indexes, and serves it. It's first-party data straight from Google — but you don't need it to appear in results.) — see the crawled vs. rendered HTML for a route and confirm the per-route title, description, and canonical actually land.
- Rich ResultsRich results (formerly 'rich snippets') are enhanced search listings — stars, images, prices, breadcrumbs, video thumbnails, and more — that Google and Bing build from structured data. They're a display feature, not a ranking factor, and eligibility never guarantees they'll show. Test / URL Inspection “View crawled page” — Google’s own render of a single URL, useful for confirming content survives renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM..
curlwith a 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. UA — the fastest way to compare the raw HTML of several routes and catch a shared shell.- Screaming Frog SEO SpiderA 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. (JavaScript renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. 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 ToolsMicrosoft's free portal for monitoring and improving how a site appears in Bing search — the peer to Google Search Console, plus IndexNow instant indexing, richer backlink data, and keyword volumes. Because Bing's index also feeds Microsoft Copilot, it doubles as a window into AI-search visibility. — URL Inspection — 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. renders JS less consistently than 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., 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/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. to an error response. Failure
interpretation: The SPA is generating 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.. 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 recrawlCrawl frequency is how often a search engine comes back to re-fetch a page it already knows about. Popular pages that change often get refreshed many times a day; stable pages can go weeks or months between crawls — and you influence it indirectly, not by setting a dial. in URL InspectionA Google Search Console feature that reports how Google sees one specific URL on a property you own. By default it shows the last-indexed snapshot; a separate \"Test live URL\" mode fetches the current version.. 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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM. and 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. fit in the bigger picture.
My speaking
- How Search Works (SlideShare) — my walkthrough of 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., renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM., 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., 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! — 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.’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 SEONuxt SEO is the practice of optimizing Nuxt.js (Vue's meta-framework) apps so search engines and AI crawlers can crawl, render, and index them. Nuxt's big advantage is that it ships server-side rendering by default, so pages arrive as fully-formed HTML instead of an empty Vue SPA shell.) — 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 SEOMaking sure search engines can crawl, render, and index content that depends on JavaScript. series covers renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM., 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 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. (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 renderingTurning HTML, CSS, and JavaScript into the final visual page and DOM., 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 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. full of URLs that all render the same shell. On top of that, each route needs 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., title, and 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. 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.