12k
All articles

Créer un routage côté client avec l'History API

Construisez un routeur vanilla avec History API, pushState et popstate, paramètres dynamiques, URL SEO, et correctifs pour 404 et XSS.

OpenReplay Team
OpenReplay Team
Créer un routage côté client avec l'History API

Le routage côté client remplace les vues en mettant à jour l’URL et en réeffectuant le rendu en JavaScript, sans aucun aller-retour vers le serveur (le serveur n’est contacté qu’au tout premier chargement ou lors d’un rafraîchissement forcé).

Si vous avez déjà livré une single-page app, vous connaissez ce moment : tout fonctionne en local, puis un collègue clique sur le bouton Retour et l’URL change alors que la page, elle, reste désespérément immobile. Il faut cinq minutes pour corriger cela une fois qu’on sait où chercher, et presque tout le monde tombe dans le piège la première fois.

Des frameworks comme React Router et Vue Router encapsulent ce comportement dans des composants et des hooks, mais en dessous, ils pilotent tous la même primitive du navigateur : l’History API. Cet article construit un routeur vanilla minimal, correct et déployable en une cinquantaine de lignes, explique la répartition des rôles entre pushState et popstate, et couvre les deux pièges (le 404 au déploiement et le risque d’injection XSS) qui font la différence entre un jouet et quelque chose que vous pouvez mettre en production.

Points clés

  • En mode History, history.pushState(state, '', url) modifie l’URL sans recharger la page, mais il ne déclenche pas d’événement popstate. C’est à vous d’appeler votre fonction de rendu après pushState, et d’écouter séparément popstate pour gérer les boutons Retour et Suivant.
  • Les URL propres du mode History (/dashboard) sont préférables pour le SEO et le partage, mais elles exigent que le serveur réécrive tout chemin inconnu vers index.html, sinon une visite directe ou un rafraîchissement renvoie une erreur 404.
  • Le deuxième argument de pushState est un paramètre title hérité que les navigateurs ignorent ; il ne peut pas être omis, alors passez toujours une chaîne vide.
  • Injecter une vue avec innerHTML constitue un vecteur XSS pour toute donnée non fiable interpolée, et supprime silencieusement les écouteurs d’événements sur le balisage injecté. Construisez les nœuds avec createElement, assainissez les données, ou utilisez une bibliothèque de templating, puis attachez les comportements via la délégation d’événements.
  • La Navigation API a atteint le statut Baseline Newly available en janvier 2026 et constitue le successeur émergent de ce modèle, mais l’History API reste la base de référence offrant la plus large compatibilité.

Quelle est la différence entre le mode hash et le mode History ?

Le routage côté client met à jour la vue lorsque l’URL change, sans rechargement complet de la page. Il existe deux façons de modifier l’URL sans naviguer : le mode hash et le mode History. Le mode hash encode la route après un # (/app#/users). Le fragment situé après le hash n’est jamais envoyé au serveur : la navigation par hash est donc purement côté client et ne nécessite aucune configuration serveur ; on écoute alors l’événement hashchange. Le mode History produit des chemins propres (/users) à l’aide de l’History API et écoute popstate.

Mode hashMode History
Forme de l’URL/app#/users/users
Événement de changementhashchangepopstate
Configuration serveurAucuneRéécriture de tous les chemins vers index.html
Rafraîchissement / lien profondFonctionne toujours404 sans réécriture
SEO / URL partageablesPlus faiblePlus propre, à privilégier

Le mode History est le choix par défaut pour ses URL propres et indexables, et c’est celui que nous utilisons dans cet article. Son seul coût est qu’il nécessite un support côté serveur, abordé plus bas.

Les primitives de l’History API dont vous avez réellement besoin

Trois primitives portent un routeur en mode History. history.pushState(state, unused, url) ajoute une entrée à la pile d’historique de session et modifie la barre d’adresse ; history.replaceState fait de même mais écrase l’entrée courante au lieu d’en ajouter une. location.pathname lit le chemin courant pour vous permettre de faire correspondre une route. L’événement popstate se déclenche lorsque l’utilisateur appuie sur Retour ou Suivant.

