Error 404 al recargar una SPA de React/Vue después del deploy

El servidor busca un archivo en la URL que recargaste, no lo encuentra y devuelve 404. Arréglalo haciendo que el servidor responda las rutas desconocidas con index.html, para que el router del cliente tome el control.

Actualizado el 6 min de lectura

Objetivos de aprendizaje

Después de leer este artículo podrás:

  • Entender por qué recargar rompe rutas que funcionaban al navegar
  • Aplicar el fallback a index.html en nginx, Express, Apache o Netlify
  • Evitar que se oculten las páginas 404 reales y los assets que faltan
En esta página

Haces el deploy de tu app de React o Vue. La página de inicio carga, los enlaces funcionan, y entonces recargas en /dashboard o pegas un enlace profundo y el servidor te devuelve un 404. En local nunca pasó.

La app está bien. El servidor simplemente no sabe que /dashboard es una pantalla de tu JavaScript y no un archivo. A continuación, el mecanismo y la solución para los servidores más comunes.

CLIC EN UN ENLACERECARGAR LA PÁGINANavegadorRoutercambia la pantalla/ → /dashboardServidorno recibe nadaNavegadorrecarga /dashboardGET /dashboardServidorbusca el archivo404sin fallbackindex.htmlcon fallback
Hacer clic en un enlace no llega al servidor; recargar sí, y sin fallback a index.html responde 404.

¿Por qué recargar devuelve 404 si la navegación funciona?#

Una aplicación de una sola página (SPA) entrega un único archivo HTML, normalmente index.html, más los bundles de JavaScript. Cuando haces clic en un enlace, el router (React Router, Vue Router) llama a la History API y cambia la vista. La URL cambia, pero el navegador no envía ninguna petición.

Recargar es distinto. El navegador pide al servidor exactamente esa ruta:

http
GET /dashboard HTTP/1.1
Host: example.com

Un hosting estático asocia URLs con archivos. En la salida de tu build no existe ningún archivo ni carpeta dashboard, así que responde:

text
404 Not Found

El servidor de desarrollo lo oculta porque ya hace fallback a index.html para las rutas desconocidas. La documentación de Vite también aclara que vite preview solo sirve para previsualizar el build en local y no está pensado como servidor de producción, así que una vista previa que funciona demuestra poco sobre tu hosting.

¿Cómo lo arreglo en el servidor?#

Configura el servidor para que devuelva index.html en cualquier ruta que no sea un archivo existente. Así el navegador carga la app, el router lee la URL y renderiza la pantalla correcta. La documentación de Vue Router describe esto como la configuración de servidor necesaria para el modo history.

La regla tiene dos partes, en este orden:

  1. Si la petición coincide con un archivo real (un script, una hoja de estilos, una imagen), sírvelo.
  2. Si no, sirve index.html y deja que decida el router del cliente.

Esta es esa regla para los servidores más comunes.

nginx#

Usa try_files en el bloque location /:

nginx
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }
}

nginx prueba el archivo y luego el directorio. Si ninguno existe, se aplica el último parámetro, que aquí es una redirección interna a /index.html. Recarga nginx después de editar.

Express u otro servidor Node.js#

Sirve la carpeta del build y luego añade una ruta comodín que envíe index.html. Este ejemplo usa Express 5, que exige un comodín con nombre:

js
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";

const dist = path.join(path.dirname(fileURLToPath(import.meta.url)), "dist");
const app = express();

app.use(express.static(dist));
app.get("/{*splat}", (req, res) => {
  res.sendFile(path.join(dist, "index.html"));
});

app.listen(process.env.PORT || 3000);

En Express 4 el comodín es app.get("*", ...). En Express 5 el comodín debe tener nombre, y /{*splat} también coincide con la ruta raíz, como explica la guía de migración de Express. Registra el comodín después de express.static y de las rutas de tu API, o se las tragará.

Apache#

Pon esto en un archivo .htaccess junto a index.html, con mod_rewrite activado:

apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>

Las dos condiciones saltan los archivos y directorios existentes, así que solo las rutas desconocidas caen en el fallback.

Netlify y Firebase Hosting#

En Netlify, añade un archivo _redirects a la carpeta publicada (en una app de Vite, ponlo en public/ para que se copie a dist/):

