12k
All articles

Construcción de enrutamiento del lado del cliente con la History API

Crea un router vanilla con History API, pushState y popstate, parámetros dinámicos, URLs SEO y soluciones para 404 y riesgos XSS.

OpenReplay Team
OpenReplay Team
Construcción de enrutamiento del lado del cliente con la History API

El enrutamiento del lado del cliente intercambia vistas actualizando la URL y volviendo a renderizar en JavaScript, sin ida y vuelta al servidor (solo se contacta al servidor en la primera carga o en una recarga forzada).

Si alguna vez has puesto en producción una single-page app, conoces ese momento: todo funciona en local, y entonces un compañero de equipo pulsa el botón Atrás y la URL cambia mientras la página se queda ahí, inmóvil. Se arregla en cinco minutos una vez sabes dónde mirar, y le pasa a casi todo el mundo la primera vez.

Frameworks como React Router y Vue Router envuelven este comportamiento en componentes y hooks, pero por debajo todos accionan la misma primitiva del navegador: la History API. Este artículo construye un router vanilla mínimo, correcto y desplegable en unas 50 líneas, explica el reparto de responsabilidades entre pushState y popstate, y cubre los dos escollos (el 404 en el despliegue y el riesgo de inyección XSS) que separan un juguete de algo que puedes llevar a producción.

Puntos clave

  • En modo History, history.pushState(state, '', url) cambia la URL sin recargar la página, pero no dispara un evento popstate. Debes llamar tú mismo a tu función de renderizado después de pushState y, por separado, escuchar popstate para gestionar Atrás y Adelante.
  • Las URLs limpias del modo History, como /dashboard, son mejores para SEO y para compartir, pero requieren que el servidor reescriba cualquier ruta desconocida hacia index.html, o una visita directa o una recarga devolverá un 404.
  • El segundo argumento de pushState es un parámetro title heredado que los navegadores ignoran; no se puede omitir, así que pasa siempre una cadena vacía.
  • Inyectar una vista con innerHTML es un vector de XSS para cualquier dato no confiable interpolado, y descarta silenciosamente los event listeners del marcado inyectado. Construye nodos con createElement, sanitiza, o usa una librería de plantillas, y añade el comportamiento mediante delegación de eventos.
  • La Navigation API alcanzó el estado Baseline Newly available en enero de 2026 y es la sucesora emergente de este patrón, pero la History API sigue siendo la base con mayor compatibilidad.

¿Cuál es la diferencia entre el modo hash y el modo History?

El enrutamiento del lado del cliente actualiza la vista cuando cambia la URL, sin una recarga completa de la página. Hay dos formas de cambiar la URL sin navegar: modo hash y modo History. El modo hash codifica la ruta después de un # (/app#/users). El fragmento que va después del hash nunca se envía al servidor, por lo que la navegación basada en hash es puramente del lado del cliente y no necesita ninguna configuración de servidor; se escucha el evento hashchange. El modo History produce rutas limpias (/users) usando la History API y escucha popstate.

Modo hashModo History
Forma de la URL/app#/users/users
Evento de cambiohashchangepopstate
Configuración de servidorNingunaReescribir todas las rutas a index.html
Recarga / enlace profundoSiempre funciona404 sin reescritura
SEO / URLs compartiblesMás débilMás limpias, preferidas

El modo History es la opción por defecto por sus URLs limpias e indexables, y es el que se construye en este artículo. Su único coste es que necesita soporte del servidor, algo que se cubre más adelante.

Las primitivas de la History API que realmente necesitas

Tres primitivas sostienen un router en modo History. history.pushState(state, unused, url) añade una entrada a la pila del historial de sesión y cambia la barra de direcciones; history.replaceState hace lo mismo, pero sobrescribe la entrada actual en lugar de añadir una nueva. location.pathname lee la ruta actual para que puedas hacer coincidir una ruta. El evento popstate se dispara cuando el usuario pulsa Atrás o Adelante.

La regla crítica: pushState y replaceState no disparan popstate. Debes llamar tú mismo a tu función de renderizado después de cada pushState y, por separado, registrar un listener de popstate para que Atrás y Adelante del navegador vuelvan a renderizar la vista. Si te falta el listener, la URL cambia al pulsar Atrás mientras el DOM permanece congelado: un bug invisible en una revisión de código pero evidente en cuanto ves un session replay de la aplicación.

