Guide SPA SEO

Comment faire application monopages (React Router, Vue Router, Angular Router) crawlable et indexable — le app-shell et soft-404 problèmes, History API vs hash routing, per-route HTML via SSR/prerendering, per-route canonicals et titles, et sitemap generation pour client-side routes.

Première publication : 3 juil. 2026 · Dernière mise à jour : 3 août 2026 · Avancé

A application monopage charge un document et swaps views avec JavaScript au lieu de requesting a nouveau page depuis le serveur. Nombreux SPAs implement que avec an 'app shell' — le serveur renvoie un URL's worth de réel HTML, a near-empty shell, jusqu’à JS renders chaque route — mais c’est a courant implementation choice, pas a règle chaque SPA suit; vérifier ce que a direct requête à chaque route en réalité renvoie avant assuming il. Le corriger, où a bare app-shell setup est le problème, a deux independent halves: addressability (History API, pas hash/#! fragments, donc chaque view a a réel URL — a navigateur soft navigation via le History API changements l’URL et UI mais ne fait pas itself créer a nouveau serveur réponse) et contenu availability (SSR, prerendering, or a meta-framework donc chaque de ceux URLs peut retourner unique HTML on requête). On top de que, vérifier chaque route's titre, canonical, et robots state les deux on direct entry et après rendu, handle soft 404s (routers tend à garder a 200 status pour 'introuvable' views — redirection à une URL que itself 404s, or ajouter a rendu noindex, though an initial noindex peut causer rendu à être skipped), et assurez-vous chaque route dans votre sitemap en réalité resolves à réel, indexable HTML — il y a aucun special SPA sitemap format.

