Hash Routing vs History Mode

After reading this you will be able to decide, for a given deployment, whether to route on the URL fragment (/#/path) or on clean History-mode paths (/path) — and know exactly which server rewrite, crawl, and scroll consequences each choice commits you to.

← Back to SPA vs MPA Tradeoffs

Prerequisites

Core Concept

The two modes differ in where the route lives inside the URL, and that single distinction cascades into every operational decision. Hash routing stores the route after a #, in the URL fragment. The fragment is never sent to the server — the browser strips everything from # onward before issuing the request — so https://site/#/reports and https://site/#/settings both resolve, on the network, to a request for /. That means the server only ever needs to serve one file, and no rewrite rule is required. History mode stores the route in the real path, so https://site/reports is a genuine request the server must answer. If the server has no file at /reports, it returns a 404 unless a rewrite rule redirects every unknown path back to the app’s index.html.

Everything else follows from that. Because the fragment stays client-side, hash routing survives on any static host with zero configuration, but the visible # looks synthetic, is historically weaker for crawlers, and complicates real fragment anchors. History mode yields clean, canonical, share-friendly URLs that crawlers and social scrapers treat as first-class pages — at the cost of a rewrite rule and a server that can answer any path. The right answer is a function of what your host can do, not of which mode is “modern”.

Request paths for hash routing versus History mode Hash routing sends only the root path to the server so no rewrite is needed, while History mode sends the full path and depends on a rewrite back to the app shell. Hash routing /#/reports fragment stays local GET / serves index.html no config needed History mode /reports full path sent GET /reports rewrite → index.html 404 without a rule clean, canonical URL crawler-friendly
Hash routing keeps the route in the fragment so the network only ever sees the root path; History mode sends the real path and needs a rewrite to avoid a 404.

Comparison

Concern Hash routing (/#/path) History mode (/path)
Server config None — any static host works Rewrite every unknown path to index.html
URL appearance Visible #, looks synthetic Clean and canonical
Crawlability Weaker; fragment treated as one page historically First-class indexable pages
Deep-link on cold load Always works, no server involvement Works only if the rewrite is in place
Real anchor links (#section) Collides with the route fragment Free — the fragment is unused
Scroll restoration Manual; fragment change fires no navigation Integrates with history.scrollRestoration
Best fit Static hosts, embedded widgets, file:// builds Anything public-facing that competes in search

Implementation

Both routers subscribe to a change signal, resolve the current route string, and render. The difference is entirely in how the route string is read and written. Hash mode listens for hashchange and reads location.hash; History mode listens for popstate, reads location.pathname, and must intercept link clicks to call pushState instead of letting the browser issue a full navigation.

// TypeScript 5.x — framework-agnostic, no runtime dependencies

type RouteHandler = (path: string) => void;

interface Router {
  start(): void;
  navigate(path: string): void; // programmatic navigation
  dispose(): void;
}

// --- Hash router: route lives in location.hash, server never sees it. ---
export function createHashRouter(onRoute: RouteHandler): Router {
  // "/#/reports" → "/reports"; empty fragment means the index route.
  const read = () => location.hash.slice(1) || "/";
  const onHashChange = () => onRoute(read());

  return {
    start() {
      window.addEventListener("hashchange", onHashChange);
      onRoute(read()); // resolve the deep-linked route on cold load
    },
    navigate(path) {
      // Assigning the hash fires hashchange; no server request is made.
      location.hash = path;
    },
    dispose() {
      window.removeEventListener("hashchange", onHashChange);
    },
  };
}

// --- History router: route lives in the real path, needs a server rewrite. ---
export function createHistoryRouter(onRoute: RouteHandler): Router {
  const read = () => location.pathname || "/";
  const onPopState = () => onRoute(read());

  // Intercept in-app links so a click updates the URL without a full reload.
  const onClick = (e: MouseEvent) => {
    const link = (e.target as HTMLElement).closest("a");
    if (!link || link.origin !== location.origin || link.hasAttribute("data-external")) return;
    e.preventDefault();
    history.pushState(null, "", link.pathname); // see pushState/replaceState usage
    onRoute(read());
  };

  return {
    start() {
      window.addEventListener("popstate", onPopState);
      document.addEventListener("click", onClick);
      onRoute(read()); // works on cold load ONLY if the host rewrote to index.html
    },
    navigate(path) {
      history.pushState(null, "", path);
      onRoute(read());
    },
    dispose() {
      window.removeEventListener("popstate", onPopState);
      document.removeEventListener("click", onClick);
    },
  };
}

The History router’s cold-load path is the line that fails silently in production: onRoute(read()) only runs if the browser actually loaded your index.html for /reports, which only happens when the host rewrites unmatched paths to it. On a static host without that rule, the visitor gets a raw 404 before any JavaScript executes — the exact scenario that keeps hash mode justified.

Verification

Confirm the two modes behave as designed by deep-linking directly to a nested route on a cold load — never by clicking in from the index, which hides the server-config difference entirely.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("hash route deep-links without any server rewrite", async ({ page }) => {
  // The fragment is never sent upstream, so the root file answers the request.
  await page.goto("/#/reports");
  await expect(page.locator("[data-route='reports']")).toBeVisible();
});

test("history route deep-links only when the host rewrites to index.html", async ({ page }) => {
  const res = await page.goto("/reports");
  // A correctly configured host returns 200 and the app shell, not a 404.
  expect(res?.status()).toBe(200);
  await expect(page.locator("[data-route='reports']")).toBeVisible();
});

For a config-free manual check, request a nested History-mode path with curl -I https://your-site/reports: a 200 means the rewrite is live, while a 404 proves the route only survives because of client-side navigation and will break on any shared deep link.

Gotchas

  • History mode without a catch-all rewrite works perfectly in development (the dev server rewrites for you) and 404s the moment it hits a static host — always test a cold deep-link on the real deployment target, not just localhost.
  • Hash routing hijacks the fragment, so genuine in-page anchors like #pricing fight the router; if you need both, namespace real anchors or move to History mode where the fragment is free.
  • Switching an existing site from hash to History mode orphans every previously shared /#/path link; add a one-time client-side redirect that reads location.hash on load and calls replaceState to the clean path.
  • Changing location.hash fires hashchange but performs no navigation, so the browser will not restore scroll for you — hash-mode apps must manage scroll manually rather than leaning on history.scrollRestoration.

FAQ

Is hash routing bad for SEO? It is weaker but no longer fatal: modern crawlers can execute JavaScript and follow fragment routes, yet they still treat the fragment as a variation of one URL, so History-mode paths remain the safer default for any route that must rank independently.

Do I really need server configuration for History mode? Yes — every path a visitor can deep-link to must resolve to your app shell, which means a rewrite that maps unmatched paths to index.html; without it, a cold load or refresh on a nested route returns a 404 before your router ever runs.

When is hash routing still the right choice? On static hosts that cannot express rewrite rules, inside embedded widgets or iframes, on file:// protocol builds, and for internal tools where clean URLs and crawlability simply do not matter — hash mode trades appearance for zero-config deep-linking.

Can I switch from hash to History mode later without breaking old links? Yes, if you keep a small compatibility shim: on load, read any legacy location.hash, translate it to the new clean path, and use replaceState so the stale fragment URL upgrades in place without adding a history entry.

Why does refreshing a nested route work in development but 404 in production? Development servers rewrite unknown paths to index.html automatically, masking the missing rule; production static hosts do not, so the same refresh becomes a real request for a file that does not exist unless you add the catch-all rewrite yourself.