00 An explorable guide · the History API · URLSearchParams · routers
Stack. URL. Router.
The browser tab is a state machine.
A tab looks like a document viewer, but it's really a small runtime with a timeline. The history stack owns where the tab has been and can go; the URL bar is a live view of the current entry and an input for the next one; the History API gives JavaScript levers over that timeline; and a router teaches your app to speak it. The deep idea: the URL isn't just where your app is — it's what your app currently means. Press a button below to send a navigation through the machine, then click any box to go deeper.
Every route through the machine ends the same way: the router reads the current URL and repaints. Full navigations detour through the server for a fresh document; client navigations, Back/Forward and refinements never leave the tab — they just move or edit the timeline, then re-render. Click the History Stack, URL Bar, Router or Server boxes for the deep dive.
01 First principles · one structured string
A URL is layered state, not just an address.
Every part of a URL answers a different question — and each layer carries a different kind of state. The path is identity-level state: what thing am I looking at? The query string is modifier state: viewed how — filtered, sorted, paginated? The hash is client-side fragment state: which section, which substate? One subtlety worth locking in: the hash is never sent to the server — GET /products/shoes?brand=nike goes over the wire; #reviews stays in the tab.
Hover a colored segment to spotlight its card — this is exactly what new URL(string) computes. Try deleting the query, adding a second brand=, or removing the protocol to see the parser reject it.
https://shop.example.com/products/shoes?brand=nike&sort=price-asc&page=2#reviews means: use HTTPS → go to shop.example.com → request the shoes listing → apply view state brand=nike, sort=price-asc, page=2 → then, client-side only, focus the #reviews section.02 Mechanism one · the timeline
The history stack is a list plus a cursor.
Every navigation creates a history entry — not just a string, but a URL plus a state object, a scroll position, and restore info. Back and Forward don't create anything: they move the cursor along entries that already exist. And the stack's one ruthless rule: navigate anywhere new while the cursor sits mid-timeline, and the future is discarded. Drive the browser below and watch all three behaviors.
Try the future-discard: visit a few pages, press ◀ Back twice, then click any link — the dashed future entries vanish. Notice the filter buttons merge onto the current query instead of clobbering it: that's url.searchParams.set(), the fix for bug #1 in section 08.
03 Mechanism three · the levers
pushState creates a moment. replaceState edits the current one.
The History API is tiny — pushState, replaceState, back, forward, go, and one event: popstate. The classic trap is a search box that calls pushState on every keystroke: typing nike plants four back-button stops, and escaping the page means walking back through nik → ni → n. The rule: replace while refining, push on intent. Type in the box below under each strategy and watch what the stack — and your Back button — inherit.
The popstate detail: calling pushState changes the URL and stack but fires no event — your code did it, your code knows. popstate fires only when an existing entry is activated: Back, Forward, history.go(). Routers listen to popstate to re-render on Back.
// user changed page, opened a product,
// applied a major filter, moved a wizard step
const url = new URL(location.href)
url.searchParams.set('page', '2')
history.pushState(null, '', url)
// stack: [ …, ?page=1, ?page=2 ] ← new stop// typing, cleaning invalid params,
// filling defaults, noisy intermediate state
const url = new URL(location.href)
url.searchParams.set('q', input.value)
history.replaceState(null, '', url)
// stack: [ …, ?q=nike ] ← same stop, edited04 The parser · the browser's built-in state (de)serializer
URLSearchParams is a list of pairs, not an object.
A query string is text; your app wants structure. URLSearchParams converts both ways — but its internal model is [[key, value], …], not {key: value}. That's deliberate: query strings allow duplicate keys (?brand=nike&brand=adidas), which is why .get() returns only the first match and .getAll() exists. And every value is a string — ?page=2&inStock=false gives you "2" and "false"; your app supplies the meaning.
.set replaces every duplicate with one value · .append adds another · mutations rewrite the query string above in real time — the pairs list is the source, the string is just its serialization.
Encoding is the whole point
&, =, ? and # are syntax. When a value contains them, hand-concatenated strings become ambiguous — does ?redirect=/dash?tab=billing&mode=edit have two params or one? URLSearchParams escapes on the way out and decodes on the way in, which is why you should never build query strings with + concatenation.
space → + · & → %26 · = → %3D · # → %23 — try pasting a whole URL as the value: nested URLs are the classic case for redirect= params.
Present, empty, missing — three different facts
These five URLs look interchangeable and aren't. Click each to interrogate the instance:
Boolean(params.get('inStock')) is true for "false" — any non-empty string is truthy. Decode explicitly: params.get('inStock') === 'true', and for numbers validate: Number.isInteger(n) && n > 0 ? n : 1. Section 08 turns this into a bug hunt.05 The architecture · one loop, no sync code
Don't sync state to the URL. Derive state from it.
The failure mode is duplicated truth: the URL says ?page=2, a hidden useState says page = 5, and an effect tries to keep them married. The strong model treats the URL like reducer state: a user action doesn't set component state — it proposes a new URL. The browser records it; the app re-derives everything. One direction, no reconciliation, and Back/Forward work for free because the browser already saw every state. Play the loop:
UI = f(URL) — more completely, UI = render(data(parse(match(URL)))). When the URL changes for any reason — click, Back, bookmark, pasted link, edited address bar — the same function runs. That's the whole trick.
// React state is "truth"; URL tries to keep up
const [page, setPage] = useState(1)
useEffect(() => {
// sync… sometimes… hopefully
history.replaceState(null, '', `?page=${page}`)
}, [page])
// Back button → URL changes → state doesn't → 🐛// read: derive from the URL every render
const params = useSearchParams()
const page = parsePositiveInt(params.get('page'), 1)
// write: propose the next URL — that's the action
function nextPage() {
const url = new URL(location.href)
url.searchParams.set('page', String(page + 1))
router.push(url.pathname + url.search)
}06 The interpreter · from address to component tree
The browser sees URLs. The route table gives them meaning.
The browser has no idea /products/:id means “render ProductDetailPage with a param.” That mapping — pattern → component (plus layouts, loaders, boundaries) — is the route table, and a router is essentially render(match(url)). Feed the matcher below and watch it walk the table:
| pattern | renders | match? |
|---|---|---|
| / | HomePage | |
| /products | ProductListPage | |
| /products/:id | ProductDetailPage | |
| /dashboard | DashboardPage | |
| /dashboard/orders/:id | OrderDetailPage |
Patterns are checked for the most specific win; dynamic segments like :id capture into params; the query never participates in matching — it arrives separately as searchParams.
Two roads to the same table
File-based routing treats the filesystem as a route DSL — [id] means dynamic segment, page.tsx means leaf, layout.tsx nests. Code-based routing declares the same table as data. The framework scans folders at build time; the browser never sees them. Toggle:
07 The judgement call · not everything belongs in the address bar
Answer three questions. Learn where the state lives.
Search queries, filters, pagination, tabs, date ranges: URL. Hover state, drag state, half-typed input: memory. Theme preference: localStorage. Session identity: cookie. Orders and profiles: database. When it's not obvious, run the decision — or click a preset to see it answered:
Referer headers, screenshots, and every shared link. That's bug #6 next door.08 The failure modes · every one of these ships weekly
Point the bug detector at a symptom.
URL-state bugs are recognizable from the outside. Pick the complaint as a user would say it; get the diagnosis, the offending line, and the fix.
09 The consequence · URLs are cache keys
Different string, different cache entry — even when the meaning is identical.
To the server, the CDN, and the browser cache, ?brand=nike&sort=price-asc and ?sort=price-asc&brand=nike are two different resources. Add a noisy trackingId and every visitor computes the page from scratch. The cure is a canonical URL: strip the noise, params.sort(), and logically-equal states collapse into one key — better cache hits, cleaner SEO, deduplicated bookmarks, reproducible tests. Watch four requests hit a cache, then canonicalize them:
canonicalize(url) = drop params not in the schema · omit defaults (page=1, sort=relevance) · params.sort() for stable order. Prefer /products over /products?q=&page=1&sort=relevance — let the parser fill defaults.
const SCHEMA = new Set(['q','brand','sort','page'])
const DEFAULTS = { page: '1', sort: 'relevance' }
function canonicalize(href) {
const url = new URL(href)
for (const key of [...url.searchParams.keys()]) {
if (!SCHEMA.has(key)) url.searchParams.delete(key) // noise out
else if (url.searchParams.get(key) === DEFAULTS[key])
url.searchParams.delete(key) // defaults out
}
url.searchParams.sort() // stable order
return url.pathname + url.search
}// URL state pairs naturally with SSR:
// the server sees path + query BEFORE any JS runs
export default async function Page({ searchParams }) {
const params = await searchParams // Next.js 15+
const brand = params.brand ?? 'all'
const page = parsePositiveInt(params.page, 1)
// HTML arrives already filtered — no client
// fetch, no flash of unfiltered content
const products = await getProducts({ brand, page })
return <ProductGrid products={products} />
}10 Reference · pin these to the wall
Where state can live — and the params API on one card.
| location | visible in URL? | survives reload? | server can read? | shareable? | reach for it when… |
|---|---|---|---|---|---|
useState / memory | no | no | no | no | hover, drag, open dropdowns, unsaved keystrokes |
| URL query | yes | yes | yes | yes | search · filters · sort · page · tabs · date ranges |
| URL hash | yes | yes | usually no | yes | section anchors, pure client substate |
localStorage | no | yes | no | no | theme, drafts, device-specific preferences |
| cookie | no | yes | yes | no | session identity, server-readable prefs, A/B bucket |
| database | no | yes | yes | no | orders, profiles, saved settings, permissions |
URLSearchParams — the whole API you actually use
| call | returns | the catch |
|---|---|---|
new URLSearchParams(str | obj | pairs) | instance | leading ? optional; everything becomes strings |
.get(key) | string | null | first value only when keys repeat |
.getAll(key) | string[] | the duplicate-keys API — ?brand=nike&brand=adidas |
.has(key) | boolean | ?discount → has true, get "" |
.set(key, v) | — | replaces all existing values with one |
.append(key, v) | — | adds another value, keeps existing |
.delete(key) | — | removes every value for the key |
.sort() | — | canonical ordering for cache keys |
.toString() | string | encodes: space→+, &→%26 |
for (const [k, v] of params) | pairs | iterates duplicates too — it's a list, not an object |
url.searchParams | live view | mutations update url.href; a standalone instance updates nothing — you must push it |
11 Lock it in
The router doesn't replace the browser's state machine.
It teaches your app to speak it.
Everything in this guide is one loop wearing different hats. The history stack owns the timeline. The URL bar shows the current state and accepts the next one. The History API is the set of levers; the router interprets the address through a route table built from files or code. And URLSearchParams is the serializer that lets your state ride inside the address.
Strong architecture URL → parse → validate → derived state → data → UI UI event → next URL → (the same pipeline) Weak architecture memory state → effects → sync URL maybe → fetch maybe → fix Back button maybe The deep idea The URL is not just where your app is. The URL is what your app currently means.