TL;DR — An SPA’s core SEO risque n’est pas “JavaScript” dans le abstract — c’est que client-side routing changements ce que le utilisateur voit sans modification ce que le serveur voudrait retourner. Le corriger a deux independent halves personnes constantly conflate: addressability (History API, unique per-route URLs, aucun #! fragments) et contenu availability (SSR, prerendering/SSG, or a meta-framework). Faire seulement le premier et vous obtenir a tidy sitemap de URLs que tout rendre le même shell. On top de les deux, chaque route nécessite son propre canonical/titre/description dans le rendu DOM, vous ont à handle soft 404s que garder a 200, et chaque route dans le sitemap a à independently resolve à réel HTML.

Ce que SPA SEO en réalité is

SPA is an application architecture, pas a guaranteed SEO échec state. Preuve à l’appui de cette affirmation Primary standard or official documentation supporting the adjacent article claim. Portée : Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Niveau de confiance : élevé · Vérifié : MDN: Single-page application Server rendering, prerendering, or carefully implemented client rendering peut expose content, but none guarantees indexation. Preuve à l’appui de cette affirmation Primary standard or official documentation supporting the adjacent article claim. Portée : Protocol semantics and Search behavior are kept separate; no indexing, ranking, or migration-timing guarantee is inferred. Niveau de confiance : élevé · Vérifié : Google: JavaScript SEO basics

Ce est le deep dive on application monopages précisément — client-side routing avec React Router, Vue Router, or Angular Router, où après le premier charger le serveur jamais sends un autre complet page. Il sits alongside le broader JavaScript SEO guide (qui covers parity, lazy-loading, infinite scroll, et JS rendu généralement) et le per-framework écrire-ups pour React, Suivant.js, Nuxt, Angular, Vue, Svelte, et Astro. Ici Je’m seulement interested dans le routing couche et ce que il fait à exploration et indexation.

Pourquoi bare client-side-routed SPAs échouer at SEO

Un URL, un HTML réponse — 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. » (Traduction) (Résumé en français de la section vingt-deux, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

c’est le entier problème dans un sentence: client-side routing changements ce que le utilisateur voit sans modification ce que le serveur voudrait retourner. Tout downstream — soft 404s, contenu dupliqué, manquant titles — est a symptom de que.

Le serveur jamais sees qui “page” was requested (pourquoi hash routing fails)

«Older SPAs load views from URL fragments — example.com/#/products. Google’s exact words: “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” This fails for a specific, mechanical reason: browsers never send the fragment (anything after #) to the server in the HTTP request. The server literally cannot know which “page” was requested, so it can’t return different content or a different status code for it. A crawler that fetches the URL once gets identical HTML regardless of the fragment. » (Traduction) (Résumé en français de la section vingt-cinq, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

c’est aussi pourquoi Google formally deprecated son 2009 AJAX-exploration scheme back dans October 2015: “En bref: Nous sont ne … plus recommending le AJAX exploration proposal nous fait back dans 2009.” Le old _escaped_fragment_ workaround let serveurs pre-rendre fragment routes on requête — mais il patched autour le routing problème plutôt que fixing il. Google’s propre recommendation replacing il est le History API, covered ci-dessous.

Soft 404s — client-side routers garder a 200 pour everything

«This one is close to inevitable in a bare SPA. Client-side routers, by design, keep the original page’s 200 status for every virtual navigation — including “not found” states. Google flags it explicitly: “In a single-page application (SPA), this can be especially difficult. To prevent error pages from being indexed, you can use one or both of the following strategies.” And the why: “When a SPA is using client-side JavaScript to handle errors they often report a 200 HTTP status code instead of the appropriate status code.” » (Traduction) (Résumé en français de la section vingt-huit, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

Le résultat est vide or erreur views getting indexé as thin 200 pages. Google documents exactly deux strategies ici, precisely: redirection (or faire a complet requête) à une URL whose serveur renvoie a réel 404/erreur status, or ajouter a noindex balise à le erreur view avec JavaScript. Watch le deuxième un: si que noindex est présent depuis le very premier paint plutôt que ajouté après le app decides le route est invalide, il peut causer Google à skip rendu lune page altogether — donc vérifier ce que a direct requête renvoie avant quelconque JS exécute, pas seulement ce que montre up dans le rendu DOM afterward.

Preuve à l’appui de cette affirmation 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. Portée : SPAs Niveau de confiance : élevé · Vérifié : Fix Search-related JavaScript problems

Shared-shell duplicates — a possible causer, pas an automatic diagnosis

il y a a deuxième, sneakier échec mode: distinct routes obtenir indexé as duplicates de chaque autre parce que ils rendre bas à le même shared header/nav/footer boilerplate. Gary Illyes décrit un façon ce se produit: “Je ont a bunch de emails dans mon inbox où le problème est que le centerpiece took forever à charger, donc rendu timed out (mon la plupart probable explanation) et nous étaient left avec a bunch de pages que seulement avait le boilerplate. Avec seulement le boilerplate, ceux pages sont dups.” His corriger: “Essayer à restructure le js calls tel que le contenu (notamment marginal boilerplate) charge premier.” (Relayed via a LinkedIn post, qui resists automated verification; treat as élevé-confidence secondary.)

«Notice Illyes frames it as “my most likely explanation,” not a confirmed diagnosis — that’s worth taking literally. “Render timeout” isn’t something you can diagnose from symptoms alone; you need request-level evidence (what a direct fetch actually returns), rendered-output evidence (whether the centerpiece content is present after rendering), and Search Console evidence (duplicate/indexing signals) before attributing a route’s duplicate-content problem to a timeout specifically, rather than to a bug, a blocked resource, or a genuinely identical shell. There’s also no fixed, published timeout to design around — Google’s own basics doc says a page “may stay on this queue for a few seconds, but it can take longer than that,” without committing to a number. Don’t plan around an assumed render-queue duration; instead, make sure primary content loads as early as possible regardless of how long rendering ends up taking. » (Traduction) (Résumé en français de la section trente-deux, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

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.

Faire seulement #1 et vous obtenir clean, shareable URLs que tout encore retourner le même blank shell. Pour a route vous en réalité vouloir indexé, vous besoin les deux.

c’est pas a universal mandate à ajouter SSR everywhere, though. SSR et prerendering reduce comment beaucoup vous depend on a robot d’exploration successfully executing votre JavaScript — ils ne pas become mandatory le instant a site est technically an SPA. Avant picking an architecture pour a donné route, vérifier three choses: si que route nécessite à classer à tout (an internal admin panel ne fait pas), ce que a direct, JS-gratuit requête à il déjà renvoie (some setups déjà send meaningful HTML), et qui robots d’exploration vous en réalité besoin à satisfy (Google renders JS fairly reliably; Bing et la plupart AI robots d’exploration moins donc — voir ci-dessous). CSR que déjà passes ceux vérifications ne fait pas besoin à become SSR simplement parce que le site est an SPA.

Rendu côté serveur (SSR)

Le serveur exécute votre app pour chaque requête et renvoie fully-formed HTML pour que route, alors le client “hydrates” il dans a actif SPA. Ce est le la plupart robust option parce que le robot d’exploration obtient complet contenu on le premier récupérer, aucun JS execution requis.

Static prerendering / SSG

Au lieu de rendu per requête, vous construire chaque route’s HTML ahead de temps à deploy. Perfect pour contenu que ne fait pas modifier per utilisateur. Depuis mon propre JavaScript SEO writing: quelconque kind de SSR, static rendu, et prerendering setup est going à être fine pour recherche moteurs — le chose à éviter est leaving contenu locked behind client-seulement rendu.

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

Pour a nouveau construire, le honest recommendation est à pas hand-roll React-Router-seulement or Vue-Router-seulement client-side routing à tout. A meta-framework — Suivant.js, Nuxt, Angular avec son SSR package, SvelteKit, Remix — donne vous SSR/SSG et route-based metadata out de le box, qui sidesteps ce entier category de problème. (Chaque de ceux a son propre deep dive on ce site.) Être honest à propos de le flip side: retrofitting SSR sur an existing hand-rolled SPA est réel engineering fonctionner — a migration project, pas a config toggle.

Dynamic rendering as a stopgap, pas a destination

«You can serve crawlers a separately-rendered HTML snapshot (dynamic rendering). Both Google and Bing accept it — Bing “recommend[s] dynamic rendering as a great alternative for websites relying heavily on JavaScript” (relayed from Bing’s 2018 blog; the two short bingbot-capability quotes below are directly verified, this longer one is not independently re-checked) — but Google is clear it’s a workaround: “Dynamic rendering was a workaround and not a long-term solution… Instead, we recommend that you use server-side rendering, static rendering, or hydration as a solution.” (Relayed from Google’s dynamic-rendering doc; wording matches what’s already cited in this site’s broader JavaScript SEO guide.) It’s a bridge, not an architecture. » (Traduction) (Résumé en français de la section quarante-cinq, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

«## History API vs. hash routing (#!) » (Traduction) (Résumé en français de la section quarante-six, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

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
Serveur réponseLe bytes a fresh, JS-gratuit HTTP requête à une URL en réalité obtient back.
Rendu DOMCe que a navigateur (or Googlebot’s renderer) construit après executing JavaScript contre que serveur réponse.
Recherche processingComment Google séparément explore le serveur réponse, plus tard renders lune page, et indexes fondé on les deux.
Navigateur complet-document navigationAn réel nouveau HTTP requête à une URL — le seulement state que peut modifier le serveur réponse or code d’état.
Navigateur soft navigationA History API transition (pushState/replaceState) que changements le visible URL, navigateur history, et on-screen UI.

Le un everyone conflates: a soft navigation changements l’URL et le UI, mais il fait pas par 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 par clicking via le app depuis le homepage exercises soft navigation; testing il par requesting l’URL directement exercises le serveur réponse. Les deux matter, et ils peut disagree.

Ce que the History API donne vous

«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. » (Traduction) (Résumé en français de la section cinquante-deux, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

Pourquoi fragment/hash URLs sont invisible à le serveur — et à 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. » (Traduction) (Résumé en français de la section cinquante-quatre, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

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.” » (Traduction) (Résumé en français de la section cinquante-six, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

«## Making each route independently indexable » (Traduction) (Résumé en français de la section cinquante-sept, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

«Clean URLs and real HTML get you crawled. To get indexed correctly, each route needs its own signals. » (Traduction) (Résumé en français de la section cinquante-huit, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

«### Per-route canonical tags — and the “most restrictive directive” trap » (Traduction) (Résumé en français de la section cinquante-neuf, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

Chaque route nécessite son propre rel=canonical dans le rendu DOM. Le trap: si a placeholder canonical (or a noindex) ships dans le raw HTML shell et JavaScript est supposed à overwrite il plus tard, vous pouvez obtenir a conflict. Google resolves conflicts entre le raw et rendu versions par taking le plus restrictive signal — as j’ai put il avant, Google va choisir le la plupart restrictive statements entre le HTML et le rendu version de une page. A stray noindex or incorrect canonical baked dans le shell peut silently suppress le entier route même après votre JS “corrections” il.

Per-route titles et meta descriptions via JavaScript

«Setting these in JS is fine — Google says so directly: “You can use JavaScript to set or change the meta description as well as the <title> element.” The requirement is that they land in the rendered DOM Google evaluates, not just flash briefly. Give each page its own title and description that update when the route changes. » (Traduction) (Résumé en français de la section soixante-deux, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

Réel <a href> liens entre routes

«Make your inter-route links real anchors with href attributes, not click handlers on <div>s. Google discovers URLs by extracting hrefs; a <div onClick> that navigates via the router is invisible to the crawler’s link extraction. And don’t lean on client-side state to carry content across those navigations — Google’s renderer “does not retain state across page loads: Local Storage and Session Storage data are cleared across page loads. HTTP Cookies are cleared across page loads.” » (Traduction) (Résumé en français de la section soixante-quatre, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

Sitemap generation pour SPA routes

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

Le sitemap protocol est unchanged pour SPAs. Le réel fonctionner est making certain chaque route vous liste independently resolves à réel, unique, rendu HTML. A sitemap de 500 client-side routes est worthless si ceux routes tout retourner le même shell. Donc le sitemap est downstream de votre rendu stratégie, pas a substitute pour il.

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

Pour apps avec parameterized routes, vous pouvez’t hand-maintain le liste. Le sitemap generator a à exécuter contre le même données source le app utilise — a construire script or a serveur endpoint que enumerates chaque id — donc le sitemap et le app jamais drift apart. Ceux enumerated URLs sont seulement worth listing une fois ils sont SSR’d or prerendered.

«### Keeping the sitemap in sync with what’s server-resolvable » (Traduction) (Résumé en français de la section soixante-dix, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.)

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

ne pas spot-vérifier un URL. Le entier point de le SPA problème est que routes peut differ dans le navigateur mais pas on le wire, donc tester plusieurs routes à le raw-HTML level:

  • Récupérer raw HTML per route avec curl (a Googlebot utilisateur-agent où pertinent) et confirmer le contenu est unique per URL, pas le shared shell.
  • Inspection d’URL dans Search Console — comparer le exploré/rendu HTML pour plusieurs routes, et confirmer le per-route titre, description, et canonical sont présent.
  • Confirmer erreur routes retourner le correct signal — a “pas trouvé” view devrait soit redirection à a réel erreur status or carry noindex dans le rendu DOM.

Courant myths à propos de SPA SEO

«- “Google can’t index SPAs at all.” Outdated. Google runs evergreen Chromium and generally renders SPA content. The real risks are specific: soft 404s, hash routing, render timeouts, and non-Google crawlers (Bing less reliably, most AI crawlers not at all). » (Traduction) (Résumé en français de la section soixante-dix-sept, sous-partie un : le texte source est conservé pour vérification lors de la relecture francophone.) «- “Adding the History API fixes SPA SEO.” It fixes addressability only. If the server still returns the same shell for every route, Google still has to execute JS to see anything. » (Traduction) (Résumé en français de la section soixante-dix-sept, sous-partie deux : le texte source est conservé pour vérification lors de la relecture francophone.) «- “Hash routing still works as a fallback.” Deprecated since 2015 and recommended against ever since. » (Traduction) (Résumé en français de la section soixante-dix-sept, sous-partie trois : le texte source est conservé pour vérification lors de la relecture francophone.) «- “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. » (Traduction) (Résumé en français de la section soixante-dix-sept, sous-partie quatre : le texte source est conservé pour vérification lors de la relecture francophone.) «- “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. » (Traduction) (Résumé en français de la section soixante-dix-sept, sous-partie cinq : le texte source est conservé pour vérification lors de la relecture francophone.) «- “You need a special SPA sitemap generator.” No special format exists; the work is making every listed route resolve to real HTML. » (Traduction) (Résumé en français de la section soixante-dix-sept, sous-partie six : le texte source est conservé pour vérification lors de la relecture francophone.)

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.

Est hash (#!) routing mauvais pour le SEO? Oui — le fragment jamais reaches le serveur, donc Google ne peut pas reliably resolve ceux URLs. Utiliser le History API.

Fait chaque route besoin son propre balise canonical? Oui, dans le rendu DOM — et faire certain rien plus restrictive (a stray noindex or incorrect canonical) ships dans le raw shell.

Is a prerendering service cloaking? Aucun, provided le contenu served to bots matches ce que utilisateurs voir.

Devrait Je utiliser a meta-framework au lieu de building routing myself? Pour a nouveau construire, généralement oui — Suivant.js/Nuxt/Angular-SSR/SvelteKit donner vous SSR/SSG et per-route metadata pour gratuit.

Pourquoi fait mon SPA retourner 200 pour pages que ne pas exist? Parce que le client-side router garde le original 200 status pour virtual navigations. Corriger il avec a JS redirection à a réel erreur status or a rendu noindex.

Ajouter une note d’expert

Épingler une citation d’expert

Nouvelle personne ? Créez son profil non revendiqué à /admin/experts/ → Épingler une citation d’expert d’abord.