The URL as state
Filters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
The problem: a view nobody can find again
Picture a shoe shop with 4,000 products. A shopper picks the Running category, ticks size 42, sorts by price from low to high, and goes to page 3. She finds a pair she likes, copies the address, and sends it to a friend: "which of these do you like?"
The friend opens the link and sees the shop's default list: all 4,000 products, sorted by "Featured", page 1. Every choice the shopper made lived in a useState call inside the page. The address bar still said /shoes, so the link carried none of it.
The same gap shows up in smaller ways. Back from a product lands on page 1 with no filters. A refresh after a network hiccup loses everything, and a bookmark for next week is useless. Support asks for "a link to what you're seeing" and gets nothing they can open.
What went wrong
The page kept state that describes which view the user is looking at in memory only. Memory dies with the tab, does not travel in a link, and is not part of the browser's history. The browser already has a place for this kind of state: the URL.
The fix is to write those choices into the address: /shoes?category=running&size=42&sort=price-asc&page=3. Now the link carries the view, a refresh rebuilds it, Back and Forward step through it, and a bookmark saves it. The server, a CDN, analytics, and your error logs can all read it.
Five things you get for free
- Sharing. Anyone who opens the link sees the same view, on any device (if they can see the data).
- Refresh safety. A reload, a crash, or a restored tab rebuilds the view from the address.
- Back and Forward. Each meaningful change can be a history entry, so Back undoes what the user expects it to undo.
- Bookmarks and history search. Browser history keeps
?q=trail+shoes, so typing "trail" in the address bar finds last week's search. - Server rendering and caching. The server sees the full view in the request, so it can render page 3 of running shoes directly, and a CDN can cache it by URL.
None of this needs a state library. It needs a decision about which state is "where am I" state, and care when reading and writing the address.
Check your understanding · Exercise 1
A list page keeps its filters and page number in useState only. Which of these break? Select all that apply.
The mental model: the URL is the saved view
Think of a library call number. Anyone holding it can walk to the exact shelf and spot, so a friend handed the slip finds the same book. Keeping the view only in component state is like remembering "third shelf, somewhere on the left": it works for you, right now, and nobody else can follow it.
The URL is the page's call number: a complete, compact description of the view. Given only the URL (and the data on the server), the app can rebuild the screen. Everything else on the screen is either data fetched for that view or short-lived interaction state that nobody would expect to share.
Put another way, the URL is serialized view state. Serializing turns a value in memory, like { sort: "price-asc", page: 3 }, into text, like sort=price-asc&page=3; parsing is the reverse. Most of this lesson is about doing those two steps well.
Three places in a URL can hold state
A URL has a fixed shape. Three parts can hold state: the path, the query string, and the hash, each with a different job and reach.
- Path (
/shoes/runningor/orders/8812): names the resource or the page. Routers match on it to pick a component. Use it for things that change what the page is, not how it is shown. - Query string (everything after
?and before#): a list ofkey=valuepairs joined by&. It holds options for the view: filters, sort, page, tab, search text. It is sent to the server with the request. - Hash or fragment (after
#): points at a spot inside the page, like#reviews. The browser never sends it to the server, and changing only the hash does not reload the page. Old single-page apps routed with it (/#/shoes); the History API has made that rarely necessary.
A useful rule: if two values of the state show two different things (two products, two users), it is usually a path segment. If they show the same thing in a different way (sorted, filtered, on another page), it is a query parameter. /users/42?tab=activity is easier to route, cache, and read than /users?id=42&tab=activity.
What belongs in the URL, and what doesn't
The lesson on state categories names five kinds of state: server state, client UI state, URL state, form state, and stream state. Here is how to spot URL state in practice, using the share test from chapter 1.
| State | In the URL? | Why |
|---|---|---|
| Search text after it is submitted or settled | Yes: ?q=trail+shoes | People share and bookmark searches. |
| Filters and facets | Yes: ?size=42&brand=asics | Defines which items the view shows. |
| Sort order | Yes: ?sort=price-asc | Same items, a different view people expect to share. |
| Page number or cursor | Yes: ?page=3 or ?after=c_9f2 | Back from a product must land on the same page. |
| Selected tab | Usually: ?tab=reviews | A link to "the reviews tab" is useful. |
| Selected item in master and detail | Yes: path or ?id=8812 | Opening a mail or an issue is a navigation. |
| Map center and zoom, chart date range | Yes | Teams send these views to each other. |
| Hover, focus, an open tooltip or dropdown | No | Nobody expects a link to open a dropdown. |
| Unsaved form text, long drafts | No | Keep it in the form or a saved draft. |
| Tokens, one-time codes, personal data | Never | URLs end up in history, logs, and analytics. |
| Large state: a whole document, 500 selected rows | No | Save it on the server and put its id in the URL. |
Some items sit on the line. A product preview modal can go either way: if people share "look at this one" links, give it a URL (?preview=8812), and Back will close it, as phone users expect. A mobile "show filters" panel usually stays out: it is how you reach the view, not the view itself.
Why secrets never go in the URL
A URL is copied to many places you do not control: browser history, server and CDN access logs, analytics tools, screenshots, and sometimes the Referer header. By default browsers send only the origin to other sites, but requests to your own origin carry the full URL.
So the URL holds what to show, never who you are. Session tokens belong in HttpOnly cookies or in memory. Emails, phone numbers, and addresses stay out of query strings, even on internal tools. Password reset and magic-link tokens are the common exception, and they are single-use and short-lived for exactly this reason.
Check your understanding · Exercise 2
On an email client, which of these should be part of the URL? Select all that apply.
How it works: the History API, step by step
Every tab keeps a session history: an ordered list of entries with a pointer to the current one. Back and Forward move the pointer. An entry can be a full page load or a same-document entry: the URL changed, but the page stayed loaded and JavaScript redrew the view.
Same-document entries come from the History API, which every router is built on (the client-side rendering lesson covers how a router picks a component). Three parts matter.
history.pushState(state, "", url)adds a new entry after the current one, moves the pointer to it, and showsurlin the address bar. Any entries ahead of the pointer (the Forward list) are thrown away. The page does not reload and nothing is sent to the server.history.replaceState(state, "", url)changes the URL and state of the current entry in place. The list keeps its length, so Back has nothing new to step over.- The
popstateevent fires onwindowwhen the user (orhistory.back()) moves between two same-document entries. The browser changed the URL, not your code, so you must redraw the view to match.event.stateholds thestateobject stored with that entry.
Two details trip people up. First, pushState and replaceState do not fire popstate; only moving through history does. So code that re-renders on popstate must also re-render after its own pushes, which is why routers notify their subscribers on both.
Second, the middle argument is unused; pass an empty string. And the url must be on the same origin, or the browser throws a SecurityError.
A worked example: five clicks and a Back
Follow the shoe shopper through the history list. Each step shows the entries, with the current one in brackets.
- She opens
/shoes. A normal page load. History:[/shoes]. - She picks Running. The app calls
pushStatewith/shoes?category=running. History:/shoes,[?category=running]. The list fetches running shoes. - She ticks size 42. Another push:
/shoes?category=running&size=42. History has three entries now. - She sorts by price. Push:
...&sort=price-asc. Four entries. - She opens a product. The router pushes
/p/8812. Five entries, the product page is current. - She presses Back. The browser moves the pointer to entry four and fires
popstate. The router readslocation.searchand renders the filtered, sorted list, at once if the query cache still holds the result.
popstate on Back. In both cases the list is drawn from the URL, so nothing else has to remember the filters.Nothing in the list page remembered anything; the URL was the memory. The list read it on render, the data layer looked up the result by the same values, and the page came back as she left it. Chapter 4 turns that into the main design rule.
The state object: per-entry data that is not shareable
The first argument to pushState is stored with the entry and handed back in popstate as event.state, or read any time as history.state. The browser keeps it across a reload or a restored tab, but it is not part of the URL, so a shared link does not carry it.
That makes it the right home for state that should come back with Back but not travel in a link: the scroll position or the first visible item in a long list (see the pagination lesson), or "where did this modal come from", so closing it can go back instead of pushing.
// We restore scroll ourselves, so the browser should not
history.scrollRestoration = "manual";
// Save the scroll spot on the current entry before leaving the list
history.replaceState({ ...history.state, scrollY: window.scrollY }, "");
// Our own navigation: add an entry, then redraw
function navigate(url: string) {
history.pushState(null, "", url);
render(new URL(url, location.href));
}
// Back and Forward: the browser changed the URL, so redraw from it
window.addEventListener("popstate", (event) => {
render(new URL(location.href));
window.scrollTo(0, event.state?.scrollY ?? 0);
});
What happens on reload
A pushed URL is a promise: on a reload or in a new tab, the server gets a real request for it. A server-rendered app reads ?category=running&size=42 and sends the filtered list in the first HTML. A client-rendered app needs the server to answer every app path with the same HTML shell (the "SPA fallback"), then reads the URL on start.
Check your understanding · Exercise 3
Which of these fire a popstate event? Select all that apply.
Syncing without loops: one source of truth
The most common bug with URL state is keeping it in two places. The filters already live in useState, someone adds "put them in the URL too", and two effects appear: one copies state into the URL, the other copies the URL back into state. It works in the demo and breaks in small ways in production.
// Anti-pattern: two copies kept in sync by effects
const [params, setParams] = useSearchParams();
const [sizes, setSizes] = useState(params.getAll("size"));
useEffect(() => {
// state to URL
const next = new URLSearchParams(params);
next.delete("size");
sizes.forEach((s) => next.append("size", s));
setParams(next);
}, [sizes]);
useEffect(() => {
// URL to state: a new array every time
setSizes(params.getAll("size"));
}, [params]);
getAll returns a new array on every call, so every URL change sets state, which runs the first effect and writes the URL again. At best that is wasted renders and duplicate writes; if the two sides format values even slightly differently, they never settle.What breaks with two copies
- Loops. State changes, the effect writes the URL, the URL change sets state to a new but equal array, which writes the URL again. With push, each write can also add a history entry.
- Drift. One copy is always a render behind the other, so for a moment the list and the URL disagree, and a fetch can start with the wrong values.
- A broken Back button. If the URL-to-state effect is missing or wrongly guarded, Back changes the address bar but not the list.
- A flash on load.
useStatestarts with the default, the effect reads the URL later, and the user sees the default view first.
The fix: the URL is the only copy
Delete the state. Read the values from the URL on every render, and when the user changes something, write a new URL and let the re-render pick it up. With only one copy there is nothing to sync, and Back and Forward work with no extra code, because they change the copy every render reads.
function useShoeFilters() {
const [params, setParams] = useSearchParams();
const filters = parseFilters(params); // validated, with defaults
function update(patch: Partial<Filters>, opts?: { replace?: boolean }) {
// page goes back to 1 unless the patch sets it; keys we do not own are kept
const next = serializeFilters({ ...filters, page: 1, ...patch }, params);
setParams(next, { replace: opts?.replace ?? false });
}
return { filters, update };
}
function ShoeList() {
const { filters } = useShoeFilters();
const query = useQuery({
queryKey: ["shoes", filters],
queryFn: () => fetchShoes(filters),
placeholderData: keepPreviousData,
});
// ...
}
parseFilters turns raw strings into typed, valid values with defaults (chapter 7); serializeFilters does the reverse (chapter 6). Every component calls the same hook, so the toolbar, the list, and the "3 filters applied" badge can never disagree.
The data follows the URL too. A new URL means a new query key, so the right data loads. Back restores the old key, and the server state library (TanStack Query here) serves the old result from cache with no spinner. keepPreviousData keeps the old list on screen while a new one loads, so the page does not flash empty.
Changes that must reset other values
Some values only make sense together. Page 7 of "all shoes" may not exist once the user filters to size 42, and a cursor like ?after=c_9f2 means nothing after the sort changes. So any write that changes the filters or sort must drop the page or cursor. Put that rule in the one update function, not in each click handler, or someone will forget it.
The one exception: text the user is typing
A search box is where "read only from the URL" is too strict: routing every keystroke would feel slow on big pages and refetch per letter. So the input holds a draft in useState, and a debounced effect commits it to ?q= about 300 ms after typing stops.
The URL stays the source of truth for the committed value. The one sync the other way is when the URL changes from outside, such as on Back: the draft must reset to the new q. Give the input a key equal to the committed value so React remounts it, or reset the draft when the committed q string changes (chapter 5 shows this).
Decision Where should the filter values live?
What we pick: Make the URL the only copy and read it through one typed hook. Use a local draft only for text being typed. If a store needs the values, derive them from the URL rather than storing them twice.
Check your understanding · Exercise 4
A filter page keeps sizes in useState and mirrors them to the URL with an effect. Users report Back changes the address but not the list. Which fixes are sound? Select all that apply.
Push or replace: designing the Back button
Every URL write is a choice between two methods, and that choice designs the Back button. Push adds an entry, so Back undoes this change. Replace rewrites the current entry, so the URL is up to date but Back skips over this change. Routers expose the same choice: setSearchParams(next, { replace: true }) in React Router, router.push and router.replace in Next.js, navigate({ search, replace: true }) in TanStack Router.
The question to ask is: would the user think of this as a step they might want to undo? Opening page 3 is a step, and so is applying the "Running" filter. Typing the fourth letter of a search is not, and neither is the 40th position of a price slider mid-drag.
Address bar: shop.example/shoes
Change some filters or type in the search box, then press Back. Leaving the shop from here takes 1 Back press.
Try the lab. With push on every change, typing "trail" adds five entries. With push for choices and one entry per search, the same session leaves a few entries, each a view worth returning to.
A default for each kind of change
| Change | Method | Why |
|---|---|---|
| Pick a filter, a category, or a sort option | push | A deliberate choice; Back should undo it. |
Go to another page (?page=3) | push | Users expect Back to return to page 2. |
| Open an item, a detail panel, or a shareable modal | push | Back should close it, especially on phones. |
| Switch a tab | push or replace | Push if tabs feel like pages (profile tabs); replace if they are small panels inside one view. |
| Type in a search box | replace, debounced | One entry per search, not one per letter. |
| Drag a slider, pan or zoom a map | replace, throttled | Continuous input; only the end position matters. |
| Fix or clean the URL on load (bad values, defaults) | replace | A push here would trap the user: Back lands on the bad URL, which redirects forward again. |
| Load more items into an infinite list | replace or history.state | Keep the depth for reload without adding an entry per batch. |
The search box pattern
Search boxes need the most care, because they mix typing (replace) with a result the user may want to return to (push). A pattern that works well: the first committed change of a search pushes one entry, and later changes in the same burst replace it. Back from a product then lands on the full query's results, and one more Back leaves the search.
function SearchBox() {
const { q, setQuery } = useSearchQuery(); // reads ?q= from the URL
const [draft, setDraft] = useState(q);
const [seenQ, setSeenQ] = useState(q);
const typing = useRef(false);
if (q !== seenQ) {
// the URL changed, maybe from outside (Back): follow it
setSeenQ(q);
setDraft(q);
}
useEffect(() => {
if (draft === q) return;
const id = setTimeout(() => {
setQuery(draft, { replace: typing.current }); // first commit pushes
typing.current = true;
}, 300);
return () => clearTimeout(id);
}, [draft, q, setQuery]);
return (
<input type="search" aria-label="Search shoes" value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => { typing.current = false; }} />
);
}
q, the draft follows it instead of writing the old text back.Back-button traps to avoid
- Pushing on mount. A page that pushes a cleaned-up URL as soon as it loads leaves an entry that bounces the user forward again when they press Back. Normalize with replace.
- Pushing identical URLs. Clicking the already-active filter, or a re-render that writes the same params, should not add an entry. Compare the new search string with
location.searchbefore writing. - Modals that push but close with a button. If opening a modal pushes
?preview=8812, closing it with the X should go back (when it was opened in this session), not push/shoes, or Back reopens it. - Losing the scroll spot. A list rebuilt on Back lands at the top unless scroll is restored; routers help (
ScrollRestorationin React Router).
Check your understanding · Exercise 5
Which of these URL writes should use replace rather than push? Select all that apply.
Encoding complex state
A query string is flat text: keys and values, both strings. Real view state has lists (sizes 42 and 43), ranges (50 to 120 dollars), numbers, booleans, and sometimes nested options. Encoding squeezes that into text, and parsing gets it back. Pick one convention per app and use it on every page.
Escaping: the characters that mean something
Some characters have a job in a URL: ? starts the query, & separates pairs, = splits key from value, # starts the hash. A value that contains them must be percent-encoded: written as % plus the byte in hex. Non-ASCII text is first turned into UTF-8 bytes, so é becomes %C3%A9, six characters for one letter.
Let URLSearchParams do this; never join query strings by hand. Two quirks: it writes a space as + (form encoding) where encodeURIComponent writes %20, and it parses both back. It also escapes commas as %2C, though a raw comma is legal in a query string, which matters for comma lists.
const params = new URLSearchParams();
params.set("q", "trail shoes & socks");
params.append("size", "42");
params.append("size", "43");
params.toString();
// "q=trail+shoes+%26+socks&size=42&size=43"
const read = new URLSearchParams("?q=trail+shoes+%26+socks&size=42&size=43");
read.get("q"); // "trail shoes & socks"
read.getAll("size"); // ["42", "43"]
read.get("page"); // null: not there, so use the default
URLSearchParams escapes on write, unescapes on read, and handles repeated keys with append and getAll.Lists, ranges, and nested options
| Shape | Repeated keys | Comma list | JSON in one param |
|---|---|---|---|
| Example | size=42&size=43 | size=42,43 | f={"size":["42","43"]} (then escaped) |
| Readable and editable by hand | Yes | Yes, the most compact | No, once escaped |
| Built-in support | getAll in URLSearchParams; most servers | Split and join yourself | JSON.parse plus a schema |
| Values that contain the separator | No problem | Must escape commas inside values | No problem |
| Nested objects | Flatten: price.min=50 | Flatten | Yes |
| Length for the sample view | 102 characters | 91 characters (95 if commas are escaped) | 237 escaped, 174 as base64url |
- Lists. Repeated keys are the most standard choice and need no custom parsing. Comma lists are shorter and read well; use them when values are ids or slugs that never contain commas. Avoid the bracket style
size[]=42unless your backend expects it: the brackets get escaped to%5B%5D. - Ranges. Two keys (
minPrice=50&maxPrice=120) are clearest and let either side be open. One key (price=50-120) is shorter, but a dash clashes with negative numbers and dates;..(price=50..120) avoids that. - Booleans. Present means true (
?inStock=1), absent means the default. Leave?inStock=falseout of links, since false is usually the default. - Nested options. Flatten with dotted keys:
?filter.brand=asics&filter.color=blue. Most filter UIs are only one level deep, so this covers them.
URL: /shoes?size=42&size=43&brand=asics&brand=hoka&sort=price-asc&page=3
repeated keys: 60 characters. A person can read it and edit it by hand.
JSON and base64: when the state is really a document
Sometimes the state is a structure: a query builder with nested AND and OR groups, a chart with ten series, a playground's source file. Apps serialize that as JSON. Raw JSON in a query param is mostly escape codes ({ becomes %7B), so the next step is base64url: base64 with - and _ instead of + and /, and no = padding.
That trades readability for structure. Base64 is about a third bigger than the JSON, nobody can edit ?f=eyJjYXRlZ29y... by hand, and shape changes need a version so old links still parse. For larger state, compress first: the TypeScript playground keeps its whole source, compressed, in the URL's hash, with no server storage.
How long can a URL be?
Browsers are generous: Chrome accepts URLs up to 2 MB. The tight limits sit between the browser and your code: servers, proxies, and CDNs often cap the request line or headers at about 8 KB (nginx's default header buffer is 8 KB, Apache's request line limit 8,190 bytes). Past that, requests fail with 414 URI Too Long or 431 Request Header Fields Too Large.
Some chat apps, email clients, and analytics tools cut or mangle long links far sooner. Budget a few hundred characters for a shareable view; past about 2,000, treat it as a design smell.
When the state is too big: save it, link to its id
The way out is a saved view: post the state to the server, get back a short id, and put only the id in the URL: /reports?view=v_82k1. The link is short and stable, the state can be any size, and the view can have a name, an owner, and permissions.
The cost is a server round trip to save and to load. A named view can also change if someone edits it, which may not be what the sharer meant. Grafana shows the mix: readable from and to parameters for the time range, and a short-link option when the full URL gets long.
Clean, canonical links
- Leave defaults out.
/shoes, not/shoes?sort=featured&page=1, so one view has one short URL. - Fix the key order. Write keys in the same order every time (a fixed list in the serializer, or
params.sort()), and sort list values, so?size=43&size=42and?size=42&size=43become one URL. - One spelling per value. Lowercase slugs (
asics, notASICS), one date format (2026-03-01), no trailing junk.
Canonical URLs are not just tidy. Caches key on the full URL, so two spellings of one view are two cache entries and a lower hit rate. Search engines treat each URL as a page, so filter combinations point to a main URL with a rel="canonical" link.
Check your understanding · Exercise 6
A report builder lets users nest AND and OR groups of conditions. Links are shared inside the company. Which encodings are reasonable? Select all that apply.
Reading the URL safely: it is user input
Everything in the URL was typed, edited, or crafted by someone. A user can change ?page=3 to ?page=-4, an old bookmark can hold a sort option you removed, and an attacker can set ?q= to a script tag or ?next= to a phishing site. Treat the query string like a form submission from a stranger.
The goal is a page that never crashes or does something dangerous, whatever the URL says. Bad values quietly fall back to defaults.
Parse with a schema, fall back to defaults
Run every read through one parse function that turns raw strings into typed, valid values. Each field gets three things: a type to coerce to, the allowed values or range, and a default for when the value is missing or wrong.
import { z } from "zod";
const SORTS = ["featured", "price-asc", "price-desc", "newest"] as const;
const SIZES: readonly string[] = ["40", "41", "42", "43", "44"];
const FiltersSchema = z.object({
q: z.string().trim().max(100).catch(""),
sort: z.enum(SORTS).catch("featured"),
page: z.coerce.number().int().min(1).max(500).catch(1),
size: z.array(z.string()).catch([]),
});
export function parseFilters(params: URLSearchParams) {
return FiltersSchema.parse({
q: params.get("q") ?? undefined,
sort: params.get("sort") ?? undefined,
page: params.get("page") ?? undefined,
// drop duplicates and unknown sizes, keep at most five
size: [...new Set(params.getAll("size"))].filter((s) => SIZES.includes(s)).slice(0, 5),
});
}
.catch, so a bad value becomes the default instead of an error. Unknown and duplicate sizes are dropped, and the list is capped.- Coerce types. Everything arrives as a string.
"3"must become the number 3, and"3abc"must become the default, notNaN. - Allow-list enums. Sort, tab, and view values come from a fixed list; anything else is the default. Never pass a raw sort string into a database query or an object key lookup.
- Clamp numbers.
?pageSize=100000is a cheap way to make your API run a huge query. Cap it in the client, and again on the server. - Cap lengths and counts. Limit text length and the number of list values, so one link cannot build a 50,000-term filter.
- Keep it quiet. A bad value is not an error page. Show the default view and, if useful, replace the URL with the cleaned one (chapter 5).
Two attacks that start in the URL
Reflected XSS (cross-site scripting: an attacker's script runs on your page). Showing "Results for ..." with the q value is safe in React, Vue, or Svelte when rendered as text, because they escape it. It turns dangerous once the value reaches innerHTML, dangerouslySetInnerHTML, or an href. The XSS lesson covers the full set of sinks.
A link built from a URL value can carry javascript:alert(1), so only allow http: and https: links, or better, only links you build yourself.
Open redirects. Login pages often take ?next=/orders to send the user back after sign-in. If the page redirects wherever next says, an attacker shares ?next=https://evil.example on your real login page, and users trust your domain. Accept only paths starting with a single / (// means another host), or check that the parsed URL's origin is yours.
function safeNext(raw: string | null): string {
if (!raw) return "/";
try {
const url = new URL(raw, location.origin);
return url.origin === location.origin ? url.pathname + url.search : "/";
} catch {
return "/"; // not a parsable URL at all
}
}
The server must check too
Client-side parsing protects the UI, not the API, which anyone can call directly. The server validates the same fields, uses parameterized queries, and enforces its own limits; one shared schema keeps the two in step.
Old links must keep working
Links in old emails are still clicked. If you rename ?order= to ?sort=, the parser should accept the old form, map it, and replace the URL with the clean version. A filter value that no longer exists should be dropped, not cause an error.
Check your understanding · Exercise 7
A product list receives ?sort=__proto__&page=abc&pageSize=99999. What should a well-built page do? Select all that apply.
The tools: from the History API to typed search params
You rarely call pushState yourself. Routers and small libraries wrap it, add parsing and types, and connect it to rendering. They all sit on the same browser layer, so the ideas so far carry over.
React Router: useSearchParams
useSearchParams() returns the current URLSearchParams and a setter, which takes new params (or a function of the old ones) and options such as replace and preventScrollReset. You get strings; parsing, defaults, and validation are yours, which is why teams wrap it in one typed hook per page, like useShoeFilters in chapter 4.
Next.js App Router
Reading happens in two places. A server page receives searchParams as a prop (a promise since Next.js 15) and can fetch the filtered list on the server, so the first HTML is already the right view. Client components call useSearchParams(), which returns a read-only object. Writing goes through router.push(url) or router.replace(url), with { scroll: false } to keep the scroll position.
A router.push to new search params asks the server to render the page again, which is what you want when the server fetches the data. For a client-only change, like a tab whose content is already loaded, Next.js (since 14.1) lets you call window.history.pushState or replaceState directly, and useSearchParams updates without a server request.
TanStack Router: search params as typed route state
TanStack Router makes search params part of each route. A route declares validateSearch, often with a schema, so Route.useSearch() returns parsed, typed values. navigate({ search: (prev) => ({ ...prev, page: 2 }) }) keeps other keys by spreading prev. Nested values are stored as JSON inside params, and the stripSearchParams middleware can drop defaults from links.
nuqs: useState that lives in the URL
nuqs gives you a hook shaped like useState, backed by the query string: const [sort, setSort] = useQueryState("sort", parseAsStringLiteral(SORTS).withDefault("featured")). Parsers handle numbers, booleans, dates, enums, and lists (parseAsArrayOf joins with commas). It has adapters for Next.js, React Router, TanStack Router, and plain React.
Its options map onto this lesson. history picks push or replace (replace is the default), limitUrlUpdates throttles or debounces writes while typing, and clearOnDefault (on by default since version 2) drops default values. shallow (also on by default) keeps a Next.js update on the client, and useQueryStates changes several keys in one write, so a new filter and a page reset are one history entry.
The Navigation API: the History API's successor
The History API was designed for pages that load one document per URL, so routers work around it: catching link clicks, guessing when traversal happens, and keeping their own list of entries. The Navigation API (window.navigation) is a newer browser API meant to replace those workarounds.
- One
navigateevent fires for every kind of navigation: link clicks, form submits, Back and Forward, and your own calls. A router callsevent.intercept({ handler })to turn it into a same-document navigation. navigation.navigate(url, { history: "replace" })does whatpushStateandreplaceStatedo, and returns promises for when the navigation is committed and finished.navigation.entries()lists the tab's same-origin entries, andnavigation.currentEntrygives the current one with a stablekey, which the History API never exposed.
Chromium browsers shipped it in 2022 (Chrome 102). Firefox 147 and Safari 26.2 added it around the start of 2026, so older browsers still in use need a fallback. In practice your router decides; the design questions (what goes in the URL, push or replace) stay the same either way.
Check your understanding · Exercise 8
Which statements about these tools are true? Select all that apply.
In real products: rendering, performance, and accessibility
How well-known products use the URL
- GitHub issues puts a whole query language in one parameter,
?q=is:issue is:open label:bug; the search box and filter menus both edit that string. - Google Maps keeps the camera in the path:
/@52.37,4.89,14zis latitude, longitude, and zoom, and the address updates as you pan. - YouTube links carry the video id and an optional start time:
?v=...&t=42s. - Figma links to a frame inside a file with
?node-id=; the huge file itself stays on the server, named by the path.
The first render: read the URL where you render
Where you read the URL decides what the user sees first. A server-rendered page reads the query string from the request and sends the filtered list in the HTML. A client-rendered page must read it in its first render, before it paints, or it shows the default view for a moment and then jumps.
Frameworks have rules here. In Next.js, a client component that calls useSearchParams in a statically rendered route is rendered on the client up to the nearest Suspense boundary, because the static HTML cannot know the query string. Wrap it in Suspense with a fallback, or read searchParams in the server page and pass the values down.
With any server rendering setup, the server and the client must parse the URL with the same function, or hydration will find two different views.
Performance
- Re-renders. Components that read the search params re-render when any key changes. Parse once per page and pass values down, or subscribe per key.
- Write rate. Debounce or throttle writes from typing, sliders, and map moves to a few per second; each write may cost a render, a fetch, or a server request.
- Server round trips. Where a URL change re-runs server rendering, a client-only change (a tab whose content is already loaded) should use a shallow update.
Accessibility
A filter change that redraws the list is silent for a screen reader user: no page load is announced, and focus stays on the checkbox. Announce the result in a polite live region ("Showing 24 of 180 running shoes"). On a page change, move focus to the results heading.
Use real links for pagination: a elements with href="?page=3". They support middle-click and "open in new tab", are announced as links, and work before JavaScript loads. Filters can live in a form with method="get": the browser turns the checked boxes into a query string itself, and your script takes over for smoother updates.
Failure modes and how you notice them
| Symptom | Likely cause | Fix |
|---|---|---|
| Shared link shows the default view | State kept in memory only | Move view state into the query string. |
| Back needs many presses to leave a page | Pushing on every keystroke or slider step | Replace for continuous input; debounce. |
| Back changes the address but not the screen | Two copies of state; popstate not handled | Read only from the URL. |
| Browser freezes or history fills with duplicates | Sync effects in a loop | One source of truth; skip writes that change nothing. |
| Default view flashes before the real one | URL read after the first paint | Read on the server or in the first render. |
| Low CDN hit rate on list pages | Same view under many URLs | Fixed key order, defaults omitted, sorted lists. |
414 or 431 errors from some users | URLs over the server or CDN limit | Shorter encoding or saved views. |
| Emails or names in analytics reports | Personal data in query params | Keep it out of URLs; scrub it in analytics. |
Check your understanding · Exercise 9
A server-rendered shop adds a size filter that only updates the URL on the client. Which problems should the team check for? Select all that apply.
Using it in a system design interview
URL state comes up in almost every design question with a list, a search, a dashboard, or a map. It is a small topic that signals a lot: you think about the whole user journey, not just the first screen.
When to bring it up
- In requirements. Ask: "Should filtered views be shareable? Should Back undo a filter?" That gives two clear non-functional requirements.
- In the state design. When you list the kinds of state (server, client UI, URL, form), name exactly which values live in the URL:
q,sort,pageorcursor, the filters, the open item. - In the data layer. The parsed URL values are the query key, so Back and Forward hit the cache.
- In the deep dive. Cover push and replace, validation, and length limits if asked about history or edge cases.
What to draw and say
Draw the loop from chapter 4: user action, URL, parsed filters, query key, cache, list. Next to it, write a sample URL like /shoes?category=running&size=42&page=3 and mark the path and the query.
Then say three sentences: "The URL is the source of truth for the view. Discrete choices push and typing replaces. Every read goes through a schema with defaults, because the URL is user input."
How deep to go, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Filters, sort, and page in the query string so links share and refresh works; reads URLSearchParams. |
| Mid-level | URL as single source of truth, push versus replace with debounced search, filters in the query key, defaults omitted, validation with fallbacks. |
| Senior | All of that plus server rendering from the URL, canonical URLs for caching and SEO, length limits and saved views, open redirects and PII in logs, accessibility of same-document updates, and an encoding convention shared across teams. |
Think first
You are designing a flight search results page with filters for stops, airlines, departure time ranges, and price, plus sort and a selected flight in a side panel. What goes in the URL and how?
The search itself (from, to, dates, passengers) is in the path or query, since it defines the page: /flights/AMS-LIS/2026-05-02?adults=2. Filters go in the query with readable keys: stops=0&airline=KL&airline=TP&dep=06:00..12:00&maxPrice=300, defaults omitted, keys in a fixed order. Sort is sort=price.
Filter clicks push; the price slider and time range sliders replace, throttled. The selected flight is ?flight=KL1693 with push, so Back closes the panel. Prices are not in the URL: they are server state and change by the minute, so a shared link re-runs the search.
Every read goes through a schema: unknown airlines are dropped, a bad date falls back to the search form. The results query key is the parsed search plus filters, so Back to a previous filter set is instant from the cache.
Summary
- State that answers "what am I looking at?" (search, filters, sort, page or cursor, tab, open item) belongs in the URL, so the view can be shared, refreshed, bookmarked, and walked back.
- Secrets, personal data, short-lived UI state, drafts, and large state stay out: URLs end up in history, logs, analytics, and
Refererheaders. - Make the URL the single source of truth: read and parse it on render, write a new URL to change it, and use the parsed values as the data query key. Two synced copies drift and loop.
- Push for discrete steps the user would undo; replace for typing, sliders, map moves, and URL clean-ups. Debounce continuous writes.
- Use readable keys: repeated keys or comma lists for arrays, two keys for ranges, defaults omitted, a fixed key order. JSON plus base64url only for real structures; saved views when state outgrows a few hundred characters.
- Many servers and CDNs reject URLs over about 8 KB, so keep shareable URLs short and never assume the browser limit is the real one.
- The URL is untrusted input: parse with a schema, allow-list values, clamp numbers, fall back to defaults, and validate redirects and links built from it.
- Read the URL where you render (on the server for SSR), announce same-document updates to screen readers, and use real links and GET forms so it works before JavaScript.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Where would you store the filters, sort, and page for a product listing, and why?
In the query string. Links can be shared and bookmarked, a refresh keeps the view, Back undoes the last filter, the server can render the filtered list in the first HTML, and a CDN can cache each canonical URL.
Components read the values through one hook that parses them with a schema and defaults. Changing a filter writes a new URL and resets the page. The parsed values are the query key, so going Back shows cached results at once.
Question 2
A user types in a search box that updates the URL, and complains that Back doesn't leave the page. What is wrong and how do you fix it?
Each keystroke pushes a history entry, so Back steps through every partial query. Keep a local draft in the input, debounce commits to the URL by about 300 ms, and use replace while typing. Push only on deliberate steps, such as the first commit of a new search or opening a result.
Question 3
Your filters live in useState and are mirrored to the URL with effects. What problems do you expect, and what would you change?
Two copies drift: one is a render behind, fetches can start with stale values, and if the effects feed each other (new array identities from getAll) they loop and flood history. Back may change the address bar but not the list, and there is a flash of default state on load.
Remove the state. Read the URL on every render through a typed parse function, write the URL to change it, and derive everything else, including the query key, from the parsed values. Keep local state only for the draft text in an input.
Question 4
How do you encode a complex filter, like several brands, a price range, and nested condition groups?
Flat filters use readable keys: brand=asics&brand=hoka&minPrice=50&maxPrice=120, defaults omitted, keys and list values in a fixed order, parsed back through a schema. That covers almost every product UI.
Nested AND and OR groups are a real structure: serialize to JSON, optionally compress, encode as base64url with a version prefix. If it can grow large or needs a name and permissions, save it on the server and put its short id in the URL. Keep URLs well under the roughly 8 KB that many servers and CDNs accept.
Question 5
What security issues does URL state introduce?
The URL is attacker-controlled input. Parse and allow-list every value, clamp numbers like page size on client and server, and never send URL values to innerHTML or into href without checking the scheme, to avoid reflected XSS. Validate ?next= style redirect targets against your own origin to prevent open redirects.
The URL is also widely copied: history, server logs, analytics, and sometimes Referer. So no tokens or personal data in it, and scrub sensitive parameters such as search text in analytics if users may type private information.
Practice it
These design problems lean on the url as state.
- Design an accordionEasy
- Design a tabs componentEasy
- Design a modal dialogEasy
- Design a pagination controlEasy
- Design report exportEasy
- Design a multi-step form wizardMedium
- Design an image lightbox with zoomMedium
- Design a podcast player with a synced transcriptMedium
- Design a search results pageMedium
- Design product ratings and reviewsMedium
- Design PinterestMedium
- Design Stack OverflowMedium
- Design an admin CMSMedium
- Design a scheduling link like CalendlyMedium
- Design a data tableHard
- Design RedditHard
- Design an e-commerce site like AmazonHard
- Design AirbnbHard
- Design a flight booking siteHard
- Design Trello or JiraHard
- Design a code review diff viewerHard
- Design an analytics dashboardHard
- Design a customer support ticket dashboardHard
- Design Google MapsHard
- Design a 3D product configuratorHard
References
- MDN: Working with the History API pushState, replaceState, popstate, and the state object.
- MDN: URLSearchParams Reading and writing query strings, repeated keys, escaping.
- MDN: Navigation API The newer navigation model and its browser support.
- React Router: useSearchParams Reading and setting search params, with replace.
- Next.js: useSearchParams Client reads, static rendering, and native history updates.
- TanStack Router: Search Params Validated, typed search params per route.
- nuqs documentation Type-safe query string state for React, with push, replace, and throttling options.
Read next
- Kinds of stateServer data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Client-side renderingThe server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.