Creating an SPA Router with the JS History API

Build a lightweight single-page application router from scratch using the History API. Learn route matching, view rendering, and Back button handling.

7 min read

A single-page application router connects URLs to view functions without reloading the page. When the user clicks a link, the URL changes and new content appears, but the browser never navigates away. Frameworks like React Router and Vue Router do this, but the core logic is a small amount of code built on the History API.

This article walks through building a working SPA router step by step. By the end you will have a router that supports route matching, link interception, Back button navigation, and parameterized routes.

Setting up the route table

Start with a simple array that maps URL patterns to render functions. Each route has a pattern string and a view callback.

javascriptjavascript
const routes = [
  { pattern: "/", view: () => "<h2>Home</h2><p>Welcome to the app.</p>" },
  { pattern: "/about", view: () => "<h2>About</h2><p>Learn more here.</p>" },
  { pattern: "/contact", view: () => "<h2>Contact</h2><p>Get in touch.</p>" },
];

The view functions return HTML strings. In a real app they would create DOM elements or trigger a component render, but the router logic stays the same.

Matching routes to patterns

The router needs a function that takes a pathname, finds the matching route, and calls its view. For static routes, a simple find works.

javascriptjavascript
function matchRoute(pathname) {
  return routes.find((route) => route.pattern === pathname);
}
 
function render(pathname) {
  const route = matchRoute(pathname);
  const app = document.querySelector("#app");
  if (route) {
    app.innerHTML = route.view();
  } else {
    app.innerHTML = "<h2>404</h2><p>Page not found.</p>";
  }
}

The render function clears the app container and inserts the matched view. If no route matches, it shows a 404 page. This handles the initial render, but links still cause full page reloads.

To prevent full navigation, listen for clicks on internal links. When a link points to the same origin, cancel the default behavior and use the router instead.

javascriptjavascript
document.addEventListener("click", (event) => {
  const link = event.target.closest("a");
  if (!link) return;
 
  const url = new URL(link.href);
  if (url.origin !== location.origin) return;
 
  event.preventDefault();
  history.pushState({}, "", url.pathname);
  render(url.pathname);
});

The click handler finds the nearest anchor element, checks that it is an internal link, cancels the default navigation, pushes a new history entry, and renders the matching view. Now clicking links updates the URL and content without a page reload.

Handling the Back and Forward buttons

The popstate event fires when the user navigates with the browser buttons. The router must listen for it and render the view for the updated pathname.

javascriptjavascript
window.addEventListener("popstate", () => {
  render(location.pathname);
});

With this one line, the Back and Forward buttons work correctly. Each time the user navigates, the router renders the right view for the current URL.

Adding parameterized routes

Most apps need routes like /users/42 that extract a dynamic segment. Start by writing a function that converts colon-prefixed patterns into regular expressions:

javascriptjavascript
function buildMatcher(pattern) {
  const paramNames = [];
  const regexString = pattern.replace(/:(\w+)/g, (_, name) => {
    paramNames.push(name);
    return "([^/]+)";
  });
  const regex = new RegExp(`^${regexString}$`);
  return { regex, paramNames };
}

The buildMatcher function finds :param segments, creates a regex that captures those positions, and maps matched values back to parameter names.

Now update the matchRoute function to use this matcher and extract parameters:

javascriptjavascript
function matchRoute(pathname) {
  for (const route of routes) {
    const { regex, paramNames } = buildMatcher(route.pattern);
    const match = pathname.match(regex);
    if (match) {
      const params = {};
      paramNames.forEach((name, i) => {
        params[name] = match[i + 1];
      });
      return { ...route, params };
    }
  }
  return null;
}

The buildMatcher function finds :param segments in the pattern, creates a regex that captures those positions, and maps matched values back to parameter names. The router now supports routes like /users/:id.

With the new matcher, routes with parameters work naturally:

javascriptjavascript
const routes = [
  { pattern: "/", view: () => "<h2>Home</h2>" },
  {
    pattern: "/users/:id",
    view: (params) => `<h2>User ${params.id}</h2><p>Profile page.</p>`,
  },
];

The view function receives the params object with the extracted id value, so it can fetch the right data or render the appropriate content.

Putting it all together

The complete router initializes on page load, handles link clicks, and responds to browser navigation. Here is the initialization wiring:

javascriptjavascript
document.addEventListener("DOMContentLoaded", () => {
  render(location.pathname);
});
 
document.addEventListener("click", (event) => {
  const link = event.target.closest("a");
  if (!link) return;
  const url = new URL(link.href);
  if (url.origin !== location.origin) return;
  event.preventDefault();
  history.pushState({}, "", url.pathname);
  render(url.pathname);
});

The DOMContentLoaded handler renders the initial route when the page first loads. The click handler intercepts same-origin link clicks and routes them through pushState instead of letting the browser navigate. Together these two listeners handle all forward navigation.

Now add the popstate listener so the Back and Forward buttons also work correctly:

javascriptjavascript
window.addEventListener("popstate", () => {
  render(location.pathname);
});

This is about 20 lines of routing logic. Real frameworks add nested routes, lazy loading, guards, and transition animations, but they all build on these same three primitives from the History API.

Common mistakes

  • Forgetting to call render on page load. The initial URL must be handled, not just future clicks and popstate events.
  • Not preventing default on intercepted links. The browser follows the href normally unless you call preventDefault.
  • Ignoring external links and anchor links. Only intercept same-origin navigation. Let external links and hash anchors behave normally.
  • Expecting pushState to trigger popstate. It does not. You must call render manually after pushState.
  • Skipping the 404 case. Every router needs a fallback for unmatched routes.

Quick reference

ConcernImplementation
Define routesArray of { pattern, view } objects
Match a pathConvert pattern to regex, test against pathname
Render a viewCall the matched view function, insert into the DOM
Intercept linksGlobal click listener with preventDefault and pushState
Handle Back/Forwardpopstate event listener calls render
ParametersColon-prefixed segments in patterns become capture groups
Initial loadCall render with location.pathname on DOMContentLoaded
Rune AI

Rune AI

Key Insights

  • Define routes as an array of pattern-view pairs with parameterized segments.
  • Intercept link clicks with a global event listener and call pushState.
  • Use the popstate event to render the correct view on Back and Forward navigation.
  • Match routes by converting patterns to regular expressions and extracting parameters.
  • Call the router on page load to render the initial route before any navigation happens.
RunePowered by Rune AI

Frequently Asked Questions

Do I need a framework to build a client-side router?

No. The History API provides the primitives that every framework router builds on. For a production app, using a framework router is more practical, but building your own teaches you exactly how client-side routing works.

How does this router handle page reloads?

A production SPA needs server-side configuration to serve the same HTML for every route. Without it, reloading on /about returns a 404. This article focuses on the client-side router; the server setup depends on your backend.

Can this router handle dynamic route parameters like /users/42?

Yes. The pattern matching in this article supports parameterized routes using colon-prefixed segments. You can extend it further for query string parsing and nested routes.

Should I use hash-based routing instead?

Hash routing using the hashchange event is simpler and requires no server config, but produces ugly URLs like /#/about. The History API gives clean URLs and is the modern standard.

Conclusion

A client-side router built on the History API needs three pieces: a route table mapping patterns to view functions, link interception that calls pushState instead of following hrefs, and a popstate handler that renders the correct view when the user presses Back or Forward. This small router covers all three and gives you a foundation to extend with nested routes, guards, and lazy loading.