SPA e SEO

Come rendere le applicazioni a pagina singola (React Router, Vue Router, Angular Router) indicizzabili e scansionabili — i problemi dell'app-shell e dei soft-404, History API vs routing con hash, HTML per rotta tramite SSR/prerendering, canonical e title per rotta, e generazione della sitemap per rotte lato client.

Prima pubblicazione: 3 lug 2026 · Ultimo aggiornamento: 20 ago 2026 · Advanced
Lingue

Un'applicazione a pagina singola carica un unico documento e cambia le viste con JavaScript invece di richiedere una nuova pagina al server. Molte SPA implementano questo con un 'app shell' — il server restituisce l'HTML reale di un solo URL, una shell quasi vuota, finché il JS non renderizza ogni rotta — ma questa è una scelta implementativa comune, non una regola che ogni SPA segue; controlla cosa restituisce effettivamente una richiesta diretta a ciascuna rotta prima di assumerlo. La soluzione, dove un setup app-shell nudo è il problema, ha due metà indipendenti: indirizzabilità (History API, non frammenti hash/#!, così ogni vista ha un URL reale — una navigazione soft del browser tramite History API cambia l'URL e l'interfaccia ma non crea di per sé una nuova risposta del server) e disponibilità dei contenuti (SSR, prerendering o un meta-framework così che ciascuno di quegli URL possa restituire HTML unico su richiesta). In aggiunta, verifica il title, il canonical e lo stato robots di ogni rotta sia all'ingresso diretto che dopo il rendering, gestisci gli errori soft “pagina non trovata” (i router tendono a mantenere uno stato 200 per le viste 'non trovato' — reindirizza a un URL che restituisce effettivamente uno stato di errore, o aggiungi un noindex renderizzato, anche se un noindex iniziale può causare la mancata esecuzione del rendering), e assicurati che ogni rotta nella tua sitemap risolva effettivamente in HTML reale e indicizzabile — non esiste un formato speciale di sitemap per SPA.

TL;DR — Il rischio SEO principale di una SPA non è “JavaScript” in astratto — è che il routing lato client cambia ciò che l’utente vede senza cambiare ciò che il server restituirebbe. La soluzione ha due metà indipendenti che le persone confondono costantemente: indirizzabilità (History API, URL unici per rotta, niente frammenti #!) e disponibilità del contenuto (SSR, prerendering/SSG, o un meta-framework). Fai solo la prima e ottieni una sitemap ordinata di URL che renderizzano tutti la stessa shell. In aggiunta a entrambe, ogni rotta deve avere il suo canonical/titolo/descrizione nel DOM renderizzato, devi gestire i soft 404 che mantengono un 200, e ogni rotta nella sitemap deve risolversi indipendentemente in HTML reale.

Cos’è realmente la SEO per SPA

SPA è un’architettura applicativa, non uno stato di fallimento SEO garantito. 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 Il rendering lato server, il prerendering o un rendering lato client implementato con cura possono esporre i contenuti, ma nessuno garantisce l’indicizzazione. 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

Questo è l’approfondimento su applicazioni a pagina singola nello specifico — routing lato client con React Router, Vue Router o Angular Router, dove dopo il primo caricamento il server non invia mai un’altra pagina completa. Si affianca alla guida più ampia sulla SEO JavaScript (che copre parità, caricamento lazy, scorrimento infinito e rendering JS in generale) e agli articoli per framework specifici per React, Next.js, Nuxt, Angular, Vue, Svelte e Astro. Qui mi interessa solo il livello di routing e cosa fa a crawling e indicizzazione.

Perché le SPA con routing lato client puro falliscono nella SEO

Un URL, una risposta HTML — il problema dell’app shell

Google descrive la modalità di errore con precisione: “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.” (traduzione) «Alcuni siti JavaScript possono usare il modello app shell in cui l’HTML iniziale non contiene il contenuto effettivo e Google deve eseguire JavaScript prima di poter vedere il contenuto effettivo della pagina.» In una SPA pura, l‘“app shell” è tutto ciò che il server restituisce. Recupera /products da zero e ottieni lo stesso documento quasi vuoto che otterresti per /about. Il contenuto diverge solo dopo che il browser esegue il tuo JavaScript e il tuo router decide cosa mostrare.

Questo è l’intero problema in una frase: il routing lato client cambia ciò che l’utente vede senza cambiare ciò che il server restituirebbe. Tutto ciò che ne consegue — soft 404, contenuti duplicati, titoli mancanti — è un sintomo di questo.

Il server non vede mai quale “pagina” è stata richiesta (perché il routing con hash fallisce)

Le SPA più vecchie caricano le viste da frammenti di URL — example.com/#/products. Le parole esatte di Google: “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” (traduzione) «Una SPA può usare frammenti di URL (ad esempio https://example.com/#/products) per caricare viste diverse.» Questo fallisce per un motivo specifico e meccanico: i browser non inviano mai il frammento (qualsiasi cosa dopo #) al server nella richiesta HTTP. Il server letteralmente non può sapere quale “pagina” è stata richiesta, quindi non può restituire contenuti diversi o un codice di stato diverso per essa. Un crawler che recupera l’URL una volta ottiene HTML identico indipendentemente dal frammento.

Questo è anche il motivo per cui Google ha deprecato formalmente il suo schema di crawling AJAX del 2009 nell’ottobre 2015: “In short: We are no longer recommending the AJAX crawling proposal we made back in 2009.” (traduzione) «In breve: non raccomandiamo più la proposta di crawling AJAX che abbiamo fatto nel 2009.» Il vecchio workaround _escaped_fragment_ permetteva ai server di pre-renderizzare le route con frammenti su richiesta — ma aggirava il problema del routing invece di risolverlo. La raccomandazione di Google che lo sostituisce è la History API, trattata di seguito.

Soft 404 — i router lato client mantengono un 200 per tutto

Questo è quasi inevitabile in una SPA pura. I router lato client, per progettazione, mantengono lo stato 200 della pagina originale per ogni navigazione virtuale — inclusi gli stati “non trovato”. Google lo segnala esplicitamente: “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.” (traduzione) «In un’applicazione a pagina singola (SPA), questo può essere particolarmente difficile. Per impedire che le pagine di errore vengano indicizzate, puoi usare una o entrambe le seguenti strategie.» E il perché: “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.” (traduzione) «Quando una SPA usa JavaScript lato client per gestire gli errori, spesso riporta un codice di stato HTTP 200 invece del codice di stato appropriato.»

Il risultato è che viste vuote o di errore vengono indicizzate come pagine 200 sottili. Google documenta esattamente due strategie qui, precisamente: reindirizza (o effettua una richiesta completa) a un URL il cui server restituisce un vero 404/codice di errore, oppure aggiungi un tag noindex alla vista di errore con JavaScript. Attenzione alla seconda: se quel noindex è presente dal primissimo paint piuttosto che aggiunto dopo che l’app decide che la route non è valida, può far sì che Google salti del tutto il rendering della pagina — quindi verifica cosa restituisce una richiesta diretta prima che venga eseguito qualsiasi JS, non solo ciò che appare nel DOM renderizzato successivamente.

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

Duplicati shell condivisi: una causa possibile, non una diagnosi automatica

C’è un secondo, più subdolo modo di fallire: rotte distinte vengono indicizzate come duplicati l’una dell’altra perché vengono renderizzate nello stesso boilerplate condiviso di header/nav/footer. Gary Illyes ha descritto un modo in cui ciò accade: “I have a bunch of emails in my inbox where the issue is that the centerpiece took forever to load, so rendering timed out (my most likely explanation) and we were left with a bunch of pages that only had the boilerplate. With only the boilerplate, those pages are dups.” (traduzione) «Ho un mucchio di email nella mia casella di posta in cui il problema è che il contenuto principale ha impiegato un’eternità a caricarsi, quindi il rendering è andato in timeout (la mia spiegazione più probabile) e ci siamo ritrovati con un mucchio di pagine che avevano solo il boilerplate. Con solo il boilerplate, quelle pagine sono duplicati.» La sua soluzione: “Try to restructure the js calls such that the content (including marginal boilerplate) loads first.” (traduzione) «Cerca di ristrutturare le chiamate js in modo che il contenuto (incluso il boilerplate marginale) si carichi per primo.» (Riportato tramite un post su LinkedIn, che resiste alla verifica automatizzata; trattalo come fonte secondaria ad alta affidabilità.)

Nota che Illyes la definisce “my most likely explanation” (traduzione) «la mia spiegazione più probabile», non una diagnosi confermata: vale la pena prenderlo alla lettera. Il timeout di rendering non si può diagnosticare dai soli sintomi; servono prove a livello di richiesta (cosa restituisce una fetch diretta), prove dell’output renderizzato (se il contenuto principale è presente) e dati di Search Console (segnali di duplicazione o indicizzazione). Solo così puoi distinguere un timeout specifico da un bug, una risorsa bloccata o una shell realmente identica. Inoltre, non esiste un timeout fisso e pubblicato attorno a cui progettare: il documento di base di Google afferma che una pagina “may stay on this queue for a few seconds, but it can take longer than that,” (traduzione) «può restare in questa coda per alcuni secondi, ma potrebbe volerci più tempo», senza indicare una durata precisa. Assicurati quindi che il contenuto principale si carichi il prima possibile, indipendentemente dal tempo complessivo richiesto dal rendering.

La soluzione: ottieni HTML reale per ogni rotta

La soluzione completa ha due parti indipendenti che la gente confonde di continuo:

  1. Indirizzabilità URL — History API, URL unici per rotta, niente frammenti.
  2. Disponibilità del contenuto — SSR, prerendering statico/SSG, o un meta-framework.

Se fai solo #1 ottieni URL puliti e condivisibili che restituiscono tutti la stessa shell vuota. Per una rotta che vuoi davvero indicizzare, ti servono entrambe.

Non è però un mandato universale per aggiungere SSR ovunque. SSR e prerendering riducono quanto dipendi dal fatto che un crawler esegua con successo il tuo JavaScript — non diventano obbligatori nel momento in cui un sito è tecnicamente una SPA. Prima di scegliere un’architettura per una data rotta, verifica tre cose: se quella rotta deve posizionarsi affatto (un pannello admin interno no), cosa restituisce già una richiesta diretta e senza JS (alcune configurazioni inviano già HTML significativo), e quali crawler devi effettivamente soddisfare (Google renderizza JS in modo abbastanza affidabile; Bing e la maggior parte dei crawler AI meno — vedi sotto). Un CSR che supera già questi controlli non deve diventare SSR solo perché il sito è una SPA.

Server-side rendering (SSR)

Il server esegue la tua app per ogni richiesta e restituisce HTML completo per quella rotta, poi il client lo “idrata” trasformandolo in una SPA viva. Questa è l’opzione più robusta perché il crawler riceve contenuto completo al primo fetch, senza bisogno di eseguire JS.

Prerendering statico / SSG

Invece di renderizzare per richiesta, costruisci l’HTML di ogni rotta in anticipo al deploy. Perfetto per contenuti che non cambiano per utente. Dai miei scritti sul SEO JavaScript: qualsiasi configurazione di SSR, rendering statico e prerendering andrà bene per i motori di ricerca — la cosa da evitare è lasciare il contenuto bloccato dietro un rendering solo client-side.

Oppure: non reinventare la ruota — usa un meta-framework

Per una nuova build, la raccomandazione onesta è di non creare a mano un routing lato client solo con React-Router o solo con Vue-Router. Un meta-framework — Next.js, Nuxt, Angular con il suo pacchetto SSR, SvelteKit, Remix — ti offre SSR/SSG e metadati basati sulle route out of the box, il che aggira l’intera categoria di problemi. Sii onesto sul rovescio della medaglia: adattare SSR a una SPA fatta a mano esistente è un vero lavoro di ingegneria — un progetto di migrazione, non un interruttore di configurazione.

Rendering dinamico come soluzione temporanea, non come destinazione

Puoi servire ai crawler uno snapshot HTML renderizzato separatamente (rendering dinamico). Sia Google che Bing lo accettano — Bing “raccomanda il rendering dinamico come una grande alternativa per i siti web che dipendono fortemente da JavaScript” (riportato dal blog di Bing del 2018; le due brevi citazioni sulle capacità di bingbot qui sotto sono verificate direttamente, questa più lunga non è stata ricontrollata in modo indipendente) — ma Google è chiaro che è una soluzione temporanea: “Il rendering dinamico era una soluzione temporanea e non una soluzione a lungo termine… Invece, ti consigliamo di usare il rendering lato server, il rendering statico o l’idratazione come soluzione.” (Riportato dal documento di Google sul rendering dinamico; la formulazione corrisponde a quanto già citato nella guida più ampia sul SEO JavaScript di questo sito.) È un ponte, non un’architettura.

API History vs. routing con hash (#!)

Cinque stati, non due

Testare una route SPA diventa confuso perché “funziona” in realtà copre cinque stati diversi e verificabili separatamente:

StatoCosa è
Risposta del serverI byte che una richiesta HTTP fresca e senza JavaScript a un URL riceve effettivamente.
DOM renderizzatoCiò che un browser (o il renderer di Googlebot) costruisce dopo aver eseguito JavaScript su quella risposta del server.
Elaborazione della ricercaCome Google separatamente esegue il crawl della risposta del server, successivamente renderizza la pagina e indicizza in base a entrambi.
Navigazione completa del documento nel browserUna nuova richiesta HTTP effettiva a un URL — l’unico stato che può cambiare la risposta del server o il codice di stato.
Navigazione soft del browserUna transizione dell’API History (pushState/replaceState) che cambia l’URL visibile, la cronologia del browser e l’interfaccia utente sullo schermo.

Quello che tutti confondono: una navigazione soft cambia l’URL e l’interfaccia utente, ma non crea da sola una nuova risposta HTTP o uno stato — questo accade solo con una navigazione completa (o una richiesta diretta equivalente, come curl). Testare una route cliccando attraverso l’app dalla homepage esercita la navigazione soft; testarla richiedendo direttamente l’URL esercita la risposta del server. Entrambi contano, e possono essere in disaccordo.

Cosa ti dà l’API History

La raccomandazione di Google è inequivocabile: “Ti consigliamo di usare l’API History per caricare contenuti diversi in base all’URL in una SPA.” (Nel documento live “History API” è un link, quindi questo deep link punta alla frase introduttiva.) L’API History (pushState/replaceState) permette al tuo router di cambiare l’URL visibile in un percorso reale e condivisibile — /products, non /#/products — senza un ricaricamento completo. Questo risolve la metà dell’indirizzabilità: ogni vista ora ha un URL a cui un server potrebbe rispondere in modo diverso.

Perché gli URL con frammento/hash sono invisibili al server — e a Google

Poiché il frammento non raggiunge mai il server (vedi sopra), l’API History è l’unico modo per dare a ogni route un URL che il server possa effettivamente servire. Google mette un limite nel suo documento di base: “non usare frammenti per caricare contenuti di pagina diversi. L’esempio seguente è una cattiva pratica, perché Googlebot non può risolvere in modo affidabile gli URL.” Questa è la stessa guida che ho scritto altrove — usa URL dall’aspetto normale come /products, non URL con hash come /#/products, perché Google non può indicizzare in modo affidabile quelli con hash.

La deprecazione del 2015, brevemente

L’era dell’hash-bang (#!) è terminata con il post di Google del 2015: “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.” (traduzione) «I tempi sono cambiati. Oggi, purché tu non impedisca a Googlebot di eseguire la scansione dei file JavaScript o CSS, in genere riusciamo a renderizzare e comprendere le pagine web come i browser moderni.» Google aggiunge: “you can use the History API pushState() to ensure accessibility for a wider range of browsers (and our systems).” (traduzione) «puoi usare pushState() della History API per garantire l’accessibilità a una gamma più ampia di browser (e ai nostri sistemi)». Una sfumatura importante: Google non ha rimosso subito dall’indice i vecchi siti hash-bang — “we’ll generally crawl, render, and index the #! URLs” (traduzione) «in genere eseguiremo la scansione, il rendering e l’indicizzazione degli URL #!» — ma il fatto che possa ancora provarci non significa che sia una soluzione consigliata.

Rendere ogni route indicizzabile in modo indipendente

URL puliti e HTML reale ti fanno indicizzare. Per essere indicizzato correttamente, ogni route ha bisogno dei propri segnali.

Tag canonical per route — e la trappola della “direttiva più restrittiva”

Ogni route ha bisogno del proprio rel=canonical nel DOM renderizzato. La trappola: se un canonical segnaposto (o un noindex) viene inserito nel guscio HTML grezzo e JavaScript dovrebbe sovrascriverlo in seguito, puoi ottenere un conflitto. Google risolve i conflitti tra la versione grezza e quella renderizzata prendendo il segnale più restrittivo — come ho già detto, Google sceglierà le dichiarazioni più restrittive tra l’HTML e la versione renderizzata di una pagina. Un noindex fuori posto o un canonical errato incorporato nel guscio può sopprimere silenziosamente l’intera route anche dopo che il tuo JS lo “corregge”.

Titoli e meta description per route tramite JavaScript

Impostarli in JS va bene — Google lo dice direttamente: “You can use JavaScript to set or change the meta description as well as the <title> element.” (traduzione) «Puoi usare JavaScript per impostare o modificare la meta description e l’elemento title.» Il requisito è che finiscano nel DOM renderizzato che Google valuta, non solo che appaiano brevemente. Dai a ogni pagina il proprio titolo e la propria descrizione che si aggiornano quando cambia la route.

Rendi i link tra le rotte vere ancore con attributi href, non elementi gestiti al clic come <div>. Google scopre gli URL estraendo gli href; un <div onClick> che naviga tramite il router è invisibile all’estrazione dei link del crawler. Non fare affidamento nemmeno sullo stato lato client per trasportare contenuti attraverso quelle navigazioni: il renderer di Google afferma “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.” (traduzione) «WRS non conserva lo stato tra i caricamenti delle pagine: i dati di Local Storage e Session Storage vengono cancellati tra un caricamento e l’altro. Anche i cookie HTTP vengono cancellati.»

Generazione della sitemap per le route SPA

Non esiste un formato speciale di “sitemap SPA” — è un problema di contabilità

Il protocollo sitemap è invariato per le SPA. Il lavoro vero è assicurarsi che ogni route che elenchi in modo indipendente risolva in HTML reale, unico e renderizzato. Una sitemap di 500 route lato client è inutile se quelle route restituiscono tutte lo stesso guscio. Quindi la sitemap è a valle della tua strategia di rendering, non un suo sostituto.

Gestione delle route dinamiche/parametriche (/product/:id)

Per le app con route parametriche, non puoi mantenere l’elenco a mano. Il generatore di sitemap deve essere eseguito sulla stessa fonte dati che usa l’app — uno script di build o un endpoint del server che enumera ogni id — così la sitemap e l’app non divergono mai. Quegli URL enumerati valgono la pena di essere elencati solo una volta che sono SSR’d o prerenderizzati.

Mantenere la sitemap sincronizzata con ciò che è risolvibile dal server

Rigenera la sitemap come parte della tua build o con una pianificazione legata alla tua fonte di contenuti. Una route che restituisce 404 (o peggio, soft-404 a 200) ma che è nella tua sitemap è un segnale di crawl-budget e di qualità che non vuoi.

Testare ciò che Google vede davvero

Non controllare a campione un singolo URL. Il punto centrale del problema SPA è che le route possono differire nel browser ma non sul filo, quindi testa più route a livello di HTML grezzo:

  • Recupera l’HTML grezzo per ogni route con curl (un user-agent di Googlebot dove pertinente) e conferma che il contenuto sia unico per URL, non la shell condivisa.
  • URL Inspection in Search Console — confronta l’HTML crawlerizzato/rendered per diverse route e conferma che title, description e canonical per route siano presenti.
  • Conferma che le route di errore restituiscano il segnale giusto — una vista “non trovato” dovrebbe reindirizzare a un vero stato di errore o portare noindex nel DOM rendered.

Miti comuni sulla SEO per SPA

  • “Google non può indicizzare le SPA.” Superato. Google esegue Chromium evergreen e generalmente renderizza il contenuto delle SPA. I rischi reali sono specifici: soft 404, hash routing, timeout di rendering e crawler non Google (Bing meno affidabile, la maggior parte dei crawler AI per niente).
  • “Aggiungere la History API risolve la SEO per SPA.” Risolve solo l’indirizzabilità. Se il server restituisce ancora la stessa shell per ogni route, Google deve comunque eseguire JS per vedere qualcosa.
  • “L’hash routing funziona ancora come fallback.” Deprecato dal 2015 e sconsigliato da allora.
  • “Il rendering lato client è una penalità di ranking.” Non esiste una penalità CSR diretta. Il danno è indiretto — rendering fallito/ritardato, soft 404 e clustering duplicato da timeout di rendering riducono ciò che viene indicizzato.
  • “Il pre-rendering per i bot è cloaking.” Non quando il contenuto corrisponde a ciò che gli utenti vedono alla fine — solo il quando/dove del rendering differisce, non il contenuto.
  • “Serve un generatore di sitemap speciale per SPA.” Non esiste un formato speciale; il lavoro è far sì che ogni route elencata risolva in HTML reale.

FAQ

Google può indicizzare una single-page application? Sì, se ogni route risolve in HTML reale, unico (tramite SSR/prerendering) a un URL reale. Le SPA solo client spesso no.

Mi serve SSR, o il rendering lato client è mai accettabile? Il CSR può funzionare per contenuti che non devono posizionarsi, ma per qualsiasi cosa vuoi indicizzare in modo affidabile, prerenderizzala o usa SSR — non scommettere che il renderer esegua il tuo JS in tempo.

L’hash (#!) routing è dannoso per la SEO? Sì — il frammento non arriva mai al server, quindi Google non può risolvere in modo affidabile quegli URL. Usa la History API.

Ogni route ha bisogno del proprio tag canonical? Sì, nel DOM rendered — e assicurati che nulla di più restrittivo (un noindex fuori posto o un canonical errato) venga inviato nella shell raw.

Un servizio di prerendering è cloaking? No, a condizione che il contenuto servito ai bot corrisponda a ciò che vedono gli utenti.

Dovrei usare un meta-framework invece di costruire il routing da solo? Per una nuova build, di solito sì — Next.js/Nuxt/Angular-SSR/SvelteKit ti danno SSR/SSG e metadati per route gratuitamente.

Perché la mia SPA restituisce 200 per pagine che non esistono? Perché il router lato client mantiene lo stato 200 originale per le navigazioni virtuali. Risolvilo con un reindirizzamento JS a un vero stato di errore o un noindex rendered.

Add an expert note

Pin an expert quote

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