12k
All articles

Client-seitiges Routing mit der History API umsetzen

Bauen Sie einen Vanilla-Router mit der History API, pushState und popstate, dynamischen Parametern, SEO-URLs und Fixes für 404 und XSS.

OpenReplay Team
OpenReplay Team
Client-seitiges Routing mit der History API umsetzen

Client-seitiges Routing wechselt Views, indem die URL aktualisiert und in JavaScript neu gerendert wird – ohne Roundtrip zum Server (der Server wird nur beim allerersten Laden oder bei einem Hard Refresh kontaktiert).

Wer schon einmal eine Single-Page-App ausgeliefert hat, kennt diesen Moment: Lokal funktioniert alles, dann drückt ein Teammitglied den Zurück-Button, die URL ändert sich – und die Seite bleibt einfach unbewegt stehen. Die Behebung dauert fünf Minuten, sobald man weiß, wo man suchen muss, und beim ersten Mal tappt fast jeder in diese Falle.

Frameworks wie React Router und Vue Router verpacken dieses Verhalten in Komponenten und Hooks, doch darunter steuern sie alle dasselbe Browser-Primitive: die History API. Dieser Artikel baut in rund 50 Zeilen einen minimalen, korrekten und deploybaren Vanilla-Router, erklärt die Arbeitsteilung zwischen pushState und popstate und behandelt die beiden Stolperfallen (den Deployment-404 und das XSS-Injection-Risiko), die ein Spielzeug von etwas Produktionsreifem unterscheiden.

Die wichtigsten Erkenntnisse

  • Im History-Modus ändert history.pushState(state, '', url) die URL ohne Neuladen der Seite, löst aber kein popstate-Event aus. Sie rufen Ihre Render-Funktion nach pushState selbst auf und lauschen separat auf popstate, um Zurück und Vorwärts zu behandeln.
  • Die aufgeräumten /dashboard-URLs des History-Modus sind besser für SEO und zum Teilen, erfordern aber, dass der Server jeden unbekannten Pfad auf index.html umschreibt – andernfalls liefert ein direkter Aufruf oder ein Refresh einen 404.
  • Das zweite Argument von pushState ist ein veralteter title-Parameter, den Browser ignorieren; er lässt sich nicht auslassen, übergeben Sie daher immer einen leeren String.
  • Eine View mit innerHTML einzufügen ist ein XSS-Vektor für jegliche interpolierten, nicht vertrauenswürdigen Daten und verwirft stillschweigend Event-Listener auf dem eingefügten Markup. Erzeugen Sie Nodes mit createElement, sanitisieren Sie die Daten oder nutzen Sie eine Templating-Bibliothek – und binden Sie Verhalten über Event-Delegation an.
  • Die Navigation API hat im Januar 2026 den Status „Baseline Newly available“ erreicht und ist der aufkommende Nachfolger dieses Musters; die History API bleibt jedoch die Baseline mit der breitesten Kompatibilität.

Was ist der Unterschied zwischen Hash-Modus und History-Modus?

Client-seitiges Routing aktualisiert die View bei einer URL-Änderung, ohne die Seite vollständig neu zu laden. Es gibt zwei Wege, die URL zu ändern, ohne zu navigieren: Hash-Modus und History-Modus. Der Hash-Modus kodiert die Route nach einem # (/app#/users). Das Fragment nach dem Hash wird nie an den Server gesendet, sodass hash-basierte Navigation rein client-seitig abläuft und keinerlei Serverkonfiguration benötigt; dabei lauschen Sie auf das hashchange-Event. Der History-Modus erzeugt mit der History API aufgeräumte Pfade (/users) und lauscht auf popstate.

Hash-ModusHistory-Modus
URL-Form/app#/users/users
Änderungs-Eventhashchangepopstate
ServerkonfigurationKeineAlle Pfade auf index.html umschreiben
Refresh / Deep LinkFunktioniert immer404 ohne Rewrite
SEO / teilbare URLsSchwächerAufgeräumter, bevorzugt

Der History-Modus ist wegen seiner aufgeräumten, indexierbaren URLs die Standardwahl – und er ist die Grundlage dieses Artikels. Der einzige Preis: Er benötigt Serverunterstützung, dazu unten mehr.

Die History-API-Primitive, die Sie wirklich brauchen

Drei Primitive tragen einen History-Modus-Router. history.pushState(state, unused, url) fügt einen Eintrag zum Session-History-Stack hinzu und ändert die Adressleiste; history.replaceState tut dasselbe, überschreibt aber den aktuellen Eintrag, anstatt einen neuen anzulegen. location.pathname liest den aktuellen Pfad, damit Sie eine Route matchen können. Das popstate-Event feuert, wenn der Nutzer Zurück oder Vorwärts drückt.

