Cómo corregir los errores «Cannot GET» tras desplegar una SPA
Corrige los errores Cannot GET y 404 de una SPA tras el despliegue con reescrituras de servidor para Nginx, Apache, Netlify, Vercel y S3 CloudFront.
Un error «Cannot GET /route» o 404 después de desplegar una aplicación de página única (SPA) suele ser un problema de configuración del servidor más que un fallo de enrutamiento. La solución consiste en hacer que el servidor devuelva index.html para cualquier ruta de solicitud que no coincida con un archivo real.
El patrón es conocido. La build se publica, todas las páginas funcionan al navegar con clics y, de repente, alguien recarga /dashboard, o abre un enlace compartido a /orders/42, y obtiene un 404 escueto. Normalmente el router está bien, y también la build. Al servidor se le pidió un archivo que no existe.
Este artículo explica por qué el error solo aparece en navegaciones completas (hard navigations) y luego cubre la solución y la configuración para Nginx, Apache, Netlify, Vercel y S3 detrás de CloudFront, junto con el efecto secundario que hay que gestionar después.
Puntos clave
- Un 404 en una SPA al recargar ocurre porque la solicitud llega al servidor, que busca un archivo real en esa ruta y solo encuentra
index.htmlen la raíz. - Los servidores de desarrollo local ocultan el fallo porque ya aplican un fallback a
index.htmlpara las rutas sin coincidencia. - La solución es una reescritura (rewrite), no una redirección: servir
index.htmlcon un estado 200 para que la URL permanezca intacta y el router pueda leerla. - En S3, el enfoque del documento de error mantiene el estado 404; lo que devuelve un 200 es una respuesta de error personalizada de CloudFront que mapea tanto 403 como 404 a
/index.html. - Un fallback general (catch-all) implica que las URL incorrectas devuelvan 200, por lo que la aplicación necesita su propia ruta comodín que renderice una vista de «no encontrado».
¿Cuándo aparece el error «Cannot GET»?
El error aparece solo en navegaciones completas: al recargar la página, al escribir una URL en la barra de direcciones o al abrir un enlace profundo compartido en una pestaña nueva. La navegación dentro de la aplicación sigue funcionando, porque una vez que la aplicación se ha cargado, el router cambia de vista enteramente en el navegador sin contactar con el servidor. El mensaje exacto varía según el host: los servidores basados en Express muestran «Cannot GET /route», mientras que los hosts estáticos devuelven su página 404.
Esta es también la razón por la que el fallo sobrevive al QA. Las repeticiones de sesión (session replays) de una SPA recién desplegada muestran la ruptura en las navegaciones completas —una recarga o un enlace abierto externamente—, nunca durante los clics dentro de la aplicación, de modo que las pruebas que solo navegan con clics por la aplicación en ejecución pasan sin problemas mientras los usuarios reales se topan con el 404.
¿Por qué un 404 al recargar una SPA es un problema del servidor?
Un servidor estático asocia cada ruta de solicitud a un archivo en disco. Una build de SPA produce un único archivo HTML, index.html, más los recursos JS y CSS, por lo que una solicitud directa a /dashboard no encuentra ningún archivo en esa ruta y el servidor responde correctamente con un 404. React Router, Vue Router y SvelteKit configurado como aplicación de página única se topan con esto de forma idéntica, porque el framework es irrelevante: las rutas existen únicamente en JavaScript que todavía no se ha cargado.
El error nunca aparece en desarrollo local porque la mayoría de los servidores de desarrollo para SPA vienen con el fallback ya activado: cualquier ruta que no coincida con un archivo recibe index.html automáticamente. Tu configuración local estaba haciendo discretamente lo que tu servidor de producción no hace.
¿Cuál es la solución para un error «Cannot GET»?
Configura el servidor para que sirva index.html en cualquier ruta de solicitud que no coincida con un archivo existente, de modo que la aplicación se cargue y su router renderice la vista correspondiente a esa URL. Esto debe ser una reescritura que devuelva index.html con un estado 200, no una redirección: una redirección cambiaría la URL en la barra de direcciones, y el router necesita la ruta original intacta.
| Host | Dónde vive la configuración | Mecanismo |
|---|---|---|
| Nginx | bloque server | try_files |
| Apache | vhost o .htaccess | FallbackResource |
| Netlify | _redirects o netlify.toml | regla de reescritura con estado 200 |
| Vercel | vercel.json | array rewrites |
| S3 + CloudFront | configuración de sitio web del bucket + distribución | documento de error + respuesta de error personalizada |
Si realmente no puedes tocar el servidor, el enrutamiento basado en hash esquiva todo esto porque el fragmento nunca sale del navegador, pero convierte permanentemente cada URL en /#/about, así que trátalo como último recurso.
Nginx y Apache
Nginx y Apache expresan cada uno el fallback de la SPA como una única directiva en la configuración del servidor. Para Nginx, añade un fallback con try_files en la ubicación raíz. Busca la ruta de la solicitud como archivo, luego como directorio y, cuando no encuentra ninguno, sirve /index.html internamente con un 200:
server {
listen 80;
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
Para Apache, una sola directiva de mod_dir hace el mismo trabajo. Los archivos reales se siguen sirviendo tal cual, y todo lo demás cae en el fallback:
FallbackResource /index.html
Si la aplicación reside bajo una subruta, inclúyela: FallbackResource /app/index.html. El equivalente más antiguo con mod_rewrite sigue funcionando en .htaccess:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
Netlify y Vercel
En Netlify, una regla de redirección con estado 200 se convierte en una reescritura: el navegador sigue mostrando la ruta que solicitó el visitante y el contenido de index.html vuelve en la respuesta. Puedes añadir un archivo _redirects de una línea:
/* /index.html 200
o su equivalente en netlify.toml:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
El archivo _redirects debe acabar dentro del directorio de publicación, así que asegúrate de que tu build lo copie en la carpeta de salida; netlify.toml vive en la raíz del repositorio. Una regla con splat no se apropiará de una ruta que tenga detrás un archivo real, por lo que los recursos JS y CSS se seguirán cargando.
Para Vercel, añade una entrada rewrites a vercel.json:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Es preferible el destino explícito /index.html frente a /: ambos resuelven al mismo archivo en Vercel, pero la forma explícita declara qué se sirve realmente y se traslada como modelo mental a cualquier otro host. Una excepción: con cleanUrls: true activado, el destino no puede llevar la extensión .html, y Vercel mapea index.html a la raíz del sitio, así que establece el destino como /.
S3 y CloudFront
Corregir un 404 de SPA en S3 requiere dos piezas de configuración, porque el ajuste del bucket por sí solo conserva el estado de error. En el alojamiento de sitios web estáticos de S3, establece index.html como documento de índice y como documento de error:
aws s3 website s3://your-bucket \
--index-document index.html \
--error-document index.html
Esto sirve la aplicación para rutas desconocidas, pero conserva el estado de error: el navegador recibe index.html con un código 404. Para devolver un 200, añade respuestas de error personalizadas de CloudFront que mapeen tanto 403 como 404 a /index.html con un código de respuesta 200. El mapeo de 403 importa porque una distribución que usa el endpoint REST de S3 como origen recibe 403 Access Denied, no 404, para claves que no existen. En términos de Terraform:
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
Un peligro a tener en cuenta: las respuestas de error personalizadas se aplican a toda la distribución, por lo que si haces proxy de /api/* a través de la misma distribución, los 403 y 404 de la API también volverán como index.html.
El coste: tus 404 reales desaparecen
Un fallback general tiene un precio: las URL genuinamente incorrectas ahora devuelven index.html con un 200 en lugar de un 404 real. El servidor ya no puede distinguir /orders/42 de /ordersss/42, así que la aplicación debe definir su propia ruta comodín que renderice una vista de «no encontrado». Todos los routers tienen una forma de expresarlo; en React Router se ve así:
<Route path="*" element={<NotFound />} />
Ten en cuenta que este es un 404 renderizado en el cliente: el estado HTTP sigue siendo 200, lo cual importa si te preocupa cómo clasifican los rastreadores esas páginas.
Conclusión
El 404 al recargar es el servidor haciendo exactamente lo que hacen los servidores estáticos, y la solución es una única regla aplicada en el dialecto de tu host: reescribir toda ruta que no sea un archivo hacia index.html con un 200. Añade el fragmento correspondiente a tu host, vuelve a desplegar, haz una recarga forzada de una ruta profunda para confirmarlo y luego añade la ruta comodín de «no encontrado» para que las URL incorrectas sigan indicando a los usuarios que están perdidos.
Preguntas frecuentes
¿Funciona la solución del fallback para SPA en GitHub Pages?
No. GitHub Pages no admite reescrituras del lado del servidor, por lo que no hay forma de configurar una regla de fallback a index.html. La solución alternativa estándar es una página 404.html personalizada que contenga un script que redirija a index.html preservando la ruta solicitada, que el router restaura después de la carga. GitHub sigue sirviendo esa página con estado 404. La otra opción es el enrutamiento basado en hash, que nunca envía la ruta al servidor.
¿Tienen este problema los frameworks con renderizado en servidor como Next.js o Nuxt?
No cuando ejecutan su propio servidor. Un framework con renderizado en servidor gestiona cada ruta en el servidor, por lo que una recarga o un enlace profundo devuelve HTML renderizado directamente. El problema del 404 al recargar solo afecta a las builds estáticas de página única, donde las rutas existen únicamente en JavaScript del lado del cliente. Una aplicación exportada estáticamente desde uno de estos frameworks puede seguir topándose con él cuando una ruta solicitada no tiene un archivo HTML prerenderizado en disco.
¿Reescribir todas las rutas a index.html romperá mis recursos JS y CSS?
No. Cada mecanismo busca un archivo real antes de recurrir al fallback: try_files de Nginx prueba primero el URI de la solicitud, FallbackResource de Apache deja intactas las solicitudes de archivos reales, y una reescritura con splat en Netlify no se apropiará de una ruta existente a menos que lo fuerces con 200!. Si los recursos siguen fallando después de añadir el fallback, la causa habitual son rutas de recursos relativas que se resuelven dentro de una ruta anidada, por lo que el navegador los solicita desde el directorio equivocado y recibe index.html en su lugar.
¿Servir index.html con estado 200 perjudica el SEO?
Puede hacerlo. Cuando una URL inexistente devuelve un estado 200 con contenido de «no encontrado», Google puede clasificarla como un «soft 404» y eliminarla del índice, porque el código de estado ya no distingue las páginas reales de las URL incorrectas. Si la indexación en buscadores importa para tus rutas, el prerenderizado o el renderizado en servidor restauran los códigos de estado correctos por ruta. En aplicaciones detrás de un inicio de sesión, los rastreadores nunca ven las rutas, por lo que el compromiso es irrelevante.