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.
- 01Read an address safelyRead the parts of
location, likepathnameandhash, without changing the real page. - 02Picture the history listExplain the list of entries, the pointer to the current one, why entries ahead get dropped, the saved
state, and whenpopstatefires. - 03Pick a kind of routeCompare normal links, hash routes, History API routes, and the Navigation API.
The central idea
A browser tab remembers the pages you visited in it. This memory is called the session history. It has two parts:
- a list of entries, one for each place you visited in this tab;
- a pointer that marks which entry is the current one.
When you press Back, the browser moves the pointer to the entry before. When you press Forward, it moves the pointer to the entry after.
Many web apps load one page and then change what it shows, without loading a new page. This is called client-side routing. The code that does it is called a router. A router can show /inbox, then /inbox/42, and go back to /inbox when you press Back. A router does four jobs:
- It reads the current address (the URL).
- It works out which screen that address stands for.
- It shows that screen.
- It keeps the session history up to date.
The demos on this page never change the real address or your real history. They never call history.pushState, history.replaceState, or navigation.navigate. They never set location or location.hash. This site's own router (from Next.js) controls this page's address, so your Back button keeps working. The demos use small, tested pretend versions instead. They only read real browser values after the page has loaded.
Imagine you drop a breadcrumb at each step on a forest path. Walking back or forward drops no new crumbs. You only move along the crumbs that are already there. Now say you walk back a few steps and then take a different path. The crumbs ahead of you on the old path are no longer part of your trip.
- In real life: The breadcrumbs on the path
- In JavaScript: The entries in this tab's history
- In real life: The crumb where you stand
- In JavaScript: The current entry, marked by the pointer
- In real life: Walking back or forward along the crumbs
- In JavaScript: Pressing Back or Forward
- In real life: Walking back, then taking a new path
- In JavaScript: A new entry after Back drops the entries ahead
This lesson uses ideas from these earlier lessons: URL & URLSearchParams, Introduction to events, Default actions, and Event delegation.
Read the current address with location
INTERACTIVEwindow.location is an object that describes the address of the current page. You can read these parts of it:
href: the whole address.origin: the scheme, host, and port, likehttps://shop.example. Two addresses with the same origin belong to the same site.pathname: the path, like/app/products.search: the part from?on, like?sort=popular.hash: the part from#on, like#details.
Reading these parts is safe. Setting many of them makes the browser go to a new address. So the inspector below only reads this page's real address, after the page has loaded. For the address you type, it uses new URL(input, base). That builds a separate URL object with the same parts, and it changes nothing on the page.
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);This page
Reading this page's address…
history.length: reading…
The address you typed, read against
https://shop.example/app/catalog/
https://shop.example/app/products?sort=price#reviewshttps://shop.example/app/products?sort=price#reviewsThe first list reads this lesson page's real address, after the page has loaded. The second list reads the address you type, with new URL(input, base). Nothing here changes the real address or your Back button.
location is safe. Setting location or location.hash would make the browser go somewhere, so this lesson never does that.What each part is for:
pathnameis the route. It is the main part a router looks at.searchholds small settings you want to share in a link, like filters.hashis the part after#. It can point to a heading on the page, or it can hold a route. The browser does not send it to the server when it loads a page.
The table below shows four ways to change the address with location. Read each row as: when I write this, this is what happens, and this is what Back does next.
| When you write | What happens | What Back does next |
|---|---|---|
location.href = url | The browser loads a new page. | Back returns to the old page. |
location.assign(url) | The browser loads a new page. | Back returns to the old page. |
location.replace(url) | The browser loads a new page in place of the current entry. | Back skips the old page. |
location.hash = value | Usually the page stays, and only the hash changes. | Back may return to the old hash. |
const next = "/checkout"; // In a browser, each line below loads a new page.location.href = next;location.assign(next);location.replace(next);Line 4 and line 5 both load the new page and keep the old page in the history list, so Back can return to it. Line 6, location.replace(next), loads the new page too. But the new page takes the place of the current entry, so Back skips the old page. That is useful after a one-time step, such as a finished sign-in.
Setting only location.hash usually stays on the same page. It can add a new entry for the new hash.
Session history: a list of entries and a pointer
INTERACTIVEYou can picture a tab's history as an array of entries plus a number, index. The number points at the current entry, so we call it the pointer.
- Back makes the pointer 1 smaller.
- Forward makes the pointer 1 bigger.
- A new entry added while the pointer is in the middle first removes every entry ahead of the pointer. Then it goes at the end.
The simulator below is a pretend history list. Its buttons change only the list in the simulator, not your real history.
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 when popstate fires. pushState and replaceState never fire it.
Press these buttons in order: pushState, User clicks link, Back, then pushState. The /about entry disappears. Also watch the log. Adding or replacing an entry never logs popstate. Moving with Back, Forward, or go(-2) does.
/about was ahead of you. pushState drops /about and adds the new entry in its place.pushState, replaceState, and popstate
STEP THROUGHThe History API is the set of tools on history that let your code add or change entries in this tab's list. Two of them change entries:
history.pushState(state, "", url)adds a new entry and makes it the current one.history.replaceState(state, "", url)changes the current entry instead of adding one.
Each one takes three values:
stateis a small piece of data saved with the entry, like{ route: "/products" }.""is an old value that browsers no longer use. Always pass an empty string.urlis the new address. It must have the same origin as the current page.
Neither one loads a page. The address bar changes, and the page stays as it is.
Back, Forward, and history.go() move the pointer to an entry that already exists. When that happens, the browser fires a popstate event. Its event.state is the state saved with that entry.
Dropping a new crumb is not walking. Swapping the crumb under your feet is not walking either. The bell only rings when you walk back or forward along the crumbs. In the same way, pushState and replaceState never fire popstate.
- In real life: Drop a new breadcrumb
- In JavaScript:
pushStateadds a new entry - In real life: Swap the crumb you stand on
- In JavaScript:
replaceStatechanges the current entry - In real life: A bell rings each time you walk to another crumb
- In JavaScript:
popstatefires when the pointer moves
The browser saves a copy of state, not the object you passed. The code below shows this with structuredClone(). It is a built-in function that makes a deep copy: a copy of the object and of every object inside it. The History API copies state in the same way.
const state = { page: "cart", filters: { color: "blue" } };const saved = structuredClone(state);state.filters.color = "red";console.log(saved.filters.color);console.log(state.filters.color);console.log(saved === state);In plain words, line by line:
- Line 1 makes
state, with an innerfiltersobject. - Line 2 copies it into
saved. - Line 3 changes the color in
stateto"red". - Line 4 prints
blue. The copy did not change. - Line 5 prints
red. - Line 6 prints
false, becausesavedandstateare two different objects.
structuredClone made the copy while the color was still "blue". Line 3 changes state to "red", and the copy stays "blue".Some values can't be copied this way: functions, and DOM nodes (the objects for elements on the page). Browsers may also limit how big the state can be. So keep the state small, like a route name or a filter object. Put bigger data in storage, such as sessionStorage, and save only its key in the state.
The table below shows what you can put in state. Read each row as: this kind of value, can it go in, and what to do.
| What you put in | Can it go in? | What to do |
|---|---|---|
| Plain objects, arrays, strings, and numbers | Yes. | Save only the small data you need to show the screen. |
| Functions and DOM nodes | No. | Save an id or a string instead. |
| Large data | Maybe not. | Put it in sessionStorage or your app's data, and save its key. |
The object you passed to pushState | Only a copy goes in. | Changing your object later does not change history.state. |
A router uses these tools at two moments: when the user clicks a link, and when the user presses Back. The step-through below follows both. In the History API version of its code, console.log("pushState " + href) stands in for the real history.pushState call, so this page's address doesn't change.
Choose a router style. Guess the order of the printed lines, then step through. This is a pretend model: it never touches this 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: "/" } });After its own pushState call, the router shows the new screen right away, because no event will come. Later, when the user presses Back, the popstate listener shows the screen named in event.state.
pushState adds an entry and fires no event, so the router shows the new page itself. Back moves to an entry that already exists, and that fires popstate.In modern browsers, moving Back or Forward between two hashes on the same page fires popstate too. There is also a separate event just for hash changes, called hashchange.
When you press Back or Forward, browsers usually scroll the page back to where it was. history.scrollRestoration controls this. It is "auto" by default, which means the browser restores the scroll position. A router can set it to "manual" and restore the position itself. Then the router must also move the scroll and the keyboard focus to the right place. Keep "auto" unless your app has a clear plan for this.
console.log(history.scrollRestoration);history.scrollRestoration = "manual";console.log(history.scrollRestoration);In a browser, line 1 prints auto, the default. Line 2 switches to manual. Line 3 prints manual.
Hash routing: the route after #
INTERACTIVEA hash route keeps the route in the hash, the part of the address after #. For example, in https://site.example/app#/products, the page is /app and the route is #/products.
A book has sticky tabs that mark its parts. To reach another part, you open the same book at another tab. You don't fetch a new book. The server only hands over the book: /app. The browser keeps the sticky tab: #/products.
- In real life: The book itself
- In JavaScript: The loaded page, such as
/app - In real life: A sticky tab inside the book
- In JavaScript: The hash, such as
#/products - In real life: Opening the book at another tab
- In JavaScript: Changing only the hash, on the same page
Hash addresses look less tidy than clean paths. But they work on almost any host that serves plain files, with no extra setup. The lab below shows the same screens with both kinds of route.
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 good app address. But a direct visit only works if the host sends it to the app's main page (a rewrite).
The table below compares the two ways to keep a route in the address. Read each row as a question, with one answer for each kind of route.
| Question | History API route | Hash route |
|---|---|---|
| What part of the address changes? | The path and search, like /products?page=2. | The hash, like #/products?page=2. |
| Does the server get the route? | Yes, after a refresh or a direct visit. | No. The part after # stays in the browser. |
| What does a direct visit need? | The host must send app paths to index.html (a rewrite). | Nothing extra. It works on almost any host of plain files. |
| Which events do you listen for? | Link clicks and popstate. | Link clicks, hashchange, and moves with Back or Forward. |
The code below splits one address into its path and its hash.
const url = new URL("https://site.example/app#/products?tag=js");console.log(url.pathname);console.log(url.hash);Line 2 prints /app, because the path stops at #. Line 3 prints #/products?tag=js, the whole hash with its #. The ?tag=js here is part of the hash, not the search part.
/app only. Everything from # onward stays in the browser, where your router code reads it.Which kind of route to use:
- Use History API routes, like
/products, when you can set up your host to send every app path to the app's main page,index.html. This host setting is called a rewrite. - Use hash routes, like
#/products, when you can't change the host's settings and links must still work everywhere. - In both cases, keep normal links for going to a real new page.
Changing the hash for real can fire hashchange. Changing the address with pushState or replaceState does not, even when the new address has a different hash. So pick one event to listen for, and update the screen only from that event. Otherwise the app may draw the same screen twice.
Where you will use this
SORTERA router sits between what the browser does and what your app does. You will use these rules in dashboards, product filters, documentation sites, pop-ups that have their own address, tabbed screens, and sign-in steps.
For each move, ask one question. Should it load a new page, change a clean path with the History API, or change only the hash on the same page?
- 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 plain files with no rewrite rules
- Jump to
#commentsin a long article
Put each need under the simplest way that works for it.
- Give every link a real address in
<a href>, so people can copy it or open it in a new tab. - Cancel a click's default action with
preventDefault()only for links on your own site that your app will show itself. - After your own
pushState, show the new screen right away. Whenpopstatefires, show the screen fromevent.state. - Set up rewrites on your host before you ship History API routes, so direct visits work.
Common misconceptions
“Reading location makes the page move.”
Reading is safe. Setting location.href or location.hash can make the browser go to a new address.
“pushState fires popstate.”
It does not. Show the new screen right after you call pushState. Listen for popstate to know when the user presses Back or Forward.
“replaceState adds a new step for the Back button.”
It changes the current entry. That is handy for cleanup. For example, after a sign-up pop-up closes, you can replace ?modal=signup in the address.
“The server sees hash routes.”
The part after # stays in the browser. That makes hash routes easy to host as plain files. It also means the server can't use the route.
“Clean paths always work after a refresh.”
Only if the host sends app paths to the app's main page (a rewrite). Without that, a direct visit can show a 404 "not found" page.
“The Navigation API replaces all routing code today.”
It is useful, but not every browser has it. You still need to check for it and keep a fallback.
Practice: history and routing
5 EXERCISESPredict the first line this small history model prints.
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 code adds /shop, moves index back to 0, removes the entries after it, then adds /help. The first line printed is /home -> /help.
Does a path that starts with / stay on the same origin? Predict the first line.
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 is read against https://app.example, so the new URL has the same origin. The first line is true, and the second line is /cart.
Predict the second line this code prints.
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 whole hash, #/products?tag=js.
event.statePredict what a Back-button handler shows.
const routes = { "/": "Home", "/settings": "Settings" };
const event = { state: { route: "/settings" } };
console.log(routes[event.state.route]);A popstate handler shows the screen for the entry you moved to. Here event.state.route is /settings, so the program prints Settings.
You are building a documentation app made of plain files. Which kind of route would you pick if your host supports rewrites? Which would you pick if it doesn't? Write down both, and say why.
// If rewrites are available:
/docs/getting-started -> History API route
// If rewrites are not available:
/docs/#/getting-started -> hash routeBoth show the same screen. The host decides which address still works after a refresh or a direct visit.
Quiz: check your understanding
9 QUESTIONSRead each explanation, even when your first answer is right. Many router bugs come from mixing up three things: adding an entry, replacing an entry, and moving between entries.
Question 1 of 9This small history model goes back, then adds a new entry. What does the first line 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 9When does
popstatefire for entries made with the History API?Choose an answer to see the explanation.
Question 3 of 9This code compares two origins. What does the first line print?
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 9Which sentence about hash routes is true?
Choose an answer to see the explanation.
Question 5 of 9This code splits an address into parts. What does the second line print?
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 9Why does an app with History API routes need a rewrite on the host for direct visits?
Choose an answer to see the explanation.
Question 7 of 9What is the main idea of the Navigation API?
Choose an answer to see the explanation.
Question 8 of 9Which one loads a new page in place of the current history entry?
Choose an answer to see the explanation.
Question 9 of 9This code copies an object, then changes the original. What does the first line print?
Read the code, then predictconst state = { page: "cart", filters: { color: "blue" } }; const saved = structuredClone(state); state.filters.color = "red"; console.log(saved.filters.color); console.log(state.filters.color); console.log(saved === state);Choose an answer to see the explanation.
Key takeaways
locationholds the parts of the current address. Reading them is safe.- Session history is a list of entries plus a pointer to the current one. A new entry added from the middle drops the entries ahead.
pushStateandreplaceStateneed an address with the same origin. They never firepopstate.popstatefires when Back, Forward, orgo()moves the pointer.hashchangefires when the hash changes.- Hash routes work on almost any host. History API routes need a rewrite on the host so direct visits work.
- Not every browser has the Navigation API. Check for it, and keep a fallback.
Remember the one-liner.
Going somewhere means one of two things: loading a new page, or moving the current page's history pointer. A router makes pointer moves look like new pages.
Up next: Windows, iframes & postMessage.