Die entscheidende Regel: pushState und replaceState lösen kein popstate aus. Sie müssen Ihre Render-Funktion nach jedem pushState selbst aufrufen und zusätzlich einen popstate-Listener registrieren, damit Zurück und Vorwärts im Browser die View neu rendern. Fehlt der Listener, ändert sich beim Zurück-Klick die URL, während das DOM einfriert – ein Bug, der im Code Review unsichtbar bleibt, aber sofort offensichtlich wird, sobald man ein Session Replay der App ansieht.

Zwei weitere Details sind relevant. Das mittlere Argument ist ein veralteter title-Wert, den Browser ignorieren, und es lässt sich nicht auslassen – übergeben Sie also einen leeren String. Die url muss same-origin sein: Der Browser lädt sie beim Aufruf von pushState nicht, und der Aufruf wirft eine Exception, wenn sich die Origin von der aktuellen Seite unterscheidet. popstate selbst ist alt und zuverlässig und seit Juli 2015 browserübergreifend verfügbar.

Wie baut man einen minimalen Router?

Ein funktionierender History-Modus-Router braucht fünf Teile: eine Routes-Map, eine resolve-Funktion, die location.pathname liest und eine Route mit 404-Fallback matcht, Click-Delegation über ein data-link-Attribut, einen popstate-Listener und ein initiales Rendering. Hier die vollständige Datei:

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

Die Event-Delegation via e.target.closest('[data-link]') ist bewusst gewählt. Sie übersteht Klicks auf Kindknoten (etwa ein Icon innerhalb eines Links) und funktioniert weiter, wenn Views neu gerendert werden – anders als das Anhängen von Listenern an jedes einzelne Element oder das Auslesen von e.target.attributes[0], was von der Attributreihenfolge abhängt und bei verschachteltem Markup bricht.

Ausbaustufe: dynamische Parameter, Titel und der initiale Eintrag

Die obige match-Funktion behandelt dynamische Segmente bereits. Ein Muster wie /users/:id wird in Teile zerlegt; jedes Segment, das mit : beginnt, erfasst das entsprechende Pfadsegment in einem params-Objekt, sodass /users/42 mit { id: '42' } aufgelöst wird. Segmente ohne : müssen exakt übereinstimmen, und eine abweichende Länge überspringt das Muster – so matcht /users nicht auf /users/42. Das Setzen von document.title innerhalb von resolve aktualisiert den Tab und das History-Label bei jeder Navigation.

Eine weitere Korrektur gehört in den Router. Der Browser erzeugt Ihren ersten History-Eintrag aus einem gewöhnlichen Seitenaufruf, sodass darauf kein State gespeichert ist; der MDN-Leitfaden zur Arbeit mit der History API empfiehlt daher, beim Start history.replaceState() aufzurufen, um State an diesen Eintrag zu binden. Tun Sie das, kann der erste Zurück-Klick Ihre Ausgangs-View wiederherstellen. Genau das ist die abschließende replaceState-Zeile im Router.

Die zwei Stolperfallen, die ein Spielzeug von einem echten Router unterscheiden

Deployment. Die aufgeräumten URLs des History-Modus erfordern, dass der Server jeden unbekannten Pfad auf index.html umschreibt – andernfalls liefert ein direkter Aufruf oder Refresh von /users/42 einen 404. Es gibt keinen JavaScript-Workaround, denn die Anfrage erreicht den Server, bevor Ihr Bundle geladen ist. Konfigurieren Sie den Rewrite einmal pro Host. Express 5 hat seine Path-Matching-Syntax geändert: Jeder Wildcard muss nun benannt sein, weshalb das alte Catch-all app.get('*') beim Start einen „Missing parameter name“-Fehler wirft. Nutzen Sie die benannte Wildcard in geschweiften Klammern, die sowohl den Root-Pfad als auch alles darunter matcht:

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

Ein 404 beim Hard Refresh eines geteilten Deep Links ist ein weiterer Fehler, der im Code unauffällig wirkt, aber unmissverständlich sichtbar wird, wenn man einer echten Session dabei zusieht, wie sie auf einer leeren Seite landet.

