stack·url·router  inside the browser tab

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.

▤ One tab, four mechanisms
BROWSER TAB · A TINY OS Back / Forward click <Link> type an address GET path?query render User intent clicks · types · Back · Forward History Stack the tab's timeline · entries + a cursor 0 · / 1 · /products 2 · /products?page=2 ▲ current entry URL Bar view of current entry · input for next History API + Router pushState · replaceState · popstate match URL → params → components route table from files or code Rendered UI Products · page 2 UI = f(URL) Server reads path + query · renders HTML
— the tab is idle · send a navigation above —

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.

▤ Live URL dissector — edit it, break it, watch it re-parse

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.

Read it as a sentence. 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.

▤ A simulated browser — the page below is rendered only from the current entry's URL
⬤ shop.example.com
history stack click an entry to history.go(n)
— navigate to write the log —

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 keystroke trap
history stack
— events appear here · note when popstate fires and when it doesn't —

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.

push — a meaningful step
// 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
replace — refine the current step
// 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, edited

04 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.

▤ The params lab — one live URLSearchParams instance
?
// results print here — try .get('brand') vs .getAll('brand')

.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.

▤ Live encoder — params.set('q', yourValue).toString()

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:

The Boolean trap. 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:

▤ The URL state cycle — user clicks “next page”
address bar
derived app state
history stack
1 / 8

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.

weak — two sources of truth
// 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 → 🐛
strong — URL is truth, UI derives
// 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:

▤ The route matcher
patternrendersmatch?
/HomePage
/productsProductListPage
/products/:idProductDetailPage
/dashboardDashboardPage
/dashboard/orders/:idOrderDetailPage
— match a URL to extract params, search, and the layout chain —

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:

Q1
Does it change what the user is looking at — the page, the data, the view?
Q2
Should it survive reload, be shareable in a link, and restore on Back?
Q3
Is it sensitive, huge, or changing every frame?
— answer the questions or pick a preset —
Never in the URL, no matter the answers: passwords, tokens, API keys, card details. URLs leak — into history, logs, analytics, 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.

— pick a symptom above —

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:

▤ The cache-key collapse
// four users ask for the same shoes…

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.

the canonicalizer
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
}
the same idea, server-side
// 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.

locationvisible in URL?survives reload?server can read?shareable?reach for it when…
useState / memorynonononohover, drag, open dropdowns, unsaved keystrokes
URL queryyesyesyesyessearch · filters · sort · page · tabs · date ranges
URL hashyesyesusually noyessection anchors, pure client substate
localStoragenoyesnonotheme, drafts, device-specific preferences
cookienoyesyesnosession identity, server-readable prefs, A/B bucket
databasenoyesyesnoorders, profiles, saved settings, permissions

URLSearchParams — the whole API you actually use

callreturnsthe catch
new URLSearchParams(str | obj | pairs)instanceleading ? optional; everything becomes strings
.get(key)string | nullfirst 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()stringencodes: space→+, &→%26
for (const [k, v] of params)pairsiterates duplicates too — it's a list, not an object
url.searchParamslive viewmutations 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.
The final test. Open your app, copy the address, paste it into a private window. If the screen that appears isn't the screen you left — the state you cared about lived in the wrong place.
Back to JIT-Atlas