La règle essentielle : pushState et replaceState ne déclenchent pas popstate. Vous devez appeler vous-même votre fonction de rendu après chaque pushState, et enregistrer séparément un écouteur popstate afin que les boutons Retour et Suivant du navigateur provoquent un nouveau rendu de la vue. Oubliez cet écouteur et l’URL changera lors d’un Retour tandis que le DOM restera figé — un bug invisible en revue de code, mais évident dès que vous regardez un session replay de l’application.

Deux autres détails ont leur importance. L’argument du milieu est une valeur title héritée que les navigateurs ignorent, et elle ne peut pas être omise : passez donc une chaîne vide. L’url doit être de même origine : le navigateur ne la charge pas lorsque vous appelez pushState, et l’appel lève une exception si l’origine diffère de celle de la page courante. popstate lui-même est ancien et fiable, disponible dans tous les navigateurs depuis juillet 2015.

Comment construire un routeur minimal ?

Un routeur fonctionnel en mode History nécessite cinq éléments : une table de routes, une fonction resolve qui lit location.pathname et fait correspondre une route avec un repli 404, une délégation de clic sur un attribut data-link, un écouteur popstate et un rendu initial. Voici le fichier complet :

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 délégation d’événements via e.target.closest('[data-link]') est un choix délibéré. Elle survit aux clics sur les nœuds enfants (une icône à l’intérieur d’un lien) et continue de fonctionner lorsque les vues sont re-rendues, contrairement à l’attachement d’écouteurs sur chaque élément ou à la lecture de e.target.attributes[0], qui dépend de l’ordre des attributs et casse sur du balisage imbriqué.

Aller plus loin : paramètres dynamiques, titres et entrée initiale

La fonction match ci-dessus gère déjà les segments dynamiques. Un motif comme /users/:id est découpé en parties ; tout segment commençant par : capture le segment de chemin correspondant dans un objet params, de sorte que /users/42 se résout avec { id: '42' }. Les segments sans : doivent correspondre exactement, et une différence de longueur écarte le motif, ce qui empêche /users de correspondre à /users/42. Définir document.title à l’intérieur de resolve met à jour l’onglet et le libellé d’historique à chaque navigation.

Une dernière correction s’impose dans le routeur. Le navigateur crée votre première entrée d’historique à partir d’un chargement de page ordinaire : rien n’y est donc stocké, et le guide MDN sur l’utilisation de l’History API recommande d’appeler history.replaceState() au démarrage pour associer un state à cette entrée. Faites-le et le premier appui sur Retour pourra restaurer votre vue d’ouverture. C’est le rôle de la dernière ligne replaceState du routeur.

Les deux pièges qui distinguent un jouet d’un vrai routeur

Le déploiement. Les URL propres du mode History exigent que le serveur réécrive tout chemin inconnu vers index.html, faute de quoi une visite directe ou un rafraîchissement de /users/42 renvoie une 404. Il n’existe aucune solution de contournement en JavaScript, car la requête atteint le serveur avant le chargement de votre bundle. Configurez la réécriture une fois par hébergeur. Express 5 a modifié sa syntaxe de correspondance de chemins : chaque joker doit désormais être nommé, si bien que l’ancien fourre-tout app.get('*') lève une erreur « Missing parameter name » au démarrage. Utilisez le joker nommé entre accolades, qui correspond aussi bien au chemin racine qu’à tout ce qui se trouve en dessous :

// 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" }] }

Une 404 après un rafraîchissement forcé sur un lien profond partagé est un autre échec qui passe inaperçu à la lecture du code, mais qui saute aux yeux lorsque vous voyez une session réelle atterrir sur une page blanche.

La sécurité. Injecter une vue avec innerHTML constitue un vecteur XSS dès que des données non fiables sont interpolées (le ${p.id} ci-dessus provient directement de l’URL), et cela supprime silencieusement les écouteurs d’événements sur le balisage injecté. Échappez les valeurs interpolées (l’appel à escapeHtml ci-dessus), construisez les nœuds avec document.createElement, ou utilisez une bibliothèque de templating comme lit-html, et attachez les comportements par délégation d’événements sur un parent stable plutôt que sur les nœuds injectés. Les chaînes de template statiques, écrites par le développeur et sans interpolation, ne constituent pas en elles-mêmes une injection ; le risque vient des données non fiables que vous y insérez.