text
/*  /index.html  200

El 200 lo convierte en un rewrite, no en una redirección, así que la URL no cambia. Por defecto Netlify no oculta las URLs que coinciden con un archivo real, así que los assets se siguen sirviendo con normalidad.

En Firebase Hosting, añade un rewrite en firebase.json (los archivos que existen se siguen sirviendo primero):

json
{
  "hosting": {
    "public": "dist",
    "rewrites": [
      { "source": "**", "destination": "/index.html" }
    ]
  }
}

Otras plataformas tienen una opción equivalente, a menudo llamada "SPA mode", "rewrites" o "fallback". Busca esas palabras en la documentación de tu hosting.

¿Por qué ahora veo una página en blanco o un error de tipo MIME?#

Un fallback demasiado amplio provoca el problema contrario. Si el navegador pide /assets/app.abc123.js y el archivo no existe, el servidor devuelve index.html con estado 200, y el navegador recibe HTML donde esperaba JavaScript. Con scripts de módulo, que es lo que genera Vite, Chrome muestra el primer error de abajo; con scripts clásicos, el segundo:

text
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html".
Uncaught SyntaxError: Unexpected token '<'

Causas comunes:

  • Ruta base incorrecta. La app se sirve desde una subruta (/app/) pero se compiló para /. Ajusta base en Vite para que coincida.
  • URLs relativas de assets en rutas profundas. En /users/42, un src="assets/app.js" se resuelve como /users/assets/app.js. Usa rutas absolutas que empiecen por /.
  • Deploy desactualizado. El index.html en caché apunta a archivos con hash del build anterior que ya no existen. Sube los archivos nuevos junto con el nuevo index.html y evita una caché larga en index.html.

Abre la pestaña Network y revisa la petición que falla. Si un archivo .js vuelve como text/html, el fallback está atrapando un asset que falta.

¿Cómo mantengo una página 404 real?#

Con un fallback que lo atrapa todo, el servidor nunca devuelve 404 para las páginas. La documentación de Vue Router señala esta consecuencia y sugiere una ruta comodín dentro de la app:

js
// Vue Router
{ path: "/:pathMatch(.*)", component: NotFound }
jsx
// React Router
<Route path="*" element={<NotFound />} />

El usuario ve una pantalla de no encontrado, pero el servidor sigue respondiendo 200, y los buscadores pueden tratar esas páginas como soft 404. Si eso te importa, añade una metaetiqueta noindex en la pantalla de no encontrado, o pasa a renderizado en el servidor o prerenderizado para que el servidor pueda devolver un 404 real. Consulta SSR vs SSG vs estático para comparar las opciones.

¿Y HashRouter?#

HashRouter (React) y createWebHashHistory (Vue) ponen la ruta después del #, como en /#/dashboard. Los navegadores nunca envían el fragmento al servidor, así que cada recarga pide / y no hace falta configurar nada en el servidor. Es una opción válida en hostings donde no puedes añadir rewrites. La desventaja es que las URLs se ven peor y el servidor no puede ver ni indexar las rutas individuales.

Cómo hacerlo en Square Cloud#

Square Cloud es hosting self-service, así que tú decides cómo se sirve el build. En el runtime de sitios estáticos, el fallback es un solo paso: renombra el index.html del build a vite.html. El runtime responde con vite.html a toda ruta que no sea un archivo, así que cualquier enlace profundo carga la app. Sin vite.html, las rutas desconocidas reciben tu 404.html.

Si prefieres controlar el enrutamiento tú mismo, usa el runtime de Node.js con un servidor Express como el de arriba (con express en tu package.json). Las dos opciones están en el tutorial de React de la documentación. Una aplicación web necesita al menos 512 MB de RAM del pool compartido de tu plan y recibe un subdominio HTTPS *.squareweb.app. Puedes hacer el deploy subiendo un ZIP en el panel, con la CLI o desde GitHub.

Después del deploy, abre un enlace profundo directamente y recárgalo para confirmar la solución. El camino completo del build a la URL publicada está en cómo hospedar una app de React, y los recursos para sitios están en la página de Sites.

Hosting de sitios web

Sitios estáticos y SSR con TLS automático y CDN global

Más información

Preguntas frecuentes

¿Por qué mi SPA funciona en la página de inicio pero da 404 al recargar?

Al hacer clic en un enlace, la URL cambia en el navegador sin ninguna petición, así que el router se encarga de ella. Al recargar se envía una petición real a esa ruta, y un servidor estático no tiene ningún archivo ahí.

¿HashRouter lo soluciona?

Sí. La ruta va después del # en la URL, que el navegador nunca envía al servidor, así que cada recarga pide el mismo index.html. El precio son URLs más feas, y el servidor no puede ver la ruta.

¿El fallback debe devolver estado 200 o 404?

Para una SPA renderizada en el cliente, 200 con index.html, porque es la app la que decide qué mostrar. Muestra una pantalla de no encontrado dentro de la app para las rutas desconocidas.

¿Por qué veo una página en blanco o un error de tipo MIME en lugar de un 404?

Un fallback que lo atrapa todo también responde con index.html a las peticiones de archivos JavaScript o CSS que faltan. El navegador recibe HTML donde esperaba un script. Revisa las rutas de los assets y la opción base.

Siguiente artículoHosting de sitios vs hosting de aplicaciones (Node, Python, bots)El hosting de sitios entrega archivos listos; el hosting de aplicaciones mantiene un proceso en marcha. Mira la diferencia, la tabla y cuál elegir en cada proyecto.

Más en Sitios web

Ver los 20 artículos