QuestionsSystem Design

Design a client-side SPA router

Client-side RouterMediumSystem Design

Design a client-side router for a single-page app. Apply RADIO; cover the History API, lazy routes, and scroll.

What it tests

History API usage, path matching, code-splitting routes, scroll restoration, and deep-link handling.

Approach & answer

Requirements: client-side navigation with no full reloads, deep-linkable URLs, back/forward support, lazy-loaded routes, scroll restoration, and per-route data loading. Architecture: a Router listens to history changes and matches the current path against a route table to pick a component; Links call history.pushState instead of navigating; a popstate listener re-renders on back/forward. Data model: the route table [{ path, component, loader }] plus current location; params parsed from the path. Interface: <Link to>, useParams(), useNavigate(). Optimizations: use the History API (pushState/replaceState + popstate) — intercept Link clicks, preventDefault, push the URL, swap the view; CODE-SPLIT routes with dynamic import() so each route's bundle loads on demand (a big first-load win); prefetch a route's chunk on link hover/focus; restore scroll to top on push but preserve position on back (save scrollY per history entry); handle a not-found route and nested/relative routes. Path matching: convert patterns like /users/:id into a regex capturing params, matching most-specific first. Why History API over hash routing: real paths (/users/1) are clean, server-renderable, and SEO-friendly — hash routing (#/users/1) is a fallback for static hosts that can't rewrite. Critically, the server must rewrite all unknown paths to index.html so a deep link or refresh doesn't 404. Accessibility: move focus to the new view's heading on navigation and announce route changes so screen-reader users aren't stranded after a silent DOM swap.

Use this technique when

Any SPA that needs deep links and back/forward without reloads; reasoning about history, lazy routes, and the index.html rewrite.

Code

function Router({ routes }) {
  const [path, setPath] = React.useState(location.pathname);
  React.useEffect(() => {
    const onPop = () => setPath(location.pathname);
    window.addEventListener('popstate', onPop);       // back/forward
    return () => window.removeEventListener('popstate', onPop);
  }, []);

  const match = routes.find((r) => matchPath(r.path, path));
  const Comp = match ? match.component : NotFound;
  return <Comp params={match ? match.params : {}} />;
}

function Link({ to, children }) {
  return (
    <a href={to} onClick={(e) => {
      e.preventDefault();
      history.pushState({}, '', to);                  // no reload
      dispatchEvent(new PopStateEvent('popstate'));   // trigger re-render
    }}>{children}</a>
  );
}
// Lazy route: { path: '/settings', component: React.lazy(() => import('./Settings')) }

References