Hay dos detalles más que importan. El argumento del medio es un valor title heredado que los navegadores ignoran, y no puede omitirse, así que pasa una cadena vacía. La url debe ser del mismo origen: el navegador no la carga cuando llamas a pushState, y la llamada lanza una excepción si el origen difiere del de la página actual. El propio popstate es antiguo y fiable, disponible en todos los navegadores desde julio de 2015.

¿Cómo se construye un router mínimo?

Un router en modo History funcional necesita cinco partes: un mapa de rutas, una función resolve que lea location.pathname y haga coincidir una ruta con un fallback 404, delegación de clics sobre un atributo data-link, un listener de popstate y un renderizado inicial. Este es el archivo completo:

function escapeHtml(str) {
  return String(str).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}

const routes = {
  '/':          { view: () => '<h1>Home</h1><a href="/users/42" data-link>User 42</a>', title: 'Home' },
  '/users/:id': { view: (p) => `<h1>User ${escapeHtml(p.id)}</h1>`, title: 'User' },
  '/404':       { view: () => '<h1>404 — Not found</h1>', title: 'Not found' },
};

const app = document.getElementById('app');

function match(pathname) {
  for (const pattern of Object.keys(routes)) {
    const pParts = pattern.split('/');
    const uParts = pathname.split('/');
    if (pParts.length !== uParts.length) continue;
    const params = {};
    const ok = pParts.every((part, i) => {
      if (part.startsWith(':')) { params[part.slice(1)] = decodeURIComponent(uParts[i]); return true; }
      return part === uParts[i];
    });
    if (ok) return { route: routes[pattern], params };
  }
  return { route: routes['/404'], params: {} };
}

function resolve() {
  const { route, params } = match(location.pathname);
  app.innerHTML = route.view(params);
  document.title = route.title;
}

function navigate(url) {
  history.pushState({}, '', url);   // '' is the ignored legacy title
  resolve();                        // pushState does NOT fire popstate — render manually
}

document.addEventListener('click', (e) => {
  const link = e.target.closest('[data-link]');   // robust: works on nested markup
  if (!link) return;
  e.preventDefault();
  navigate(link.getAttribute('href'));
});

window.addEventListener('popstate', resolve);      // Back / Forward

history.replaceState({}, '', location.pathname);    // seed the initial entry
resolve();                                          // render on first paint

La delegación de eventos mediante e.target.closest('[data-link]') es deliberada. Sobrevive a clics sobre nodos hijos (un icono dentro de un enlace) y sigue funcionando cuando las vistas se vuelven a renderizar, a diferencia de asignar listeners a cada elemento o leer e.target.attributes[0], que depende del orden de los atributos y se rompe con marcado anidado.

Un paso más: parámetros dinámicos, títulos y la entrada inicial

La función match anterior ya gestiona segmentos dinámicos. Un patrón como /users/:id se divide en partes; cualquier segmento que empiece por : captura el segmento de ruta correspondiente en un objeto params, de modo que /users/42 se resuelve con { id: '42' }. Los segmentos sin : deben coincidir exactamente, y una diferencia de longitud descarta el patrón, lo que evita que /users coincida con /users/42. Establecer document.title dentro de resolve actualiza la pestaña y la etiqueta del historial en cada navegación.

Queda una corrección más que pertenece al router. El navegador crea tu primera entrada del historial a partir de una carga de página normal, por lo que no hay nada almacenado en ella, y la guía de MDN sobre el trabajo con la History API recomienda llamar a history.replaceState() al inicio para adjuntar estado a esa entrada. Haz eso y la primera pulsación de Atrás podrá restaurar tu vista inicial. Esa es la última línea replaceState del router.

Los dos escollos que separan un juguete de un router real

Despliegue. Las URLs limpias del modo History requieren que el servidor reescriba cualquier ruta desconocida hacia index.html, o una visita directa o una recarga de /users/42 devolverá un 404. No hay solución en JavaScript, porque la petición llega al servidor antes de que se cargue tu bundle. Configura la reescritura una vez por host. Express 5 cambió su sintaxis de coincidencia de rutas: ahora todo comodín debe tener nombre, así que el antiguo catch-all app.get('*') lanza un error “Missing parameter name” al arrancar. Usa el comodín con nombre entre llaves, que coincide tanto con la ruta raíz como con todo lo que hay por debajo:

