Guide SPA SEO
How to faire application monopages (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 pour client-side routes.
Langues
A application monopage loads un document and swaps views with JavaScript au lieu de requesting a nouveau page from le serveur. Nombreux SPAs implement que with an 'app shell' — le serveur renvoie un URL's worth of réel HTML, a near-empty shell, jusqu’à JS renders chaque route — but that's a courant implementation choice, pas a rule every SPA follows; vérifier ce que a direct requête to chaque route en réalité renvoie avant assuming it. The fix, où a bare app-shell setup is the problem, has two independent halves: addressability (History API, pas hash/#! fragments, so chaque view has a réel URL — a navigateur soft navigation via the History API changements l’URL and UI but doesn't itself créer a nouveau server réponse) and content availability (SSR, prerendering, or a meta-framework so chaque of ceux URLs peut retourner unique HTML on requête). On top of que, vérifier chaque route's title, canonical, and robots state les deux on direct entry and après rendering, handle soft 404s (routers tend to garder a 200 status pour 'introuvable' views — redirection to une URL que itself 404s, or ajouter a rendered noindex, though an initial noindex peut causer rendering to be skipped), and assurez-vous every route in votre sitemap en réalité resolves to réel, indexable HTML — there's aucun special SPA sitemap format.
TL;DR — A application monopage (SPA) loads un page from le serveur and alors uses JavaScript to switch “pages” sans a complet reload. The catch: nombreux SPAs send the même near-empty starting page aucun matter qui URL a moteur de recherche demande pour — that’s a courant risk of how SPAs are usually construit, pas a guarantee of every SPA. To fix it, assurez-vous every route has its propre réel URL and its propre réel HTML — usually by rendering pages on le serveur or pre-building les.
Ce que an SPA is
A application monopage commonly updates client-side views and routes sans a complet 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 peut render JavaScript SPAs, but crawlable URLs, liens, 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 application monopage is a website construit as un HTML page. Quand vous click autour — from the products page to the à propos de page — JavaScript swaps out what’s on screen au lieu de asking le serveur pour a whole nouveau page. React (with React Router), Vue (with Vue Router), and Angular tout fonctionner ce façon by par défaut. It feels fast and app-like, qui is pourquoi it’s popular.
The risk is ce que the server sends. Nombreux SPAs utiliser an “app shell” implementation:
the premier temps anyone (notamment Google) loads the app, le serveur renvoie a
mostly-empty shell, and the contenu réel obtient construit in le navigateur afterward. That’s
un courant façon to construire an SPA — pas the definition of un, and pas something every
SPA fait. But quand a site fait ship a bare app shell, the échec mode is réel: si
Google demande le serveur pour /about and /products separately, it peut obtenir the exact
même blank shell pour les deux, parce que nothing à propos de the route modifié ce que le serveur
sent. The façon to know si votre app is in que position is to vérifier ce que a direct
requête to chaque route en réalité renvoie — pas to assume it from the fact que it’s an
SPA.
Pourquoi que hurts SEO
Moteur de recherches besoin to voir votre content to rank it. With a bare SPA, three choses tend to go incorrect:
- Every URL semble the même to le serveur. Deep liens, shares, and robots d’exploration tout land on the même shell.
- 404 pages encore dire “200 OK.” A JavaScript router peut montrer a “not found” screen pendant que lune page technically reports success, so Google may index vide pages.
- The incorrect URLs. Older SPAs utilisé addresses comme
example.com/#/products. Google can’t reliably index ceux.
Comment corriger it (La version courte)
- Give chaque view a réel URL en utilisant le navigateur’s History API (clean paths comme
/products), pas#-fondé ones. - Send réel HTML pour chaque URL. Render pages on le serveur (SSR) or pre-build les ahead of temps (prerendering). Si you’re starting fresh, a framework que fait ce pour vous — Suivant.js, Nuxt, SvelteKit, Angular’s SSR — saves vous the trouble.
- Give chaque page its propre title and description que modifier quand the route changements.
- Faire liens réel liens (
<a href>), pas clickable<div>s.
Vouloir the mechanics — pourquoi hash routing fails, the “most restrictive directive” trap with canonicals, and how to generate a sitemap pour client-side routes? Switch to the Avancé tab.
TL;DR — An SPA’s core SEO risk isn’t “JavaScript” in the abstract — it’s que client-side routing changements ce que the utilisateur sees sans modification ce que the server voudrait retourner. The fix has two independent halves personnes constantly conflate: addressability (History API, unique per-route URLs, aucun
#!fragments) and content availability (SSR, prerendering/SSG, or a meta-framework). Do seulement the premier and vous obtenir a tidy sitemap of URLs que tout render the même shell. On top of les deux, chaque route nécessite its propre canonical/title/description in the rendered DOM, vous have to handle soft 404s que garder a200, and every route in le sitemap has to independently resolve to réel HTML.
Ce que SPA SEO en réalité is
SPA is an application architecture, pas a guaranteed SEO échec 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 peut expose content, but none guarantees indexation. 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
Ce is the deep dive on application monopages specifically — client-side routing with React Router, Vue Router, or Angular Router, où après the premier charger le serveur jamais sends un autre complet page. It sits alongside the broader JavaScript SEO guide (qui covers parity, lazy-loading, infinite scroll, and JS rendering généralement) and the per-framework write-ups pour React, Suivant.js, Nuxt, Angular, Vue, Svelte, and Astro. Ici I’m seulement interested in the routing couche and ce que it fait to exploration and indexation.
Pourquoi bare client-side-routed SPAs échouer at SEO
Un URL, un HTML réponse — the app-shell problem
Google describes the échec mode precisely: “Some JavaScript sites may utiliser the app
shell model où the initial HTML ne fait pas contain the réel content and Google nécessite
to execute JavaScript avant being able to voir the réel page content.” In a bare
SPA, the “app shell” is tout le serveur ever renvoie. Récupérer /products fresh and vous
obtenir the même near-empty document you’d obtenir pour /about. Le contenu seulement diverges
après le navigateur runs votre JavaScript and votre router decides ce que to montrer.
That’s the whole problem in un sentence: client-side routing changements ce que the utilisateur sees sans modification ce que le serveur voudrait retourner. Everything downstream — soft 404s, contenu dupliqué, manquant titles — is a symptom of que.
Le serveur jamais sees qui “page” was requested (pourquoi hash routing fails)
Older SPAs charger views from URL fragments — example.com/#/products. Google’s exact
words: “A SPA may utiliser URL fragments (Par exemple https://example.com/#/products) pour
chargement différent views.” Ce fails pour a spécifique, mechanical raison: navigateurs
jamais send the fragment (anything après #) to le serveur in the HTTP requête. The
server literally ne peut pas know qui “page” was requested, so it can’t retourner différent
content or a différent code d’état pour it. A robot d’exploration que récupère l’URL une fois obtient
identical HTML regardless of the fragment.
That’s aussi pourquoi Google formally deprecated its 2009 AJAX-crawling scheme back in
October 2015: “En bref: We are ne … plus recommending the AJAX exploration proposal we
made back in 2009.” The old _escaped_fragment_ workaround let servers pre-render
fragment routes on requête — but it patched autour the routing problem plutôt que
fixing it. Google’s propre recommendation replacing it is the History API, covered ci-dessous.
Soft 404s — client-side routers garder a 200 pour everything
Ce un is fermer to inevitable in a bare SPA. Client-side routers, by design, garder the
original page’s 200 status pour every virtual navigation — notamment “not found”
states. Google flags it explicitly: “In a application monopage (SPA), ce peut be
surtout difficult. To prevent error pages from being indexé, vous pouvez utiliser un or les deux
of the suivant strategies.” And the why: “Quand a SPA is en utilisant client-side JavaScript
to handle errors ils souvent report a 200 Code d’état HTTP au lieu de the appropriate
code d’état.”
Le résultat is vide or error views getting indexé as thin 200 pages. Google
documents exactly two strategies ici, precisely: redirection (or faire a complet requête) to
une URL whose server renvoie a réel 404/error status, or ajouter a noindex tag to the
error view with JavaScript. Watch the second un: si que noindex is présent from the
very premier paint plutôt que ajouté après the app decides the route is invalid, it peut
causer Google to skip rendering lune page altogether — so vérifier ce que a direct requête
renvoie avant quelconque JS runs, pas seulement ce que montre up in the rendered DOM afterward.
Shared-shell duplicates — a possible causer, pas an automatic diagnosis
There’s a second, sneakier échec mode: distinct routes obtenir indexé as duplicates of chaque autre parce que ils render bas to the même shared header/nav/footer boilerplate. Gary Illyes décrit un façon ce se produit: “I have a bunch of emails in my inbox où the problème is que the centerpiece took forever to charger, so rendering timed out (my la plupart probable explanation) and we were left with a bunch of pages que seulement had the boilerplate. With seulement the boilerplate, ceux pages are dups.” His fix: “Essayer to restructure the js calls tel que le contenu (notamment marginal boilerplate) loads premier.” (Relayed via a LinkedIn post, qui resists automated verification; treat as high-confidence secondary.)
Notice Illyes frames it as “my most likely explanation,” pas a confirmed diagnosis — that’s worth taking literally. “Render timeout” isn’t something vous pouvez diagnose from symptoms alone; vous besoin request-level evidence (ce que a direct récupérer en réalité renvoie), rendered-output evidence (si the centerpiece content is présent après rendering), and Search Console evidence (duplicate/indexation signals) avant attributing a route’s duplicate-content problem to a timeout specifically, plutôt que to a bug, a blocked resource, or a genuinely identical shell. There’s aussi aucun fixed, publié timeout to design autour — Google’s propre basics doc dit une page “may stay on ce queue pour a few seconds, but it peut prendre plus long que que,” sans committing to a number. Don’t plan autour an assumed render-queue duration; à la place, assurez-vous principal content loads as early as possible regardless of how long rendering ends up taking.
The fix: obtenir réel HTML per route
The complet fix has two independent parts que personnes conflate constantly:
- URL addressability — History API, unique per-route URLs, aucun fragments.
- Content availability — SSR, static prerendering/SSG, or a meta-framework.
Do seulement #1 and vous obtenir clean, shareable URLs que tout encore retourner the même blank shell. Pour a route vous en réalité vouloir indexé, vous besoin les deux.
That’s pas a universal mandate to ajouter SSR everywhere, though. SSR and prerendering reduce how beaucoup vous depend on a robot d’exploration successfully executing votre JavaScript — ils don’t become mandatory the instant a site is technically an SPA. Avant picking an architecture pour a donné route, vérifier three choses: si que route nécessite to rank at tout (an internal admin panel doesn’t), ce que a direct, JS-free requête to it déjà renvoie (some setups déjà send meaningful HTML), and qui robots d’exploration vous en réalité besoin to satisfy (Google renders JS fairly reliably; Bing and la plupart AI robots d’exploration moins so — voir ci-dessous). CSR que déjà passes ceux checks doesn’t besoin to become SSR simplement parce que le site is an SPA.
Rendu côté serveur (SSR)
Le serveur runs votre app pour chaque requête and renvoie fully-formed HTML pour que route, alors the client “hydrates” it into a live SPA. Ce is the la plupart robust option parce que the robot d’exploration obtient complet content on the premier récupérer, aucun JS execution requis.
Static prerendering / SSG
Au lieu de rendering per requête, vous construire every route’s HTML ahead of temps at deploy. Perfect pour content que doesn’t modifier per utilisateur. From my propre JavaScript SEO writing: quelconque kind of SSR, static rendering, and prerendering setup is going to be fine pour search engines — the chose to éviter is leaving content locked behind client-only rendering.
Or: don’t hand-roll it — utiliser a meta-framework
Pour a nouveau construire, the honest recommendation is to pas hand-roll React-Router-only or Vue-Router-only client-side routing at tout. A meta-framework — Suivant.js, Nuxt, Angular with its SSR package, SvelteKit, Remix — donne vous SSR/SSG and route-based metadata out of the box, qui sidesteps ce entier category of problem. (Chaque of ceux has its propre deep dive on ce site.) Be honest à propos de the flip side: retrofitting SSR onto an existing hand-rolled SPA is réel engineering fonctionner — a migration project, pas a config toggle.
Dynamic rendering as a stopgap, pas a destination
Vous pouvez serve robots d’exploration a separately-rendered HTML snapshot (dynamic rendering). Les deux Google and Bing accept it — Bing “recommend[s] dynamic rendering as a great alternative pour websites relying heavily on JavaScript” (relayed from Bing’s 2018 blog; the two short bingbot-capability quotes ci-dessous are directement verified, ce plus long un n’est pas independently re-checked) — but Google is clair it’s a workaround: “Dynamic rendering was a workaround and pas a long-term solution… À la place, we recommend que vous utiliser rendu côté serveur, static rendering, or hydration as a solution.” (Relayed from Google’s dynamic-rendering doc; wording matches what’s déjà cited in ce site’s broader JavaScript SEO guide.) It’s a bridge, pas an architecture.
History API vs. hash routing (#!)
Five states, pas two
Testing an SPA route obtient confusing parce que “does it work” en réalité spans five différent, separately-verifiable states:
| State | Ce que c’est |
|---|---|
| Server réponse | The bytes a fresh, JS-free HTTP requête to une URL en réalité obtient back. |
| Rendered DOM | Ce que a navigateur (or Googlebot’s renderer) builds après executing JavaScript contre que server réponse. |
| Search processing | How Google separately crawls le serveur réponse, plus tard renders lune page, and indexes fondé on les deux. |
| Navigateur full-document navigation | An réel nouveau HTTP requête to une URL — the seulement state que peut modifier le serveur réponse or code d’état. |
| Navigateur soft navigation | A History API transition (pushState/replaceState) que changements the visible URL, navigateur history, and on-screen UI. |
The un everyone conflates: a soft navigation changements l’URL and the UI, but it fait
pas by itself créer a nouveau HTTP réponse or status — que seulement se produit on a complet
navigation (or an equivalent direct requête, comme curl). Testing a route by clicking
via the app from the homepage exercises soft navigation; testing it by requesting
l’URL directement exercises le serveur réponse. Les deux matter, and ils peut disagree.
Ce que the History API donne vous
Google’s recommendation is unambiguous: “We recommend en utilisant the History API to charger
différent content fondé on l’URL in a SPA.” (In the live doc “History API” is
a lien, so ce deep lien targets the lead-in clause.) The History API
(pushState/replaceState) lets votre router modifier the visible URL to a réel,
bookmarkable chemin — /products, pas /#/products — sans a complet reload. Que fixes
the addressability half: every view now has une URL a server pourrait respond to
differently.
Pourquoi fragment/hash URLs are invisible to le serveur — and to Google
Parce que the fragment jamais reaches le serveur (voir ci-dessus), the History API is the seulement
façon to give chaque route une URL le serveur peut en réalité serve. Google puts a floor sous
it in its basics doc: “don’t utiliser fragments to charger différent page content. The
suivant exemple is a bad pratique, parce que Googlebot can’t reliably resolve the
URLs.” Ce is the même guidance I’ve written elsewhere — utiliser normal-looking URLs comme
/products, pas hash URLs comme /#/products, parce que Google can’t reliably index the
hash ones.
The 2015 deprecation, briefly
The hash-bang (#!) era ended with Google’s 2015 post: “Times have modifié. Today, as
long as you’re pas blocking Googlebot from exploration votre JavaScript or CSS fichiers, we are
généralement able to render and comprendre votre web pages comme modern navigateurs,” and
“vous pouvez utiliser the History API pushState() to garantir accessibility pour a wider range of
navigateurs (and our systems).” Un 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” n’est pas “you should still do this.”
Making chaque route independently indexable
Clean URLs and réel HTML obtenir vous crawled. To obtenir indexé correctement, chaque route nécessite its propre signals.
Per-route balise canonicals — and the “most restrictive directive” trap
Every route nécessite its propre rel=canonical in the rendered DOM. The trap: si a
placeholder canonical (or a noindex) ships in the raw HTML shell and JavaScript is
supposed to overwrite it plus tard, vous pouvez obtenir a conflict. Google resolves conflicts
entre the raw and rendered versions by taking the plus restrictive signal — as I’ve
put it avant, Google va choisir the la plupart restrictive statements entre the HTML and
the rendered version of une page. A stray noindex or incorrect canonical baked into the
shell peut silently suppress the whole route même après votre JS “fixes” it.
Per-route titles and meta descriptions via JavaScript
Setting ces in JS is fine — Google dit so directement: “Vous pouvez utiliser JavaScript to définir
or modifier the meta description as bien as the <title> element.” The requirement is
que ils land in the rendered DOM Google evaluates, pas simplement flash briefly. Give chaque
page its propre title and description que mettre à jour quand the route changements.
Réel <a href> liens entre routes
Faire votre inter-route liens réel anchors with href attributes, pas click handlers on
<div>s. Google discovers URLs by extracting hrefs; a <div onClick> que navigates
via the router is invisible to the crawler’s lien extraction. And don’t lean on
client-side state to carry content à travers ceux navigations — Google’s renderer “fait
pas retain state à travers page loads: Local Storage and Session Storage données are cleared
à travers page loads. HTTP Cookies are cleared à travers page loads.”
Sitemap generation pour SPA routes
There’s aucun special “SPA sitemap” format — it’s an accounting problem
Le sitemap protocol is unchanged pour SPAs. The réel fonctionner is making certain every route vous liste independently resolves to réel, unique, rendered HTML. A sitemap of 500 client-side routes is worthless si ceux routes tout retourner the même shell. So the sitemap is downstream of votre rendering strategy, pas a substitute pour it.
Handling dynamic/parameterized routes (/product/:id)
Pour apps with parameterized routes, vous pouvez’t hand-maintain the liste. Le sitemap
generator has to run contre the même données source the app uses — a construire script or a
server endpoint que enumerates every id — so le sitemap and the app jamais drift
apart. Ceux enumerated URLs are seulement worth listing une fois they’re SSR’d or prerendered.
Keeping le sitemap in sync with what’s server-resolvable
Regenerate le sitemap as partie of votre construire or on a schedule tied to votre content
source. A route que 404s (or worse, soft-404s at 200) but sits in votre sitemap is a
crawl-budget and quality signal vous don’t vouloir.
Testing ce que Google en réalité sees
Don’t spot-check un URL. The whole point of the SPA problem is que routes peut differ in le navigateur but pas on the wire, so tester multiple routes at the raw-HTML level:
- Récupérer raw HTML per route with
curl(a Googlebot user-agent où relevant) and confirmer le contenu is unique per URL, pas the shared shell. - Inspection d’URL in Search Console — comparer the crawled/rendered HTML pour several routes, and confirmer the per-route title, description, and canonical are présent.
- Confirmer error routes retourner the correct signal — a “not found” view devrait soit
redirection to a réel error status or carry
noindexin the rendered DOM.
Courant myths à propos de SPA SEO
- “Google can’t index SPAs at all.” Outdated. Google runs evergreen Chromium and généralement renders SPA content. The réel risks are spécifique: soft 404s, hash routing, render timeouts, and non-Google robots d’exploration (Bing moins reliably, la plupart AI robots d’exploration pas at tout).
- “Adding the History API fixes SPA SEO.” It fixes addressability seulement. Si le serveur encore renvoie the même shell pour every route, Google encore has to execute JS to voir anything.
- “Hash routing still works as a fallback.” Deprecated since 2015 and recommended contre ever since.
- “Client-side rendering is a ranking penalty.” Aucun direct CSR penalty exists. The damage is indirect — failed/delayed rendering, soft 404s, and duplicate clustering from render timeouts reduce ce que obtient indexé.
- “Pre-rendering for bots is cloaking.” Pas quand le contenu matches ce que utilisateurs eventually voir — seulement the quand/où of rendering differs, pas le contenu.
- “You need a special SPA sitemap generator.” Aucun special format exists; the fonctionner is making every listed route resolve to réel HTML.
FAQs
Peut Google index a application monopage? Yes, si chaque route resolves to réel, unique HTML (via SSR/prerendering) at a réel URL. Bare client-only SPAs souvent don’t.
Do I besoin SSR, or is rendu côté client ever okay? CSR peut fonctionner pour content que doesn’t besoin to rank, but pour anything vous vouloir indexé reliably, prerender or SSR it — don’t bet on the renderer executing votre JS in temps.
Is hash (#!) routing bad pour le SEO? Yes — the fragment jamais reaches le serveur, so
Google can’t reliably resolve ceux URLs. Utiliser the History API.
Fait every route besoin its propre balise canonical? Yes, in the rendered DOM — and faire
certain nothing plus restrictive (a stray noindex or incorrect canonical) ships in the raw
shell.
Is a prerendering service cloaking? Aucun, provided le contenu served to bots matches ce que utilisateurs voir.
Devrait I utiliser a meta-framework au lieu de building routing myself? Pour a nouveau construire, usually yes — Suivant.js/Nuxt/Angular-SSR/SvelteKit give vous SSR/SSG and per-route metadata pour free.
Pourquoi fait my SPA retourner 200 pour pages que don’t exist? Parce que the client-side
router garde the original 200 status pour virtual navigations. Fix it with a JS
redirection to a réel error status or a rendered noindex.
AI summary
A condensed prendre on the Avancé version:
- SPA SEO = client-side routing specifically (React Router, Vue Router, Angular Router). An SPA is défini by chargement un document and swapping views with JavaScript — an “app shell” que sends near-empty HTML pour every URL is un courant implementation, pas a rule every SPA follows; vérifier ce que a direct requête en réalité renvoie plutôt que assuming.
- The core risk: client-side routing peut modifier ce que the utilisateur sees sans
modification ce que the server voudrait retourner. On a bare app-shell SPA, fetching
/productsand/aboutdirectement peut obtenir byte-identical raw HTML. - Five states obtenir conflated: server réponse, rendered DOM, Search processing, navigateur full-document navigation, and navigateur soft navigation. A History API transition changements l’URL and UI but doesn’t itself créer a nouveau server réponse or status.
- Three symptoms of a bare shell: the app-shell problem, soft 404s (routers garder a
200pour “not found” — Google’s two documented fixes are redirecting to une URL que itself 404s, or a JS-addednoindex; an initialnoindexpeut causer rendering to be skipped), and shared-shell duplicates — qui “render timeout” (Illyes) is un possible causer of, pas an automatic diagnosis sans requête/render/Search evidence. - Hash routing fails mechanically: the fragment après
#jamais reaches le serveur, so le serveur can’t differ by route. Deprecated by Google in 2015; History API donne addressability, pas a complet indexability fix. - The fix has two independent halves où 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 appel on si the route nécessite to rank, ce que it déjà renvoie directement, and qui robots d’exploration besoin to render it.
- Per route: propre canonical/title/description in the rendered DOM; watch the
“most restrictive directive” trap où a raw-HTML
noindex/canonical overrides votre JS. Réel<a href>liens; don’t rely on client state (WRS clears storage/cookies à travers loads). - Sitemaps: listing a route doesn’t prove it resolves — aucun special format exists; every listed route doit independently resolve to réel HTML, and dynamic routes besoin a generator tied to the app’s données source.
- Dynamic rendering is a Google/Bing-accepted stopgap, pas a long-term architecture.
- Tester multiple routes at the raw-HTML level (direct requêtes), pas simplement by navigating dans the app.
Documentation officielle
Primary-source documentation from the moteur de recherches.
- Comprendre 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 — pourquoi dynamic rendering is a stopgap, pas a long-term solution.
- Deprecating our AJAX exploration scheme (2015) — the formal fin of hash-bang exploration and the pushState recommendation.
- Construire and submit a sitemap — general sitemap protocol (there’s aucun 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. Chaque lien is a deep lien que 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 lien, so ce deep lien targets the lead-in clause; the complet sentence is présent 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 exploration (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 déjà cited in ce site’s broader JavaScript SEO guide, pas independently re-verified ce réussir)
- “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. Lire the post
- “bingbot does not necessarily support all the same JavaScript frameworks that are supported in the latest version of your favorite modern browser.” — même post. Lire the post
Gary Illyes, Google — render timeouts créer duplicates (relayed via a LinkedIn post, qui 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.” Lire the post
Qui rendering chemin devrait I prendre?
Fonctionner top-down. The question is toujours “fait the robot d’exploration obtenir réel HTML pour ce route sans executing my JS?” — and commencer by confirming the route en réalité nécessite to be indexé and checking ce que a direct, JS-free requête déjà renvoie; pas every route nécessite SSR, and some déjà retourner usable HTML.
1. Are vous starting a nouveau construire?
- Yes → Utiliser a meta-framework (Suivant.js, Nuxt, Angular with SSR, SvelteKit, Remix). SSR/SSG and per-route metadata come construit in. Arrêter ici.
- Aucun → Continuer.
2. Fait votre content modifier per utilisateur / per requête?
- Aucun (mostly static content) → Prerender / SSG at construire temps. Simplest robust fix — every route is réel HTML on disk.
- Yes → Rendu côté serveur (SSR) so chaque requête renvoie route-specific HTML.
3. Can’t do SSR or prerendering correct now (legacy hand-rolled SPA)?
- Utiliser dynamic rendering as a temporary bridge — serve robots d’exploration a rendered snapshot. Plan the migration to SSR/SSG; don’t treat ce as the destination.
4. Whatever chemin vous chose, confirmer tout of ces per route:
- Réel URL via the History API (aucun
#!fragments). - Unique title + meta description + canonical in the rendered DOM.
- Nothing plus restrictive (
noindex, incorrect canonical) baked into the raw shell. - Réel
<a href>liens entre routes. - Error routes retourner a réel error status or a rendered
noindex(aucun soft 404s). - Every route in le sitemap independently resolves to réel HTML.
SPA SEO checklist
Addressability
- Chaque view has a réel URL via the History API (
/products), pas a fragment (/#/products). - Inter-route liens are réel
<a href>anchors, pas<div onClick>handlers.
Content availability
- Every route renvoie réel, unique HTML sans the robot d’exploration executing votre JS (SSR, prerendering/SSG, or a meta-framework).
- Route content loads avant the render window closes (éviter boilerplate-only duplicates).
Per-route indexability
- Unique
<title>and meta description per route, présent in the rendered DOM. - Unique
rel=canonicalper route in the rendered DOM. - Aucun stray
noindex/placeholder canonical in the raw shell que a plus restrictive signal pourrait lock in.
Errors
- “Not found” routes redirection to une URL whose server renvoie a réel error status or
carry
noindexajouté by JavaScript (aucun soft 404s at200). - Si en utilisant the
noindexroute, confirmer it’s ajouté après the app decides the route is invalid, pas présent from the very premier paint — an initialnoindexpeut causer Google to skip rendering lune page.
Sitemap
- Every listed route independently resolves to réel HTML.
- Dynamic routes (
/product/:id) enumerated from the app’s données source, pas hand-maintained.
Verification
- Raw HTML vérifié à travers multiple routes (curl), confirming unique content per URL.
- Inspection d’URL confirms rendered content, title, description, and canonical per route.
The mental models
1. The two halves vous doit pas conflate. Addressability (History API, unique URLs, aucun fragments) and content availability (SSR, prerendering, or a meta-framework) are independent. Clean URLs with aucun per-route HTML = a tidy sitemap of blank shells. Vous besoin les deux.
2. “What would the server return, fresh?”
The whole SPA problem is que le navigateur view diverges from le serveur réponse. Pour
quelconque route, demander ce que a curl with aucun JS execution obtient back. Si it’s the shell, the
robot d’exploration may voir the shell aussi.
3. The status-code par défaut is 200 — notamment pour errors.
Client-side routers garder the original 200. Assume every “error” view is a soft 404
jusqu’à you’ve deliberately made it retourner a réel error status or a rendered noindex.
4. The most-restrictive-directive trap.
Google reconciles raw vs. rendered by taking the plus restrictive signal. A noindex
or incorrect canonical in the shell peut override votre JS “fix.” Audit the raw HTML, pas simplement
the rendered DOM.
5. Meta-framework premier pour nouveau builds. Hand-rolling client-side routing signifie owning every un of ces problems. Picking a framework que fait SSR/SSG and per-route metadata is choosing pas to have les.
SPA SEO cheat sheet
Routing
| Approach | URL exemple | Reaches le serveur? | Google-safe? |
|---|---|---|---|
| History API | /products | Yes | Yes (recommended) |
| Hash routing | /#/products | Aucun (fragment jamais sent) | Aucun — deprecated 2015 |
Hash-bang (#!) | /#!/products | Aucun | Aucun — legacy AJAX scheme, deprecated |
Rendering strategies
| Strategy | Robot d’exploration obtient réel HTML sans running JS? | Meilleur pour |
|---|---|---|
| SSR | Yes | Per-user / per-request content |
| Prerendering / SSG | Yes | Mostly-static content |
| Meta-framework (Suivant/Nuxt/etc.) | Yes (construit in) | Nouveau builds |
| Dynamic rendering | Yes, pour bots seulement | Temporary bridge on legacy SPAs |
| Bare CSR (client-only) | Aucun | Nothing vous vouloir indexé reliably |
Per-route must-haves (tout in the rendered DOM)
- Unique URL (History API) · unique
<title>· unique meta description · uniquerel=canonical· réel<a href>liens · error routes with a réel status ornoindex.
Fast facts
- Fragment après
#is jamais sent to le serveur — that’s pourquoi hash routing fails. - Client-side routers garder
200pour everything, notamment “not found” → soft 404s. - Google reconciles raw vs. rendered by taking the plus restrictive signal.
- Aucun special SPA sitemap format exists — every listed route doit resolve to réel HTML.
SPA SEO anti-patterns
Shipping a bare CSR SPA and submitting a complet sitemap. Le sitemap listes 500 routes; tout 500 retourner the même shell to a fresh récupérer. The sitemap doesn’t faire content exist — rendering fait.
Ajout the History API and appel it fait. Clean URLs fix addressability, pas content. Sans SSR/prerendering le serveur encore renvoie the shell pour every route.
Hash / hash-bang routing “as a fallback.” The fragment jamais reaches le serveur, so le serveur can’t differ by route. Deprecated since 2015; pas a fallback, a dead fin.
Navigating with <div onClick> au lieu de <a href>.
Google extracts hrefs to découvrir URLs. Click-handler navigation is invisible to lien
extraction, so ceux routes may jamais be trouvé.
A placeholder noindex or canonical in the raw shell “that JS will overwrite.”
Google takes the plus restrictive of raw vs. rendered. The shell’s directive peut win and
silently suppress the route.
Returning 200 pour “not found” views.
Soft 404s obtenir thin/vide pages indexé. Redirection to a réel error status or ajouter a
rendered noindex.
Chargement route content slowly, après the boilerplate. Render timeouts leave seulement the shared shell, and every route collapses into a duplicate of every autre (Illyes). Charger the centerpiece content premier.
Relying on client state to carry content à travers navigations. The renderer clears Local/Session Storage and cookies à travers page loads — content que seulement exists in client state won’t be là quand Google renders the suivant route.
Courant SPA indexation problèmes
Every route renvoie the même HTML
Symptom: /products and /about have différent navigateur views but identical raw
réponses. Probable causer: History API routing provides addressability sans SSR
or prerendering. Fix: Produce route-specific HTML and confirmer a direct requête to
chaque chemin contient its propre heading, corps copy, and metadata.
Manquant routes apparaître as successful pages
Symptom: A nonexistent route montre a not-found view pendant que returning 200.
Probable causer: The client router owns the error après le serveur has déjà sent a
successful shell. Fix: Retourner the correct server status; si que ne peut pas ship yet,
redirection to une URL with a réel error status or render noindex. Confirmer with a fresh
direct requête, pas an in-app navigation.
Routes collapse into duplicates après rendering
Symptom: Moteur de recherches cluster distinct URLs or retain seulement shared navigation. Probable causer: Route content loads aussi late and rendering captures boilerplate. Fix: Prioritize principal content in le serveur réponse or earliest render chemin. Confirmer several routes expose unique content avant optional scripts finish.
Testing ce que le serveur en réalité renvoie per route
The SPA trap is que routes differ in le navigateur but pas on the wire. Vérifier multiple routes at the raw-HTML level, pas un.
Récupérer 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 ils the même 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)"Vérifier the per-route title and canonical in the raw réponse (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["'"'"'][^>]*>'Vérifier 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 le navigateur 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” que aren’t réel 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 — trouver fragment/hash liens a robot d’exploration 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. Outils pour auditing an SPA
- Inspection d’URL (Recherche Google Console) — voir the crawled vs. rendered HTML pour a route and confirmer the per-route title, description, and canonical en réalité land.
- Résultats enrichis Tester / Inspection d’URL “View crawled page” — Google’s propre render of a unique URL, utile pour confirming content survives rendering.
curlwith a Googlebot UA — the fastest façon to comparer the raw HTML of several routes and catch a shared shell.- Screaming Frog SEO Spider (JavaScript rendering mode) — explorer le site with and sans rendering to comparer raw vs. rendered content and titles à travers every route.
- Ahrefs Site Audit — surfaces indexability problèmes, manquant/duplicate titles and canonicals, and soft-404-comme patterns à travers routes.
- DebugBear — SPA-oriented performances monitoring; render timing matters parce que slow routes peut temps out into boilerplate duplicates.
- Bing Webmaster Outils — Inspection d’URL — bingbot renders JS moins consistently que Googlebot, so confirmer votre routes on Bing’s side aussi.
Prove an SPA route is independently indexable
Tester direct-entry HTML parity
Tester to run: Ouvrir representative routes in a nouveau session and récupérer the même URLs
with curl. Attendu result: Chaque URL renvoie its propre principal content and head
signals sans prior app state. Échec interpretation: The route dépend on
client navigation or stored state. Monitoring window: Immediate. Rollback
trigger: A route fonctionne seulement après entering via the homepage.
Tester error status handling
Tester to run: Requête a known-invalid route directement and inspect les deux status and
rendered directives. Attendu result: A genuine error status, or the documented
fallback of a rendered noindex/redirection to an error réponse. Échec
interpretation: The SPA is generating soft 404s. Monitoring window: Immediate.
Rollback trigger: Invalid paths ship as indexable 200 pages.
Tester metadata isolation
Tester to run: Comparer the raw and rendered title, robots tag, and canonical à travers au moins three routes. Attendu result: Chaque route has un intended, internally consistent définir. Échec interpretation: The shared shell leaks metadata entre routes or JS overwrites it aussi late. Monitoring window: Immediate locally and après recrawl in Inspection d’URL. Rollback trigger: Quelconque route inherits un autre route’s canonical or a restrictive shell directive.
Testez vos connaissances: SPA SEO
Five rapide questions on making application monopages crawlable and indexable. Pick an réponse pour chaque, alors vérifier.
Ressources utiles
My connexe writing
- JavaScript SEO Problèmes & Meilleur Practices — my complet JS-SEO guide, notamment the “don’t use fragments in URLs” and app-shell/duplicate-content sections que ce article builds on.
- The Beginner’s Guide to SEO technique — où rendering and exploration fit in the bigger picture.
My speaking
- How Search Fonctionne (SlideShare) — my walkthrough of exploration, rendering, indexation, and ranking, qui is the pipeline an SPA has to survive. (My standing disclaimer s’applique: “This is my understanding of systems… not going to be 100% complete or accurate.”)
From autour the industry
- Google — Comprendre 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 exploration scheme (2015) — the formal fin of hash-bang exploration.
- Bing — bingbot Series: JavaScript, Dynamic Rendering, and Cloaking. Oh My! — bingbot’s JS-rendering stance.
- It’s Pas Cloaking To Utiliser A Pre-Render Service Pour Blank HTML Pages Pour SPA Development (Moteur de recherche Roundtable, 2015) — coverage of Gary Illyes on pre-rendering SPAs pas being cloaking. (SER’s paraphrase of Illyes, dated 2015 — secondary.)
- SEO pour Unique Page Applications (Nuxt SEO) — a framework-side walkthrough of the même problems.
- How To Optimize Unique Page Applications Pour le SEO (DebugBear) — the rendering/performances angle on SPA indexability.
- SPA (glossary) (MDN) — a neutral definition of the single-page-application pattern.
Videos
- Recherche Google Central (YouTube) — Martin Splitt’s JavaScript SEO series covers rendering, the History API, and the SPA échec modes discussed ici. Channel
Journal des modifications
Mis à jour le 18 juil. 2026.
Résumé éditorial et détails enregistrés des changements.Détails des changements
-
Les notes détaillées des changements sont actuellement disponibles en anglais.
-
Les notes détaillées des changements sont actuellement disponibles en anglais.
-
Les notes détaillées des changements sont actuellement disponibles en anglais.
-
Les notes détaillées des changements sont actuellement disponibles en anglais.
-
Les notes détaillées des changements sont actuellement disponibles en anglais.
Comparaison complète indisponible — aucun instantané antérieur n’a été archivé pour cette révision.