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.

Première publication : 3 juil. 2026 · Dernière mise à jour : 3 août 2026 · Advanced
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 — 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 a 200, 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.

Evidence for this claim For a client-rendered not-found view, Google documents two approaches: redirect to a URL whose server returns a 404 response, or add noindex with JavaScript; an initial noindex can cause rendering to be skipped, so raw and rendered directives require separate testing. Scope: SPAs Confidence: high · Verified: Fix Search-related JavaScript problems

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:

  1. URL addressability — History API, unique per-route URLs, aucun fragments.
  2. 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:

StateCe que c’est
Server réponseThe bytes a fresh, JS-free HTTP requête to une URL en réalité obtient back.
Rendered DOMCe que a navigateur (or Googlebot’s renderer) builds après executing JavaScript contre que server réponse.
Search processingHow Google separately crawls le serveur réponse, plus tard renders lune page, and indexes fondé on les deux.
Navigateur full-document navigationAn 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 navigationA 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 noindex in 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.

Add an expert note

Pin an expert quote

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