History & navigation
Change the URL without reloading, inspect location parts, and understand how History API, hash routing, popstate, and the Navigation API power client-side routers.
- 01Inspect a URL safelyRead
location-like parts without changing the real page. - 02Model browser historyExplain entries, the current pointer, forward truncation, state objects, and
popstate. - 03Pick a routing styleCompare normal links, hash routes, History API routes, and the Navigation API.
The central idea
A browser tab remembers where it has been. That memory is called the session history: a list of entries plus a current pointer. When you press Back, the browser usually moves that pointer to an older entry. When you press Forward, it moves to a newer one.
Modern web apps use that same memory to feel like many pages while staying inside one loaded document. A router can show /inbox, then /inbox/42, then return to /inbox when Back is pressed. The trick is not magic: read the current URL, decide what route it means, render the matching screen, and keep history in sync.
The experiments never call history.pushState, never call history.replaceState, never assign to location or location.hash, and never call navigation.navigate on this lesson page. The Next.js router owns the real address here, so your Back button keeps working. Demos use tested in-page models plus read-only browser facts after mount.
Imagine dropping breadcrumbs on a trail. Back and Forward do not create crumbs; they move you along crumbs that already exist. If you walk back and then choose a new path, the old forward crumbs no longer belong to your trail.
- In real life: Breadcrumbs on the path
- In JavaScript: History entries in this tab
- In real life: Where you stand now
- In JavaScript: The current index or pointer
- In real life: Walk backward or forward
- In JavaScript: Back and Forward traverse entries
- In real life: Start a new branch
- In JavaScript: A new push after Back drops forward entries
Where the analogy stops: Real breadcrumbs do not store JavaScript state objects. Browser entries can hold a structured-cloned state value as well as a URL.
We will use ideas from URL & URLSearchParams, Introduction to events, Default actions, and Event delegation.
Read the current address with location
INTERACTIVEThe window.location object describes the current document address. You can read href, origin, pathname, search, and hash. Reading is harmless. Assigning to many location properties navigates, so our inspector only reads the real page after mount and uses new URL() for the editable address.
const url = new URL(input, base);console.log(url.href);console.log(url.origin);console.log(url.pathname);console.log(url.search);console.log(url.hash);Current page
Reading browser location…
history.length: reading…
Editable URL with base
https://shop.example/app/catalog/
https://shop.example/app/products?sort=price#reviewshttps://shop.example/app/products?sort=price#reviewsThe left panel reads this lesson page after mount. The right panel parses your input with new URL(input, base), so experimenting never changes the real address or Back button.
location is safe. Assigning to location, location.hash, or calling router navigation would navigate, so this lesson does not do that.Treat pathname as the clean route path, search as small shareable state like filters, and hash as the fragment after #. The hash can identify a heading or act as a client-side route, but it is not sent to the server during normal navigation.
Session history as entries plus a pointer
INTERACTIVEA tab’s history is not just a stack you can push and pop. It is more like an array with a current index. Back decreases the index. Forward increases it. A new visit from the middle keeps entries up to the pointer and discards the future branch.
const stack = ["/home"];let index = 0;function push(url) { stack.splice(index + 1); stack.push(url); index = stack.length - 1;}function back() { if (index > 0) index = index - 1;}- Current
/homedocument state: {"page":"home"}
loaded /home
Current entry: /home. The log says whether a traversal fired popstate; pushState and replaceState do not.
Try this sequence: pushState, User clicks link, Back, then pushState. The old forward entry disappears. Notice the log too: adding or replacing an entry does not log popstate; traversal does.
pushState, replaceState, and popstate
STEP THROUGHThe History API lets code add or edit same-origin entries. The signatures are history.pushState(state, "", url) and history.replaceState(state, "", url). The second argument exists for historical reasons; pass an empty string. The URL must be same-origin. The state value is structured-cloned, so keep it small and serializable.
Adding a crumb and changing the sign does not mean you walked back or forward. That is why pushState and replaceState do not fire popstate. The bell rings when the traveler moves along the trail.
- In real life: Add a breadcrumb
- In JavaScript:
pushStatecreates a new entry - In real life: Rewrite the sign on this spot
- In JavaScript:
replaceStateedits the current entry - In real life: A bell rings when the traveler moves
- In JavaScript:
popstatefires on traversal
Where the analogy stops: A signpost does not enforce web security. Browsers do: the URL passed to pushState or replaceState must stay on the same origin.
Choose a router style, predict the render order, then step through the model. It records the same sequence a browser router uses without touching this lesson page's real history.
script
"/": "Home page", "/products": "Products page",};let url = "/";let state = { route: "/" };function render(route) { console.log(routes[route] ?? "Not found");}function clickLink(href) { event.preventDefault(); state = { route: href }; url = href; console.log("pushState " + href); render(state.route);}function onPopState(event) { state = event.state; render(state.route);}clickLink("/products");onPopState({ state: { route: "/" } });A router usually renders immediately after its own pushState call. Later, a popstate listener renders from event.state. Same-document fragment navigations can also participate in traversal and fire popstate in modern browsers; hashchange is the fragment-specific event.
Hash routing: sticky tabs inside one book
INTERACTIVEA hash route puts app state after #. The server sees the book path, while the browser keeps the sticky tab. That is why hash routes are less pretty but very easy to deploy on static hosts.
- In real life: The book itself
- In JavaScript: The loaded document, such as
/app - In real life: A sticky tab
- In JavaScript: The fragment, such as
#/products - In real life: Moving to another tab
- In JavaScript: A same-document hash navigation
Where the analogy stops: A sticky tab is visible to a person holding the book. A URL fragment is not sent to the server in a normal request; client code reads it after the page loads.
const historyRoutes = { "/": "Home", "/products": "Products" };const hashRoutes = { "#/": "Home", "#/products": "Products" };function render(mode, route) { const table = mode === "history" ? historyRoutes : hashRoutes; return table[route] ?? "Not found";}render("history", "/products");render("hash", "#/products");https://site.example/productsNeeds a rewrite to index.html
This clean path is a great app URL, but a direct visit needs the host to rewrite it to the app shell.
| Question | History API route | Hash route |
|---|---|---|
| What changes? | The path/search, such as /products?page=2 | The fragment, such as #/products?page=2 |
| Sent to server? | Yes on reload or direct visit | No. The part after # stays in the browser |
| Deep-link requirement | Server/host must rewrite app paths to index.html | Works on almost any static host |
| Events to listen for | Clicks plus popstate | Clicks plus hashchange and traversal |
Use History API routes when your host can rewrite unknown app paths to the app shell. Use hash routes when you cannot configure the host and need direct links to work anywhere. Either way, keep normal links for real document navigations.
Where you will use this
SORTERRouters sit at the boundary between browser behavior and app behavior. You will use these rules when building dashboards, product filters, documentation sites, modal routes, tabbed interfaces, and login flows. The professional question is not “Can JavaScript change the URL?” It is “Should this be a real document load, a clean client route, or a fragment inside one document?”
- Open
https://docs.example - Go from
/pricingto/contacton a simple site - Switch
/inboxto/inbox/42inside a dashboard - Save
/products?color=blueafter a filter click - Host a tiny app on static files with no rewrite rules
- Jump to
#commentsin a long article
Sort each need into the simplest reliable navigation style.
- Use real
<a href>values so links can be copied and opened in new tabs. - Cancel the default action only for same-origin routes your app will render.
- Render after your own push, and render from
event.stateon traversal. - Configure server rewrites before shipping History API deep links.
Common misconceptions
“Reading location navigates.”
Reading is safe. Assigning to location.href or location.hash can navigate.
“pushState fires popstate.”
It does not. Render immediately after pushing. Listen for popstate when the user traverses history.
“replaceState adds a new Back-button step.”
It edits the current entry, which is perfect for cleanup like replacing ?modal=signup after the modal closes.
“The server sees hash routes.”
The fragment after # stays in the browser. That is useful for static hosting and limiting for server-side handling.
“Clean paths always work on refresh.”
Only if the server or host rewrites app paths to the app shell. Without that, a direct visit can be a 404.
“The Navigation API replaces all routing code today.”
It is promising, but not universal. Feature detection and fallbacks are still part of the design.
Practice: history and routing
5 EXERCISESPredict the first line printed by this tiny stack model.
const stack = ["/home"];
let index = 0;
stack.push("/shop");
index = 1;
index = index - 1;
stack.splice(index + 1);
stack.push("/help");
console.log(stack.join(" -> "));
console.log(index);The model visits /shop, moves back to index 0, deletes forward entries, then pushes /help. The first printed line is /home -> /help.
Decide whether a same-origin path stays on the same origin.
const current = new URL("https://app.example/home");
const next = new URL("/cart", current.origin);
console.log(next.origin === current.origin);
console.log(next.pathname);/cart resolves against the current origin, so the comparison prints true, then the pathname is /cart.
Predict the second line from the URL parser.
const url = new URL("https://example.com/app#/products?tag=js");
console.log(url.pathname);
console.log(url.hash);The first line is /app. The second line is the full fragment string, #/products?tag=js.
event.statePredict what a Back-button handler renders.
const routes = { "/": "Home", "/settings": "Settings" };
const event = { state: { route: "/settings" } };
console.log(routes[event.state.route]);A popstate handler should render the destination entry. Here event.state.route is /settings, so the program prints Settings.
You are shipping a static documentation app. Write down which route shape you would choose with rewrite support and without rewrite support, and explain why.
// If rewrites are available:
/docs/getting-started -> History API route
// If rewrites are not available:
/docs/#/getting-started -> hash routeBoth can render the same screen. The deployment environment decides which URL shape is reliable on refresh and direct visits.
Quiz: check your understanding
7 QUESTIONSRead each explanation, even when your first answer is right. Router bugs often come from mixing up adding entries, replacing entries, and traversing entries.
Question 1 of 7What does this forward-truncation model print?
Read the code, then predictconst stack = ["/home"]; let index = 0; stack.push("/shop"); index = 1; index = index - 1; stack.splice(index + 1); stack.push("/help"); console.log(stack.join(" -> ")); console.log(index);Choose an answer to see the explanation.
Question 2 of 7When does
popstatefire for History API entries?Choose an answer to see the explanation.
Question 3 of 7What same-origin check does this print first?
Read the code, then predictconst current = new URL("https://app.example/home"); const next = new URL("/cart", current.origin); console.log(next.origin === current.origin); console.log(next.pathname);Choose an answer to see the explanation.
Question 4 of 7Which statement about hash routes is accurate?
Choose an answer to see the explanation.
Question 5 of 7What does this hash parsing snippet print second?
Read the code, then predictconst url = new URL("https://example.com/app#/products?tag=js"); console.log(url.pathname); console.log(url.hash);Choose an answer to see the explanation.
Question 6 of 7Why does a History API app need server rewrite rules for deep links?
Choose an answer to see the explanation.
Question 7 of 7What is the Navigation API idea?
Choose an answer to see the explanation.
Key takeaways
locationexposes the current URL parts; reading them is safe.- Session history is entries plus a current pointer; new pushes from the middle drop forward entries.
pushStateandreplaceStaterequire same-origin URLs and do not firepopstate.popstateis for traversal;hashchangeis for fragment changes.- Hash routes work almost anywhere; History API routes need server rewrite support for deep links.
- The Navigation API is a progressive enhancement: feature-detect it and keep fallbacks.
Remember the one-liner.
Navigation is either loading a new document or moving the current document’s history pointer; routers make pointer movement render like pages.
Up next: Windows, iframes & postMessage.