SEO para SPA

Cómo hacer que las aplicaciones de página única (React Router, Vue Router, Angular Router) sean rastreables e indexables: los problemas del app shell y del error soft 404, History API frente al enrutamiento por fragmentos, HTML propio por ruta mediante SSR o prerenderizado, señales de canonización y títulos por ruta, y generación de sitemaps para rutas gestionadas en el cliente.

Publicado por primera vez: 3 jul 2026 · Última actualización: 20 ago 2026 · Avanzado
Idiomas

Una aplicación de página única carga un único documento y cambia de vista con JavaScript en lugar de pedir una página nueva al servidor. Muchas SPA lo implementan con un "app shell": el servidor devuelve el HTML real de una sola URL, un contenedor casi vacío, hasta que JavaScript renderiza cada ruta. Pero eso es una decisión de implementación habitual, no una regla que siga toda SPA; compruebe qué devuelve realmente una petición directa a cada ruta antes de darlo por supuesto. La solución, cuando el problema es una configuración de app shell sin más, tiene dos mitades independientes: direccionabilidad (History API, no fragmentos hash/#!, para que cada vista tenga una URL real; una navegación suave del navegador mediante la History API cambia la URL y la interfaz, pero no crea por sí misma una nueva respuesta del servidor) y disponibilidad del contenido (SSR, prerenderizado o un metaframework, para que cada una de esas URL pueda devolver HTML único cuando se solicita). Además, verifique el título, la señal de canonización y el estado de robots de cada ruta tanto en la entrada directa como después del renderizado, gestione las vistas inexistentes (los routers tienden a mantener un estado 200 para «no encontrado»: redirija a una URL que devuelva un estado de error real o añada un noindex renderizado, aunque un noindex inicial puede provocar que se omita el renderizado) y asegúrese de que cada ruta del sitemap se resuelve en HTML real e indexable: no existe ningún formato de sitemap específico para SPA.

TL;DR — El riesgo principal para el SEO de una SPA no es “JavaScript” en abstracto: es que el enrutamiento en el cliente cambia lo que ve el usuario sin cambiar lo que devolvería el servidor. La solución tiene dos mitades independientes que constantemente se confunden: direccionabilidad (History API, URL únicas por ruta, sin fragmentos #!) y disponibilidad del contenido (SSR, prerenderizado/SSG o un metaframework). Si solo hace lo primero, obtendrá un sitemap impecable de URL que renderizan todas el mismo contenedor. Además de ambas cosas, cada ruta necesita su propia etiqueta canónica, título y descripción en el DOM renderizado, hay que gestionar los errores soft 404 que mantienen un 200, y cada ruta del sitemap tiene que resolverse de forma independiente en HTML real.

Qué es en realidad el SPA SEO

Una SPA es una arquitectura de aplicación, no un fallo de SEO garantizado. 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 El renderizado en el servidor, el prerenderizado o un renderizado en el cliente cuidadosamente implementado pueden exponer el contenido, pero ninguno garantiza la indexación. 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

Este es el análisis en profundidad de las aplicaciones de página única en concreto: enrutamiento en el cliente con React Router, Vue Router o Angular Router, donde después de la primera carga el servidor nunca vuelve a enviar una página completa. Complementa la guía más amplia de SEO en JavaScript (que cubre la paridad, la carga diferida, el desplazamiento infinito y el renderizado de JS en general) y los artículos por framework sobre React, Next.js, Nuxt, Angular, Vue, Svelte y Astro. Aquí solo me interesa la capa de enrutamiento y lo que hace al rastreo y a la indexación.

Por qué las SPA con enrutamiento en el cliente sin más fallan en SEO

Una URL, una respuesta HTML: el problema del app shell

Google describe el modo de fallo con precisión: “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.” (traducción) «Algunos sitios con JavaScript pueden usar el modelo de app shell, en el que el HTML inicial no contiene el contenido real y Google necesita ejecutar JavaScript para poder ver el contenido real de la página». En una SPA sin más, el app shell es todo lo que el servidor llega a devolver. Solicite /products en frío y obtendrá el mismo documento casi vacío que obtendría para /about. El contenido solo diverge después de que el navegador ejecute su JavaScript y su router decida qué mostrar.

Ese es todo el problema en una frase: el enrutamiento en el cliente cambia lo que ve el usuario sin cambiar lo que devolvería el servidor. Todo lo que viene después (errores soft 404, contenido duplicado, títulos ausentes) es un síntoma de eso.

El servidor nunca ve qué “página” se solicitó (por qué falla el enrutamiento por fragmentos)

Las SPA más antiguas cargan vistas a partir de fragmentos de URL: example.com/#/products. Las palabras exactas de Google: “A SPA may use URL fragments (for example https://example.com/#/products) for loading different views.” (traducción) «Una SPA puede usar fragmentos de URL para cargar vistas diferentes». Esto falla por un motivo concreto y mecánico: los navegadores nunca envían el fragmento (todo lo que va después de #) al servidor en la petición HTTP. El servidor literalmente no puede saber qué “página” se solicitó, así que no puede devolver contenido distinto ni un código de estado distinto para ella. Un rastreador que solicite la URL una vez obtiene HTML idéntico independientemente del fragmento.

Ese es también el motivo por el que Google dejó formalmente obsoleto su esquema de rastreo AJAX de 2009 en octubre de 2015: “In short: We are no longer recommending the AJAX crawling proposal we made back in 2009.” (traducción) «En resumen: ya no recomendamos la propuesta de rastreo AJAX que presentamos en 2009». La antigua solución alternativa con _escaped_fragment_ permitía a los servidores prerenderizar las rutas por fragmento bajo demanda, pero parcheaba el problema de enrutamiento en lugar de resolverlo. La recomendación de Google que la sustituye es la History API, que se trata más abajo.

Errores soft 404: los routers en el cliente mantienen un 200 para todo

Este problema es casi inevitable en una SPA sin más. Los routers en el cliente, por diseño, mantienen el estado 200 de la página original en cada navegación virtual, incluidos los estados de «no encontrado». Google lo señala explícitamente: “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.” (traducción) «En una aplicación de página única puede resultar especialmente difícil. Para evitar que se indexen páginas de error, puede usar una de las estrategias siguientes o ambas». Y explica el motivo: “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.” (traducción) «Cuando una SPA usa JavaScript del cliente para gestionar errores, a menudo comunica un código de estado HTTP 200 en lugar del código adecuado».

El resultado son vistas vacías o de error que se indexan como páginas 200 de poco valor. Google documenta exactamente dos estrategias aquí, con precisión: redirigir (o hacer una petición completa) a una URL cuyo servidor devuelva un estado real de 404/error, o añadir una etiqueta noindex a la vista de error con JavaScript. Cuidado con la segunda: si ese noindex está presente desde el primer pintado en lugar de añadirse después de que la aplicación determine que la ruta no es válida, puede provocar que Google omita por completo el renderizado de la página; así que verifique qué devuelve una petición directa antes de que se ejecute cualquier JS, y no solo lo que aparece después en el DOM renderizado.

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

Duplicados por contenedor compartido: una posible causa, no un diagnóstico automático

Hay un segundo modo de fallo, más sutil: rutas distintas se indexan como duplicados entre sí porque se renderizan hasta quedar en la misma plantilla compartida de cabecera, navegación y pie. Gary Illyes describió una de las formas en que esto ocurre: “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.” (traducción) «Tengo varios correos en los que el problema es que el contenido principal tardó demasiado en cargarse, por lo que el renderizado agotó el tiempo de espera —mi explicación más probable— y quedaron páginas que solo contenían la plantilla. Al tener solo la plantilla, esas páginas son duplicadas». Su solución: “Try to restructure the js calls such that the content (including marginal boilerplate) loads first.” (traducción) «Intente reorganizar las llamadas de JavaScript para que el contenido —incluida la plantilla secundaria— se cargue primero». (Transmitido a través de una publicación de LinkedIn, que se resiste a la verificación automatizada; considérese fuente secundaria de alta confianza.)

Fíjese en que Illyes lo plantea como “my most likely explanation” (traducción) «mi explicación más probable», no como un diagnóstico confirmado: conviene tomárselo literalmente. El tiempo de espera de renderizado no es algo que se pueda diagnosticar solo a partir de los síntomas; se necesitan pruebas a nivel de petición (qué devuelve realmente una solicitud directa), pruebas del resultado renderizado (si el contenido principal está presente tras el renderizado) y pruebas de Search Console (señales de duplicación e indexación) antes de atribuir el problema a un tiempo de espera concreto, y no a un error, un recurso bloqueado o un contenedor genuinamente idéntico. Tampoco hay un tiempo de espera fijo y publicado con el que planificar: la documentación básica de Google dice que una página “may stay on this queue for a few seconds, but it can take longer than that,” (traducción) «puede permanecer en esta cola unos segundos, aunque también puede tardar más», sin comprometerse con una cifra. No planifique en torno a una duración supuesta de la cola; asegúrese de que el contenido principal se cargue lo antes posible, con independencia de cuánto tarde el renderizado.

La solución: conseguir HTML real por ruta

La solución completa tiene dos partes independientes que la gente confunde constantemente:

  1. Direccionabilidad de las URL: History API, URL únicas por ruta, sin fragmentos.
  2. Disponibilidad del contenido: SSR, prerenderizado estático/SSG o un metaframework.

Si solo hace el punto 1, obtendrá URL limpias y compartibles que siguen devolviendo el mismo contenedor en blanco. Para una ruta que realmente quiera ver indexada, necesita ambas.

Eso sí, no es un mandato universal para añadir SSR en todas partes. El SSR y el prerenderizado reducen la dependencia de que un rastreador ejecute correctamente su JavaScript; no se vuelven obligatorios en el instante en que un sitio es técnicamente una SPA. Antes de elegir una arquitectura para una ruta concreta, compruebe tres cosas: si esa ruta necesita posicionarse siquiera (un panel de administración interno no lo necesita), qué devuelve ya una petición directa sin JS (algunas configuraciones ya envían HTML significativo) y qué rastreadores necesita satisfacer realmente (Google renderiza JS con bastante fiabilidad; Bing y la mayoría de rastreadores de IA, menos, véase más abajo). Un CSR que ya supera esas comprobaciones no tiene por qué convertirse en SSR solo porque el sitio sea una SPA.

Renderizado en el servidor (SSR)

El servidor ejecuta su aplicación en cada petición y devuelve HTML completamente formado para esa ruta; después el cliente lo “hidrata” hasta convertirlo en una SPA activa. Es la opción más robusta porque el rastreador obtiene el contenido completo en la primera petición, sin necesidad de ejecutar JS.

Prerenderizado estático / SSG

En lugar de renderizar en cada petición, se genera de antemano el HTML de cada ruta en el despliegue. Perfecto para contenido que no cambia según el usuario. De lo que yo mismo he escrito sobre SEO en JavaScript: cualquier configuración de SSR, renderizado estático y prerenderizado va a funcionar bien para los motores de búsqueda; lo que hay que evitar es dejar el contenido encerrado tras un renderizado exclusivo del cliente.

O bien: no lo haga a mano, use un metaframework

Para un desarrollo nuevo, la recomendación honesta es no montar a mano un enrutamiento en el cliente basado solo en React Router o solo en Vue Router. Un metaframework (Next.js, Nuxt, Angular con su paquete de SSR, SvelteKit, Remix) le proporciona SSR/SSG y metadatos por ruta de serie, lo que evita por completo esta categoría de problemas. (Cada uno de ellos tiene su propio análisis en profundidad en este sitio). Sea sincero con la otra cara de la moneda: incorporar SSR a posteriori en una SPA existente hecha a mano es un trabajo de ingeniería real, un proyecto de migración, no un simple interruptor de configuración.

El renderizado dinámico como parche temporal, no como destino

Puede servir a los rastreadores una instantánea HTML renderizada por separado (renderizado dinámico). Tanto Google como Bing lo aceptan; Bing “recommend[s] dynamic rendering as a great alternative for websites relying heavily on JavaScript” (traducción) «recomienda el renderizado dinámico como una alternativa excelente para sitios que dependen en gran medida de JavaScript» (transmitido desde el blog de Bing de 2018; las dos citas breves siguientes sobre las capacidades de bingbot están verificadas directamente, esta más larga no se ha vuelto a comprobar de forma independiente), pero Google es claro en que se trata de una solución provisional: “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.” (traducción) «El renderizado dinámico era una solución provisional, no una solución a largo plazo. En su lugar, recomendamos usar renderizado en el servidor, renderizado estático o hidratación». (Transmitido desde la documentación de renderizado dinámico de Google; la redacción coincide con la ya citada en la guía más amplia de SEO en JavaScript de este sitio.) Es un puente, no una arquitectura.

History API frente al enrutamiento por fragmentos (#!)

Cinco estados, no dos

Probar una ruta de una SPA resulta confuso porque “si funciona” abarca en realidad cinco estados distintos y verificables por separado:

EstadoEn qué consiste
Respuesta del servidorLos bytes que recibe realmente una petición HTTP nueva y sin JS a una URL.
DOM renderizadoLo que construye un navegador (o el renderizador de Googlebot) tras ejecutar JavaScript sobre esa respuesta del servidor.
Procesamiento de la BúsquedaCómo Google rastrea por separado la respuesta del servidor, renderiza después la página e indexa a partir de ambas cosas.
Navegación completa de documento en el navegadorUna nueva petición HTTP real a una URL: el único estado que puede cambiar la respuesta del servidor o el código de estado.
Navegación suave en el navegadorUna transición de la History API (pushState/replaceState) que cambia la URL visible, el historial del navegador y la interfaz en pantalla.

El que todo el mundo confunde: una navegación suave cambia la URL y la interfaz, pero no crea por sí misma una nueva respuesta HTTP ni un nuevo estado; eso solo ocurre en una navegación completa (o en una petición directa equivalente, como curl). Probar una ruta haciendo clic por la aplicación desde la página de inicio ejercita la navegación suave; probarla solicitando la URL directamente ejercita la respuesta del servidor. Ambas cosas importan, y pueden no coincidir.

Qué le aporta la History API

La recomendación de Google es inequívoca: “We recommend using the History API to load different content based on the URL in a SPA.” (traducción) «Recomendamos usar la History API para cargar contenido diferente según la URL de una SPA». (En la documentación en vivo, «History API» es un enlace, así que este enlace profundo apunta a la cláusula introductoria.) La History API (pushState/replaceState) permite que su router cambie la URL visible a una ruta real que se pueda guardar en marcadores (/products, no /#/products) sin una recarga completa. Eso resuelve la mitad de la direccionabilidad: ahora cada vista tiene una URL a la que un servidor podría responder de forma distinta.

Por qué las URL con fragmento o hash son invisibles para el servidor (y para Google)

Como el fragmento nunca llega al servidor (véase más arriba), la History API es la única forma de dar a cada ruta una URL que el servidor pueda servir realmente. Google establece un mínimo al respecto en su documentación básica: “don’t use fragments to load different page content. The following example is a bad practice, because Googlebot can’t reliably resolve the URLs.” (traducción) «No use fragmentos para cargar contenido diferente. El ejemplo siguiente es una práctica incorrecta porque Googlebot no puede resolver las URL de forma fiable». Es la misma recomendación que he escrito en otros sitios: use URL de aspecto normal como /products, no URL con hash como /#/products, porque Google no puede indexar de forma fiable estas últimas.

La obsolescencia de 2015, en breve

La era del hash-bang (#!) terminó con la publicación de Google de 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,” (traducción) «Los tiempos han cambiado. Hoy, siempre que no bloquee el rastreo de sus archivos JavaScript o CSS, por lo general podemos renderizar y entender sus páginas como los navegadores modernos», y “you can use the History API pushState() to ensure accessibility for a wider range of browsers (and our systems).” (traducción) «Puede usar pushState() de la History API para garantizar la accesibilidad para más navegadores y para nuestros sistemas». Un matiz que conviene retener: Google no desindexó al instante los antiguos sitios con hash-bang (“we’ll generally crawl, render, and index the #! URLs” (traducción) «por lo general rastrearemos, renderizaremos e indexaremos las URL con #!»), pero «todavía podemos intentarlo» no es «debería seguir haciéndolo».

Hacer que cada ruta sea indexable de forma independiente

Las URL limpias y el HTML real consiguen que le rastreen. Para que le indexen correctamente, cada ruta necesita sus propias señales.

Etiquetas canónicas por ruta, y la trampa de la “directiva más restrictiva”

Cada ruta necesita su propia declaración rel=canonical en el DOM renderizado. La trampa: si en el HTML sin procesar del contenedor se envía una señal de canonización de marcador de posición (o un noindex) y se supone que JavaScript la sobrescribirá después, puede producirse un conflicto. Google resuelve los conflictos entre la versión sin procesar y la renderizada quedándose con la señal más restrictiva; como ya he dicho antes, Google elegirá las declaraciones más restrictivas entre el HTML y la versión renderizada de una página. Un noindex perdido o una señal de canonización equivocada incrustada en el contenedor pueden suprimir en silencio toda la ruta incluso después de que su JS la “arregle”.

Títulos y metadescripciones por ruta mediante JavaScript

Definirlos con JS está bien; Google lo dice directamente: “You can use JavaScript to set or change the meta description as well as the <title> element.” (traducción) «Puede usar JavaScript para definir o cambiar la metadescripción y el elemento title». El requisito es que acaben en el DOM renderizado que Google evalúa, no que solo aparezcan un instante. Dé a cada página su propio título y descripción, que se actualicen cuando cambie la ruta.

Enlaces <a href> reales entre rutas

Haga que los enlaces entre rutas sean anclas reales con atributos href, no gestores de clic sobre <div>. Google descubre URL extrayendo los href; un <div onClick> que navega mediante el router es invisible para la extracción de enlaces del rastreador. Y no se apoye en el estado del cliente para transportar contenido entre esas navegaciones: el renderizador de Google “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.” (traducción) «No conserva el estado entre cargas: los datos de Local Storage y Session Storage, así como las cookies HTTP, se borran entre cargas».

Generación de sitemaps para rutas de SPA

No existe un formato especial de “sitemap para SPA”: es un problema de inventario

El protocolo de sitemaps no cambia para las SPA. El trabajo real consiste en asegurarse de que cada ruta que incluya se resuelva de forma independiente en HTML real, único y renderizado. Un sitemap con 500 rutas gestionadas en el cliente no vale nada si todas esas rutas devuelven el mismo contenedor. Así que el sitemap depende de su estrategia de renderizado, no la sustituye.

Cómo gestionar rutas dinámicas o parametrizadas (/product/:id)

En aplicaciones con rutas parametrizadas no se puede mantener la lista a mano. El generador del sitemap tiene que ejecutarse sobre la misma fuente de datos que usa la aplicación (un script de compilación o un endpoint de servidor que enumere cada id) para que el sitemap y la aplicación nunca se desincronicen. Esas URL enumeradas solo merece la pena incluirlas cuando estén servidas con SSR o prerenderizadas.

Mantener el sitemap sincronizado con lo que el servidor puede resolver

Regenere el sitemap como parte de su compilación o con una periodicidad ligada a su fuente de contenido. Una ruta que devuelve un 404 (o peor, un soft 404 con 200) pero figura en su sitemap es una señal de presupuesto de rastreo y de calidad que no le interesa dar.

Probar lo que Google ve realmente

No se limite a comprobar una sola URL. La esencia del problema de las SPA es que las rutas pueden diferir en el navegador pero no en la red, así que pruebe varias rutas a nivel de HTML sin procesar:

  • Obtenga el HTML sin procesar de cada ruta con curl (con un user-agent de Googlebot cuando proceda) y confirme que el contenido es único por URL, no el contenedor compartido.
  • URL Inspection en Search Console: compare el HTML rastreado y el renderizado de varias rutas, y confirme que el título, la descripción y la etiqueta canónica de cada ruta están presentes.
  • Confirme que las rutas de error devuelven la señal correcta: una vista de “no encontrado” debería o bien redirigir a un estado de error real, o bien llevar noindex en el DOM renderizado.

Mitos habituales sobre el SPA SEO

  • “Google no puede indexar SPA en absoluto”. Obsoleto. Google ejecuta Chromium en versión permanentemente actualizada y por lo general renderiza el contenido de las SPA. Los riesgos reales son concretos: errores soft 404, enrutamiento por fragmentos, tiempos de espera de renderizado y rastreadores distintos de Google (Bing con menos fiabilidad, la mayoría de rastreadores de IA directamente no).
  • “Añadir la History API arregla el SEO de una SPA”. Solo arregla la direccionabilidad. Si el servidor sigue devolviendo el mismo contenedor para cada ruta, Google todavía tiene que ejecutar JS para ver algo.
  • “El enrutamiento por fragmentos sigue funcionando como alternativa”. Obsoleto desde 2015 y desaconsejado desde entonces.
  • “El renderizado en el cliente es una penalización de posicionamiento”. No existe ninguna penalización directa por CSR. El daño es indirecto: renderizado fallido o tardío, errores soft 404 y agrupación de duplicados por tiempos de espera de renderizado reducen lo que se indexa.
  • “Prerenderizar para los bots es cloaking”. No lo es cuando el contenido coincide con lo que los usuarios acaban viendo; solo cambia el cuándo y dónde del renderizado, no el contenido.
  • “Necesita un generador de sitemaps especial para SPA”. No existe ningún formato especial; el trabajo consiste en hacer que cada ruta incluida se resuelva en HTML real.

Preguntas frecuentes

¿Puede Google indexar una aplicación de página única? Sí, si cada ruta se resuelve en HTML real y único (mediante SSR o prerenderizado) en una URL real. Las SPA sin más, exclusivamente de cliente, a menudo no lo hacen.

¿Necesito SSR, o el renderizado en el cliente resulta aceptable alguna vez? El CSR puede servir para contenido que no necesita posicionarse, pero para cualquier cosa que quiera ver indexada de forma fiable, prerenderícela o use SSR; no apueste a que el renderizador ejecute su JS a tiempo.

¿Es malo para el SEO el enrutamiento por hash (#!)? Sí: el fragmento nunca llega al servidor, así que Google no puede resolver esas URL de forma fiable. Use la History API.

¿Necesita cada ruta su propia señal de canonización? Sí, en el DOM renderizado, y asegúrese de que no se envíe nada más restrictivo (un noindex perdido o una señal de canonización equivocada) en el contenedor sin procesar.

¿Es cloaking un servicio de prerenderizado? No, siempre que el contenido servido a los bots coincida con el que ven los usuarios.

¿Debería usar un metaframework en lugar de construir el enrutamiento por mi cuenta? Para un desarrollo nuevo, normalmente sí: Next.js, Nuxt, Angular con SSR o SvelteKit le dan SSR/SSG y metadatos por ruta sin costo adicional.

¿Por qué mi SPA devuelve 200 en páginas que no existen? Porque el router en el cliente mantiene el estado 200 original en las navegaciones virtuales. Soluciónelo con una redirección por JS a un estado de error real o con un noindex renderizado.

Add an expert note

Pin an expert quote

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