Sicherheit. Eine View mit innerHTML einzufügen ist ein XSS-Vektor, sobald nicht vertrauenswürdige Daten interpoliert werden (das ${p.id} oben stammt direkt aus der URL), und es verwirft stillschweigend Event-Listener auf dem eingefügten Markup. Escapen Sie interpolierte Werte (der escapeHtml-Aufruf oben), erzeugen Sie Nodes mit document.createElement oder nutzen Sie eine Templating-Bibliothek wie lit-html – und binden Sie Verhalten über Event-Delegation an einem stabilen Parent an, nicht an den eingefügten Nodes. Statische, von Entwicklern verfasste Template-Strings ohne Interpolation sind selbst keine Injection; das Risiko sind die nicht vertrauenswürdigen Daten, die Sie einfügen.

Wohin sich die Plattform entwickelt: die Navigation API

Die Navigation API hat im Januar 2026 den Status Baseline Newly available erreicht – in dem Monat, in dem Firefox 147 Support hinzufügte – und ist der aufkommende Nachfolger dieses Musters. Anstatt pushState, einen popstate-Listener und einen Click-Handler separat zu verdrahten, registrieren Sie einen einzigen navigate-Listener. Er läuft für jede Navigation, die die Seite sehen kann, unabhängig davon, was sie ausgelöst hat, und der Aufruf von event.intercept() innerhalb dieses Listeners überlässt Adressleiste und History-Stack dem Browser. Eine der Schwächen, die er adressiert, ist genau die, dass popstate bei programmatischem pushState/replaceState nicht feuert – exakt die Reibung, die dieser Router umgeht. Solange sie nicht die Kompatibilitätsuntergrenze für Ihre Zielbrowser ist, bleibt die History API die Baseline mit der breitesten Unterstützung und der klarste Weg, zu verstehen, was ein Router tatsächlich tut.

Sie haben jetzt einen lauffähigen History-Modus-Router: Routes, Parameter-Matching, delegierte Klicks, korrektes popstate-Handling, einen initialisierten ersten History-Eintrag und beide Produktions-Fixes. Der nächste konkrete Schritt ist, den Server-Rewrite für Ihren Host einzurichten, bevor Sie deployen, damit Deep Links einen Refresh überleben.

FAQs

Warum ändert der Zurück-Button in meiner SPA die URL, lässt die Seite aber unverändert?

Weil pushState und replaceState kein popstate-Event auslösen: Wenn Sie nur innerhalb Ihres Click-Handlers rendern und nie einen popstate-Listener registrieren, aktualisieren Zurück und Vorwärts lediglich die Adressleiste, ohne neu zu rendern. Die Lösung ist ein separates window.addEventListener('popstate', resolve), das Ihre Render-Funktion ausführt, sobald der Browser durch die History navigiert. Sehen Sie sich ein Session Replay an, und Sie werden eine URL-Änderung ohne DOM-Änderung beobachten.

Was ist der Unterschied zwischen pushState und replaceState?

pushState fügt einen neuen Eintrag zum Session-History-Stack hinzu, sodass die vorherige View über den Zurück-Button erreichbar bleibt. replaceState überschreibt den aktuellen Eintrag, anstatt einen neuen anzulegen, und erzeugt damit kein neues Zurück-Ziel. Nutzen Sie pushState für normale Navigation und replaceState, um beim Start den initialen Seiteneintrag zu initialisieren oder die aktuelle URL zu korrigieren, ohne die History zu verunreinigen. Beide teilen dieselbe Signatur (state, unused, url), und keines von beiden löst popstate aus.

Benötigt Routing im Hash-Modus irgendeine Serverkonfiguration?

Nein. Das Fragment nach dem Hash, etwa das '/users' in '/app#/users', wird nie an den Server gesendet. Hash-basierte Navigation ist daher rein client-seitig und funktioniert auf jedem statischen Host ohne Rewrite-Regeln. Refreshes und Deep Links lösen immer korrekt auf, weil der Server stets nur '/app' sieht. Der History-Modus ist der Kompromiss: Er erzeugt aufgeräumtere URLs, erfordert aber, dass der Server jeden unbekannten Pfad auf index.html umschreibt, sonst liefert ein Refresh einen 404.

Sollte ich die History API noch lernen, jetzt da die Navigation API Baseline ist?

Ja. Die Navigation API hat im Januar 2026 den Status Baseline Newly available erreicht und ist der aufkommende Nachfolger, der manuelles pushState, popstate und Click-Interception durch ein einziges navigate-Event und event.intercept() ersetzt. Die History API bleibt jedoch die Baseline mit der breitesten Kompatibilität, funktioniert in älteren Browsern, in denen die Navigation API es nicht tut, und ist das, was Frameworks wie React Router und Vue Router weiterhin darunter ansteuern. Sie zu lernen ist der klarste Weg, zu verstehen, was ein Router tatsächlich tut.

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.