Client-side routing: the History API, the Navigation API, and SPA vs MPA
How client-side routing works with pushState and popstate, hash routing, why deep links 404 without a server fallback, scroll and focus after navigation, the Navigation API, SPA vs MPA vs islands, prefetching and speculation rules.
Comes up in: Web fundamentals, Coding, Machine coding
Learn#
Lakshmi’s React app works perfectly until a user shares a link to /orders/42. The shared link shows the host’s 404 page, and the Back button scrolls to the wrong place.
Client-side routing changes the page without loading a new document. The History API does the URL part: history.pushState(state, "", "/orders/42") changes the address bar and adds a history entry, without any request. When the user presses Back or Forward, the browser fires popstate, and your router renders the view for the new URL. pushState itself never fires popstate.
The server must cooperate. Opening /orders/42 directly asks the server for that path; if it only knows index.html, it returns 404. Configure a fallback that serves index.html for app routes.
Hash routing (/#/orders/42) avoids that, because the part after # never reaches the server, but it gives uglier URLs and weaker SEO.
State
- URL: /orders
Page
empty
The user clicks a link to /orders/42. The router calls preventDefault, so the browser does not load a new page.
Read the steps as text
- The user clicks a link to /orders/42. The router calls preventDefault, so the browser does not load a new page.
- history.pushState changes the URL to /orders/42 and adds a history entry. No request is sent, and popstate does not fire.
- The router matches /orders/:id with id = 42, renders that view, scrolls to the top and moves focus to the new heading.
- The user presses Back. Now popstate fires; the router reads location and renders /orders, restoring its scroll position.
Predict#
Predict first
Your router calls history.pushState('', '', '/cart'). Does your popstate listener run?
popstate reports navigations the user made through history. Your own pushState call does not fire it, so the router must render after calling pushState itself. This trips up most first router implementations.
Interview angle#
Say it like this — about 30 seconds
“On a link click, the router prevents the default navigation, calls history.pushState with the new URL, and renders the matching view. Back and Forward fire popstate, which the router handles by rendering from location. Deep links 404 when the server has no fallback to index.html. After navigating, I restore or reset scroll, move focus to the new heading, and announce the page title. The Navigation API simplifies this by intercepting every navigation. MPAs with speculation rules or islands are often simpler and faster.”
Follow-up How does client-side routing work without page reloads?
Follow-up Why do deep links 404 on some SPA deployments?
Follow-up How should focus and scroll be handled after a client-side navigation?
Follow-up SPA vs MPA: trade-offs?
Practice#
Guided
Write the matching core of a small router: patterns with parameters, query strings, trailing slashes and a catch-all.
Done when
- The first matching pattern wins;
*catches everything else. :namesegments become decoded params; the query string becomes an object.- Trailing slashes do not change the match.
Remember#
Try to answer each card before you open it. Then add them to your reviews.
Does pushState fire popstate?
No. popstate fires for Back, Forward and history.go.
Why do SPA deep links 404 without server config?
The server has no file at that path; it must fall back to index.html.
What should move after a client-side navigation?
Focus, to the new page's heading or main content.
What do speculation rules do?
Tell the browser which pages to prefetch or prerender before the user clicks.
Go deeper
Four layers. Stop at the one your interview needs; each ends with a quick check.
The core ideaEvery round
history.scrollRestoration = "auto"), but an app that renders late may need to restore it itself.Check yourself: Where does the part after # go?
Nowhere: the browser never sends it to the server.
How it works underneathSenior rounds
navigate event for links, forms, Back and Forward; calling event.intercept({ handler }) turns it into a client-side navigation. Support: Chrome 102, Firefox 147, Safari 26.2 Source: MDN: Navigation API.Check yourself: What does navigation.addEventListener('navigate', …) let you do?
Intercept every navigation, including link clicks and Back, in one place.
Edge cases and trapsWhere people slip
document.title, and focus the new <h1> (with tabindex="-1") or main region, so assistive technology announces the new page.<a href> links so middle-click and “open in new tab” still work.Check yourself: Why move focus after a route change?
Screen-reader and keyboard users otherwise get no sign that the page changed.
In productionStaff-level answers
<script type="speculationrules">) prefetch or prerender likely next pages: Chromium; Safari behind a setting Source: Chrome for Developers: Prerender pages.Check yourself: What is islands architecture?
Mostly static HTML pages with small interactive components hydrated separately.
Finished the five steps? Mark it, and its cards join your reviews.
Last checked 2026-11-01. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.
Interview questions this answers
- How does client-side routing work without page reloads?
- Why do deep links 404 on some SPA deployments?
- How should focus and scroll be handled after a client-side navigation?
- SPA vs MPA: trade-offs?