Vers quoi la plateforme se dirige : la Navigation API

La Navigation API a atteint le statut Baseline Newly available en janvier 2026, le mois où Firefox 147 a ajouté sa prise en charge, et elle constitue le successeur émergent de ce modèle. Au lieu de câbler séparément pushState, un écouteur popstate et un gestionnaire de clic, vous enregistrez un unique écouteur navigate. Celui-ci s’exécute pour chaque navigation visible par la page, quelle qu’en soit l’origine, et l’appel de event.intercept() à l’intérieur de cet écouteur laisse la barre d’adresse et la pile d’historique au navigateur. L’une des limitations qu’elle corrige est justement que popstate ne se déclenche pas sur un pushState/replaceState programmatique — exactement la friction que ce routeur contourne. Tant qu’elle n’aura pas atteint le seuil de compatibilité de vos navigateurs cibles, l’History API reste la base la plus largement prise en charge et le moyen le plus clair de comprendre ce que fait réellement un routeur.

Vous disposez maintenant d’un routeur en mode History exécutable : routes, correspondance de paramètres, clics délégués, gestion correcte de popstate, entrée initiale initialisée, et les deux corrections nécessaires en production. L’étape concrète suivante consiste à mettre en place la réécriture serveur pour votre hébergeur avant de déployer, afin que les liens profonds survivent à un rafraîchissement.

FAQ

Pourquoi le bouton Retour change-t-il l'URL sans modifier la page dans ma SPA ?

Parce que pushState et replaceState ne déclenchent pas d'événement popstate : si vous n'effectuez le rendu que dans votre gestionnaire de clic sans jamais enregistrer d'écouteur popstate, les boutons Retour et Suivant mettent à jour la barre d'adresse sans provoquer de nouveau rendu. La solution est un window.addEventListener('popstate', resolve) distinct qui exécute votre fonction de rendu chaque fois que le navigateur se déplace dans l'historique. Regardez un session replay et vous verrez l'URL changer sans aucune modification du DOM.

Quelle est la différence entre pushState et replaceState ?

pushState ajoute une nouvelle entrée à la pile d'historique de session, de sorte que la vue précédente reste accessible via le bouton Retour. replaceState écrase l'entrée courante au lieu d'en ajouter une, et ne crée donc pas de nouvelle cible pour le Retour. Utilisez pushState pour la navigation normale et replaceState pour initialiser l'entrée de la page de départ au démarrage ou pour corriger l'URL courante sans polluer l'historique. Les deux partagent la même signature (state, unused, url) et aucun des deux ne déclenche popstate.

Le routage en mode hash nécessite-t-il une configuration serveur ?

Non. Le fragment situé après le hash, comme le '/users' dans '/app#/users', n'est jamais envoyé au serveur : la navigation par hash est donc purement côté client et fonctionne sur n'importe quel hébergement statique, sans aucune règle de réécriture. Les rafraîchissements et les liens profonds se résolvent toujours, car le serveur ne voit jamais que '/app'. Le mode History est le compromis inverse : il produit des URL plus propres mais exige que le serveur réécrive tout chemin inconnu vers index.html, sinon un rafraîchissement renvoie une 404.

Faut-il encore apprendre l'History API maintenant que la Navigation API est Baseline ?

Oui. La Navigation API a atteint le statut Baseline Newly available en janvier 2026 et constitue le successeur émergent, remplaçant les appels manuels à pushState, popstate et l'interception des clics par un unique événement navigate et event.intercept(). Mais l'History API reste la base offrant la plus large compatibilité, fonctionne dans les navigateurs anciens que la Navigation API ne prend pas en charge, et demeure ce que des frameworks comme React Router et Vue Router pilotent en interne. L'apprendre est le moyen le plus clair de comprendre ce que fait réellement n'importe quel routeur.

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.