// Express 5.x
app.get('/{*splat}', (req, res) => res.sendFile(__dirname + '/public/index.html'));
// Express 4.x used: app.get('*', ...)
# Nginx
location / { try_files $uri $uri/ /index.html; }
# Netlify — _redirects
/*  /index.html  200
// Vercel — vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

Un 404 al recargar un enlace profundo compartido es otro fallo que se lee bien en el código pero que se manifiesta con claridad cuando ves una sesión real aterrizar en una página en blanco.

Seguridad. Inyectar una vista con innerHTML es un vector de XSS siempre que se interpolen datos no confiables (el ${p.id} anterior viene directamente de la URL), y descarta silenciosamente los event listeners del marcado inyectado. Escapa los valores interpolados (la llamada a escapeHtml anterior), construye nodos con document.createElement, o usa una librería de plantillas como lit-html, y añade el comportamiento mediante delegación de eventos sobre un padre estable en lugar de sobre los nodos inyectados. Las cadenas de plantilla estáticas escritas por el desarrollador y sin interpolación no constituyen por sí mismas una inyección; el riesgo está en los datos no confiables que insertas.

Hacia dónde va la plataforma: la Navigation API

La Navigation API alcanzó el estado Baseline Newly available en enero de 2026, el mes en que Firefox 147 añadió soporte, y es la sucesora emergente de este patrón. En lugar de conectar pushState, un listener de popstate y un manejador de clics por separado, registras un único listener de navigate. Se ejecuta en cada navegación que la página puede ver, sea cual sea su origen, y llamar a event.intercept() dentro de ese listener deja la barra de direcciones y la pila del historial en manos del navegador. Una de las carencias que aborda es que popstate no se dispara con llamadas programáticas a pushState/replaceState: exactamente la fricción que este router sortea. Hasta que sea el mínimo común de compatibilidad para tus navegadores objetivo, la History API sigue siendo la base con mayor soporte y la forma más clara de entender qué hace realmente un router.

Ahora tienes un router en modo History ejecutable: rutas, coincidencia de parámetros, clics delegados, gestión correcta de popstate, una entrada inicial inicializada y ambas correcciones para producción. El siguiente paso concreto es configurar la reescritura del servidor para tu host antes de desplegar, para que los enlaces profundos sobrevivan a una recarga.

Preguntas frecuentes

¿Por qué el botón Atrás cambia la URL pero deja la página sin cambios en mi SPA?

Porque pushState y replaceState no disparan un evento popstate, así que si solo renderizas dentro de tu manejador de clics y nunca registras un listener de popstate, Atrás y Adelante actualizan la barra de direcciones sin volver a renderizar. La solución es un window.addEventListener('popstate', resolve) independiente que ejecute tu función de renderizado cada vez que el navegador se desplace por el historial. Observa un session replay y verás un cambio de URL sin cambio en el DOM.

¿Cuál es la diferencia entre pushState y replaceState?

pushState añade una nueva entrada a la pila del historial de sesión, de modo que la vista anterior sigue siendo accesible con el botón Atrás. replaceState sobrescribe la entrada actual en lugar de añadir una nueva, por lo que no crea un nuevo destino para Atrás. Usa pushState para la navegación normal y replaceState para inicializar la entrada de la página inicial al arrancar o para corregir la URL actual sin contaminar el historial. Ambos comparten la misma firma (state, unused, url) y ninguno dispara popstate.

¿El enrutamiento en modo hash necesita alguna configuración de servidor?

No. El fragmento que va después del hash, como el '/users' en '/app#/users', nunca se envía al servidor, por lo que la navegación basada en hash es puramente del lado del cliente y funciona en cualquier host estático sin ninguna regla de reescritura. Las recargas y los enlaces profundos siempre se resuelven porque el servidor solo llega a ver '/app'. El modo History es el compromiso: produce URLs más limpias pero requiere que el servidor reescriba cualquier ruta desconocida hacia index.html o una recarga devolverá un 404.

¿Debería seguir aprendiendo la History API ahora que la Navigation API es Baseline?

Sí. La Navigation API alcanzó el estado Baseline Newly available en enero de 2026 y es la sucesora emergente, sustituyendo el pushState manual, popstate y la interceptación de clics por un único evento navigate y event.intercept(). Pero la History API sigue siendo la base con mayor compatibilidad, funciona en navegadores antiguos donde la Navigation API no lo hace, y es lo que frameworks como React Router y Vue Router siguen accionando por debajo. Aprenderla es la forma más clara de entender qué hace realmente cualquier router.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.