Creating an SPA Router With the JS History API

A complete tutorial on creating a single-page application router with the JavaScript History API. Covers route registration, dynamic path parameters, wildcard routes, nested routes, route guards, lazy loading, query parameter parsing, transition hooks, link interception, and building a production-ready client-side router from scratch.

JavaScriptintermediate
16 min read

A client-side router intercepts link clicks, updates the URL with pushState, and renders the correct view without a full page reload. This guide builds a complete router from scratch with dynamic parameters, guards, lazy loading, and middleware.

For the underlying History API reference, see JavaScript History API guide: complete tutorial.

Router Core

javascriptjavascript
class Router {
  constructor(options = {}) {
    this.routes = [];
    this.notFoundHandler = null;
    this.currentRoute = null;
    this.rootElement = document.querySelector(options.root || "#app");
    this.middleware = [];
 
    // Listen for back/forward navigation
    window.addEventListener("popstate", () => this.resolve());
 
    // Intercept link clicks
    document.addEventListener("click", (event) => {
      const link = event.target.closest("a[href]");
      if (!link) return;
 
      const href = link.getAttribute("href");
 
      // Only intercept local links
      if (
        link.target === "_blank" ||
        link.hasAttribute("download") ||
        href.startsWith("http") ||
        href.startsWith("mailto:") ||
        href.startsWith("#")
      ) {
        return;
      }
 
      event.preventDefault();
      this.navigate(href);
    });
  }
 
  route(path, handler, options = {}) {
    const paramNames = [];
    const pattern = path.replace(/:([^/]+)/g, (_, name) => {
      paramNames.push(name);
      return "([^/]+)";
    });
 
    this.routes.push({
      path,
      pattern: new RegExp(`^${pattern}$`),
      paramNames,
      handler,
      ...options,
    });
 
    return this;
  }
 
  notFound(handler) {
    this.notFoundHandler = handler;
    return this;
  }
 
  use(fn) {
    this.middleware.push(fn);
    return this;
  }
 
  navigate(path, state = {}) {
    history.pushState(state, "", path);
    this.resolve();
  }
 
  replace(path, state = {}) {
    history.replaceState(state, "", path);
    this.resolve();
  }
 
  async resolve() {
    const path = window.location.pathname;
    const query = Object.fromEntries(new URLSearchParams(window.location.search));
 
    for (const route of this.routes) {
      const match = path.match(route.pattern);
 
      if (match) {
        const params = {};
        route.paramNames.forEach((name, i) => {
          params[name] = decodeURIComponent(match[i + 1]);
        });
 
        const context = {
          path,
          params,
          query,
          state: history.state || {},
          route: route.path,
        };
 
        // Run middleware
        const allowed = await this.runMiddleware(context);
        if (!allowed) return;
 
        // Run route guard
        if (route.guard) {
          const guardResult = await route.guard(context);
          if (!guardResult) return;
        }
 
        this.currentRoute = context;
 
        // Handle lazy loading
        if (route.lazy) {
          const module = await route.lazy();
          module.default(this.rootElement, context);
        } else {
          route.handler(this.rootElement, context);
        }
 
        return;
      }
    }
 
    // No route matched
    if (this.notFoundHandler) {
      this.notFoundHandler(this.rootElement, { path, query });
    }
  }
 
  async runMiddleware(context) {
    for (const fn of this.middleware) {
      const result = await fn(context);
      if (result === false) return false;
    }
    return true;
  }
 
  start() {
    this.resolve();
    return this;
  }
}

Registering Routes

javascriptjavascript
const router = new Router({ root: "#app" });
 
// Static routes
router.route("/", (el) => {
  el.innerHTML = `
    <h1>Home</h1>
    <nav>
      <a href="/about">About</a>
      <a href="/articles">Articles</a>
    </nav>
  `;
});
 
router.route("/about", (el) => {
  el.innerHTML = "<h1>About Us</h1><p>We build tutorials.</p>";
});
 
// Dynamic route with parameters
router.route("/articles/:id", (el, { params, query }) => {
  el.innerHTML = `
    <h1>Article ${params.id}</h1>
    <p>Query: ${JSON.stringify(query)}</p>
    <a href="/articles">Back to list</a>
  `;
});
 
// Nested dynamic parameters
router.route("/users/:userId/posts/:postId", (el, { params }) => {
  el.innerHTML = `
    <h1>User ${params.userId} - Post ${params.postId}</h1>
  `;
});
 
// 404 handler
router.notFound((el, { path }) => {
  el.innerHTML = `<h1>404</h1><p>Page not found: ${path}</p>`;
});
 
router.start();

Route Guards

javascriptjavascript
function isAuthenticated() {
  return !!localStorage.getItem("authToken");
}
 
// Protect specific routes
router.route(
  "/dashboard",
  (el) => {
    el.innerHTML = "<h1>Dashboard</h1><p>Welcome back!</p>";
  },
  {
    guard: () => {
      if (!isAuthenticated()) {
        router.navigate("/login");
        return false;
      }
      return true;
    },
  }
);
 
router.route(
  "/admin/:section",
  (el, { params }) => {
    el.innerHTML = `<h1>Admin: ${params.section}</h1>`;
  },
  {
    guard: () => {
      const user = JSON.parse(localStorage.getItem("user") || "{}");
      if (user.role !== "admin") {
        router.navigate("/");
        return false;
      }
      return true;
    },
  }
);
 
// Global middleware (runs for every route)
router.use((context) => {
  console.log(`Navigating to: ${context.path}`);
  return true; // Allow navigation
});
 
// Auth middleware
router.use((context) => {
  const publicPaths = ["/", "/about", "/login", "/register"];
  if (publicPaths.includes(context.path)) return true;
 
  if (!isAuthenticated()) {
    router.navigate(`/login?redirect=${encodeURIComponent(context.path)}`);
    return false;
  }
  return true;
});

Lazy Loading Routes

javascriptjavascript
// Load route handlers on demand
router.route("/settings", null, {
  lazy: () => import("./pages/settings.js"),
});
 
router.route("/analytics", null, {
  lazy: () => import("./pages/analytics.js"),
});
 
// pages/settings.js
export default function settingsPage(el, context) {
  el.innerHTML = `
    <h1>Settings</h1>
    <form id="settings-form">
      <label>Theme: <select name="theme">
        <option>light</option>
        <option>dark</option>
      </select></label>
    </form>
  `;
}

Query Parameter Handling

javascriptjavascript
class QueryParams {
  constructor() {
    this.params = new URLSearchParams(window.location.search);
  }
 
  get(key, defaultValue = null) {
    return this.params.get(key) ?? defaultValue;
  }
 
  getAll(key) {
    return this.params.getAll(key);
  }
 
  getNumber(key, defaultValue = 0) {
    const value = this.params.get(key);
    if (value === null) return defaultValue;
    const num = Number(value);
    return Number.isNaN(num) ? defaultValue : num;
  }
 
  getBoolean(key, defaultValue = false) {
    const value = this.params.get(key);
    if (value === null) return defaultValue;
    return value === "true" || value === "1";
  }
 
  set(key, value) {
    this.params.set(key, value);
    this.updateURL();
  }
 
  delete(key) {
    this.params.delete(key);
    this.updateURL();
  }
 
  updateURL() {
    const search = this.params.toString();
    const newURL = search
      ? `${window.location.pathname}?${search}`
      : window.location.pathname;
    history.replaceState(history.state, "", newURL);
  }
 
  toObject() {
    return Object.fromEntries(this.params);
  }
}
 
// Usage in route handler
router.route("/search", (el, { query }) => {
  const qp = new QueryParams();
  const term = qp.get("q", "");
  const page = qp.getNumber("page", 1);
 
  el.innerHTML = `
    <h1>Search: "${term}" (Page ${page})</h1>
    <input id="search-input" value="${term}" />
  `;
 
  document.getElementById("search-input").addEventListener("change", (e) => {
    qp.set("q", e.target.value);
    qp.set("page", "1");
  });
});

Page Transitions

javascriptjavascript
class TransitionRouter extends Router {
  constructor(options = {}) {
    super(options);
    this.transitionDuration = options.transitionDuration || 300;
  }
 
  async resolve() {
    const el = this.rootElement;
 
    // Fade out
    el.style.transition = `opacity ${this.transitionDuration}ms ease`;
    el.style.opacity = "0";
 
    await new Promise((r) => setTimeout(r, this.transitionDuration));
 
    // Render new content
    await super.resolve();
 
    // Fade in
    el.style.opacity = "1";
  }
}
 
const router = new TransitionRouter({
  root: "#app",
  transitionDuration: 200,
});

Router Feature Comparison

FeatureHash RouterHistory RouterNavigation API
URL format/#/path/path/path
Server config neededNoYes (catch-all)Yes
SEO friendlyNoYesYes
Browser supportAllIE10+Chrome 102+
popstate eventhashchangepopstatenavigate
State objectManualBuilt-inBuilt-in
javascriptjavascript
function updateActiveLinks() {
  const currentPath = window.location.pathname;
 
  document.querySelectorAll("a[href]").forEach((link) => {
    const href = link.getAttribute("href");
 
    if (href === currentPath) {
      link.classList.add("active");
      link.setAttribute("aria-current", "page");
    } else {
      link.classList.remove("active");
      link.removeAttribute("aria-current");
    }
  });
}
 
// Call after every navigation
router.use((context) => {
  requestAnimationFrame(updateActiveLinks);
  return true;
});
Rune AI

Rune AI

Key Insights

  • Intercept link clicks: Prevent default on same-origin <a> clicks and call pushState to update the URL without reloading
  • Match routes with regex: Convert dynamic :param segments to capture groups and extract parameters from the match
  • Route guards prevent unauthorized access: Return false from a guard to cancel navigation and redirect to login or home
  • Lazy loading reduces bundle size: Use dynamic import() for route handlers to load code only when the route is visited
  • Server must serve index.html for all routes: Without a catch-all rule, direct URL access or page refresh returns 404 from the server
RunePowered by Rune AI

Frequently Asked Questions

Do I need server-side configuration for History API routing?

Yes. When users directly access `/about` or refresh the page, the server must serve your `index.html` for all routes (a catch-all or fallback rule). Without this, the server returns 404. Configure your server (Nginx, Apache, Vercel, Netlify) with a fallback to `index.html`.

How do I handle 404 pages with a client-side router?

Register a `notFound` handler that renders a 404 page. The router tries all registered routes in order and calls the `notFound` handler if none match. For SEO, have the server return a 404 status code on truly missing resources.

Can I mix server-rendered and client-rendered pages?

Yes. Use the router only for paths that should be client-rendered. For server-rendered pages, use regular `<a>` tags with `target` or `data-external` attributes that the link interceptor skips. Check for `link.hasAttribute("data-external")` in the click handler.

How do I handle scroll position with client-side routing?

Save `window.scrollY` in state via `replaceState` before navigating. On `popstate`, restore scroll from `event.state.scrollY`. For new navigations, scroll to top with `window.scrollTo(0, 0)`. Use `history.scrollRestoration = "manual"` to disable browser default behavior.

What is the Navigation API and should I use it instead?

The Navigation API (Chrome 102+) is a newer alternative with a single `navigate` event, built-in scroll restoration, and better transition support. It is not yet supported in Firefox or Safari. For broad compatibility, use the History API. Adopt the Navigation API when browser support is sufficient for your audience.

Conclusion

A History API-based SPA router intercepts link clicks, uses pushState to update the URL, matches paths against registered routes, and renders content without page reloads. Add route guards for authentication, lazy loading for performance, and middleware for logging and analytics. For the underlying API, see JavaScript History API guide. For related browser storage, see JS sessionStorage API guide.