StateGENERALFRAMEWORK-SPECIFICSPEC-EVOLVING

The URL Is Application State

Filters, tabs, pagination and the selected item belong in the address bar far more often than teams assume — because that is the only state store the browser itself restores.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Which parts of what the user is looking at should be encoded in the URL?

The user intent

Someone has narrowed a list down to what they need and wants to send it to a colleague, bookmark it, reload it, or press Back and get the previous view. They expect the address bar to describe what they are looking at, because on the rest of the web it does.

The obvious build

The URL identifies the page. /orders is the orders page. Filters, sorting and which row is open are interface details, so they live in memory where they are easy to read and easy to change.

Why it breaks

The user filters to status=failed&region=eu&assignee=me, finds the one broken order, pastes the link into an incident channel, and everyone who clicks it sees an unfiltered list of 4,000 rows.

How it breaks in a real browser
  • The user filters to status=failed&region=eu&assignee=me, finds the one broken order, pastes the link into an incident channel, and everyone who clicks it sees an unfiltered list of 4,000 rows.
  • A reload — an accidental refresh, a crash, a browser update — throws away every narrowing decision the user made, and there is nothing to restore from (The Multi-Process Browser).
  • Back exits the application instead of undoing the last narrowing, because none of the narrowing produced a history entry. The user loses the whole session to a reflex (History and Navigation).
  • Opening a row in a new tab is impossible: there is no address for the row, so middle-click and "open in new tab" are dead, and the user has to redo the filter in the second tab.
  • Deep-linking from an email, a dashboard or an alert cannot target anything more specific than the page, so every notification lands the user at the top of a list and asks them to find the thing again.
  • Analytics and support both lose: no URL means no way to say "the user was on this view", so every bug report starts with a reconstruction interview.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The URL is a serialised, addressable, user-editable state container that the browser persists in the session history, restores after a tab discard, sends to the server, shares over any medium and exposes through Back and Forward — none of which any in-memory store does.
  • A client-side router is a subscription to that container: it listens to history changes, matches the current URL against route patterns, and renders accordingly (Client-Side Routing).
  • Path segments identify *what* is being viewed and should be hierarchical and stable: /orders/8123. Search parameters modify *how* it is viewed and are unordered and optional: ?status=failed&page=2 (URL Parameters).
  • History has two update modes and choosing between them is the whole ergonomics of the feature. A push creates an entry, so Back undoes it; a replace rewrites the current entry, so Back skips it. Discrete choices push; continuous ones replace.
  • Everything in a URL is a string supplied by an untrusted party. The user can type anything into it, and so can whoever sent the link, so parsing must validate and fall back rather than assume (URL Parameters).
  • Because the URL is a plain value, everything downstream can be a pure function of it: the query key, the fetch, the rendered view. That is what makes Back, reload and share work identically without special-casing any of them.

What this makes the browser do

And which of it is avoidable.

  • A history push or replace is cheap — a session-history entry, not a navigation. The expensive part is what your router does in response: match, mount, and trigger loaders (Route Loading Boundaries).
  • A URL change that alters a query key starts a fetch. High-frequency URL writes therefore mean high-frequency fetches unless the input is debounced before it reaches the URL (Five Components, One Request).
  • Back and Forward restore the entry without a network round trip for the document, but your loaders still run unless the response is in the client cache (The Client Cache Model).
  • The browser also restores scroll position for history traversals when the router does not fight it — one of the more commonly broken free features in single-page applications (Scroll Restoration).

What belongs in the address bar

The test is one sentence: would a user reasonably want to send this to someone, bookmark it, or get it back with Back or a reload? If yes, it is URL state. If it changes many times a second, or means nothing outside this instant, it is not.

The second column is where teams under-commit. Tab selection, sort order and pagination are almost always URL state and almost never implemented as such, which is why "send me the link to that view" is a request most internal tools cannot satisfy.

ValueIn the URL?History modeWhy
Which record is openPath segmentPushIt is a distinct thing with an identity; it deserves an address and a Back
Active filtersSearch paramPushThe defining narrowing of the view, and the thing people share
Sort orderSearch paramReplaceA refinement of the same view; Back should not step through sort changes
Page or cursorSearch paramPushUsers expect Back to return to the previous page of results
Selected tab in a tab setSearch paramReplace or pushPush if the tabs are separate destinations; replace if they are views of one thing
Committed search termSearch paramPush on submitSearches are decisions; the raw keystrokes are not
In-progress search textNoKeystroke frequency floods history; debounce into a committed value instead
Scroll positionNoContinuous, and the browser already restores it on history traversal
Whether a modal is openSometimesPushYes if the modal is a destination someone might link to; no if it is a confirmation
Anything sensitiveNeverHistory, referrers, logs and screenshots are all downstream of the address bar

Reading the URL as the single owner

FRAMEWORK-SPECIFICWritten framework-free against URLSearchParams and the History API so the mechanism is visible. In React the read is a useSearchParams hook, in Vue a computed over route.query, in Svelte a derivation from page.url, in Angular a mapped queryParamMap observable or signal — but in all five the rule is the same: parse once, derive everything, never mirror into a second owner.

The mistake that survives the decision to "use the URL" is mirroring: reading the URL once into a store and then reading the store everywhere. Now two things hold the value and a Back navigation updates only one of them.

The version below keeps one owner. The URL is parsed into a validated object, the query key is derived from that object, and every consumer reads the derivation. Back, reload, a pasted link and a programmatic filter change all take the identical path, so none of them needs its own handling.

Parse defensively, derive everything else
1type OrderView = { status: Status; page: number; sort: Sort; selectedId: string | null }
2
3const STATUSES = ['open', 'failed', 'done'] as const
4type Status = (typeof STATUSES)[number]
5type Sort = 'newest' | 'oldest'
6
7/** Every value here arrived from an untrusted string. Validate, clamp, default. */
8export function parseOrderView(search: string): OrderView {
9 const p = new URLSearchParams(search)
10 const status = p.get('status')
11 const page = Number(p.get('page'))
12 return {
13 status: (STATUSES as readonly string[]).includes(status ?? '') ? (status as Status) : 'open',
14 page: Number.isInteger(page) && page > 0 ? Math.min(page, 1000) : 1,
15 sort: p.get('sort') === 'oldest' ? 'oldest' : 'newest',
16 selectedId: p.get('id'),
17 }
18}
19
20/** Defaults are omitted so an untouched view has a clean address. */
21export function writeOrderView(v: OrderView, mode: 'push' | 'replace') {
22 const p = new URLSearchParams()
23 if (v.status !== 'open') p.set('status', v.status)
24 if (v.page !== 1) p.set('page', String(v.page))
25 if (v.sort !== 'newest') p.set('sort', v.sort)
26 if (v.selectedId) p.set('id', v.selectedId)
27 const url = p.size ? `${location.pathname}?${p}` : location.pathname
28 history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url)
29}
30
31/** The cache key is a derivation, not a second copy. */
32export const orderQueryKey = (v: OrderView) => ['orders', v.status, v.sort, v.page] as const

The absent-equals-default rule is what makes ?status=open and no parameter the same state, so two URLs that mean the same view produce the same cache key instead of two entries.

The four ways URL state goes wrong

Adopting URL state introduces its own failures, and all four are worth knowing before the first one is filed as "the Back button is broken".

Symptoms of URL state done badly
TriggerSymptomCauseResponse
Every keystroke writes a search paramBack has to be pressed dozens of times to leaveA continuous input pushing a history entry per changeKeep the raw input in component state; commit to the URL on pause or submit, and replace rather than push for refinements.
Filter change does not reset paginationA filtered list shows "no results" on page 7Two parameters updated independently with no invariant between themWrite the whole view object at once so cross-parameter rules are enforced in one place.
URL mirrored into a store on loadBack changes the address bar but not the viewTwo owners; the history listener updates only one of themDerive from the URL on every render; never copy it into a second owner (State Synchronization).
A parameter is renamed in a releaseOld bookmarks and links in tickets open the wrong viewThe URL is a public contract and it changed without a fallbackAccept the old name, rewrite it with replace, and remove it only after the links have aged out (Backward Compatibility: The Real Rules).
A parameter is rendered directly into the pageA crafted link executes script for whoever opens itAttacker-controlled input reaching an HTML sinkTreat parameters as untrusted; render as text, never as markup (Sanitization and Trusted HTML).
Selected ids accumulate in the query stringVery long links break when shared through some clientsUnbounded value in a container with practical length limitsCap the count, or move a bulk selection to a server-side saved view referenced by one id.

How to build it

Most important first.

  • Encode anything a user would reasonably want to send someone: filters, search terms, sort order, page or cursor, selected item, and the open tab of a tab set.
  • Keep transient and high-frequency values out: hover, drag position, scroll offset, whether a tooltip is showing, and the in-progress text of a search box before it is committed.
  • Debounce the input, not the URL write. Let the input update at keystroke speed in component state and push to the URL on a pause, so history stays walkable and the fetch fires once (Derived State).
  • Use replace for corrections and continuous adjustments, push for discrete decisions the user would expect Back to undo. A wrong choice here is the difference between Back working and Back requiring twelve presses.
  • Parse defensively: validate each parameter, clamp ranges, drop unknown values, and render a sensible view rather than an error for a malformed link. Links get truncated by chat clients and edited by hand.
  • Derive the fetch key from the URL rather than mirroring the URL into a store. One owner, many readers (Who Owns This State?).
  • Omit defaults from the URL so a fresh view has a clean address, and treat an absent parameter and its default value as the same state.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • URL-driven state makes the Back button a real undo, and Back is the most reliably available control any user has: it is keyboard-accessible, correctly labelled by the browser, present in every assistive technology, and needs no discovery. Moving tab selection into the URL is an accessibility improvement before it is a routing decision (History and Navigation).
  • A URL change is not an announcement. Single-page navigations do not move focus or notify a screen reader by themselves, so after a URL-driven view change you must move focus to the new content's heading or announce the change in a live region (Focus Management, Live Regions and Announcement).
  • Shareable URLs help users who work across devices and assistive setups — someone who begins on a phone with voice control and finishes on a desktop with a screen reader can carry the exact view over instead of reconstructing it.
  • When a filter changes the visible result set, announce the new count politely. Sighted users see the list shrink; nobody else does (Live Regions and Announcement).
  • Keep the title in step with the URL. Screen readers announce the document title on navigation, and a title that still says "Orders" after moving into an order detail gives no orientation (The Head: Metadata That Changes Rendering).

What can go wrong

Failure modes
  • History flooding: a slider or a search box writing on every change, producing hundreds of entries so Back is unusable and the memory for the history stack grows.
  • The mitigation failing: debouncing applied to the *fetch* but not the URL, so the address bar still churns; or replace used everywhere, so Back never undoes anything the user did.
  • URL and store both holding the value, drifting apart on any code path that updates only one (State Synchronization).
  • Unbounded parameter values — a selected-ids list that grows with each click — hitting practical URL length limits and being silently truncated by an intermediary.
  • Sensitive values encoded into the query string, then leaked through referrer headers, server access logs, screenshots and shared links (Storage Security and Durability).
  • A parameter rename shipped without a fallback, invalidating every bookmark and every link in every ticket ever filed.
What can arrive out of order
  • A Back navigation arriving while the fetch for the previous URL is still in flight: the older response resolves last and overwrites the restored view. Key the response by the URL it came from and discard mismatches (Out-of-Order Responses).
  • A debounced URL write landing after the user has already navigated elsewhere, pushing a stale entry on top of the new one (Cancelling a Request Nobody Is Waiting For).
  • Two URL writes in the same tick from different components — a filter and a page reset — where the second reads a stale copy of the search params and drops the first.
  • A shared link opened against a newer release whose parameter schema differs from the one that produced it (Long-Lived Clients and Version Skew).
Security
  • Query strings are the least private place in the browser. They appear in history, in referrer headers to other origins, in proxy and server logs, in analytics payloads, in screenshots and in every link a user pastes anywhere.
  • Never put a token, a session identifier, a password reset secret or personal data in a query string; use a cookie, a header or a POST body instead (Cookies vs Script-Readable Tokens).
  • Everything in the URL is attacker-controlled input. A parameter rendered into the DOM without escaping is a reflected XSS sink, and one used to build a redirect target is an open-redirect (Cross-Site Scripting).
  • The browser enforces nothing about parameter meaning. A user editing ?accountId= to someone else's id is a normal action for them and an authorization test for your server (What the Frontend Is Responsible For in Auth).
Misreads
  • "The URL is for routing." Routing is one use of it. Search parameters are a state store with history, restoration and sharing built in.
  • "Query strings are ugly, so keep them short." Legibility matters less than addressability; a long URL that restores the view beats a short one that does not. Where it truly matters, encode compactly — but do not delete the capability for aesthetics.
  • "Everything should be in the URL." Transient and high-frequency values flood history and make Back useless. Shareability is the test, not completeness.
  • "The router library handles this." It gives you read and write access to the address bar. Which values go there, and whether each write pushes or replaces, is your design.
  • "Back works because it is a single-page app." Back works because you created history entries. A framework does not infer which of your state changes were navigation-shaped.

Measuring it, and what changes in the field

How you would see this
  • The address bar itself: perform every meaningful interaction and watch whether it changes. That is the complete audit.
  • Press Back ten times after a normal session. If it walks the user's actual decisions, the push/replace policy is right; if it exits after one press or takes twelve, it is not.
  • Paste every URL your app produces into a private window. Anything that does not reproduce the view is state you forgot to encode; anything that reveals more than intended is a leak.
  • The Network panel shows whether a URL change caused one fetch or several — the usual sign of a mirrored value updating twice (Five Components, One Request).
Slow device, slow network, large data, old tab
  • On a slow network, URL state is the difference between a reload restoring the view immediately and a reload restoring an empty page that then discovers what to fetch.
  • On a memory-constrained device, tab discard destroys everything except the URL and persisted storage; a URL-driven view returns intact (The Multi-Process Browser).
  • With a large dataset, encoding the page or cursor in the URL is what makes a deep result position linkable at all (Pagination From the Interface Backwards).
  • In an old tab, the URL may reference an entity that has since been deleted, a filter value that no longer exists, or a parameter this release no longer understands (Long-Lived Clients and Version Skew).
What this costs
  • URL state is public state. Shareability and exposure are the same property, and you cannot have one without the other.
  • Serialisation is real work: encoding, decoding, validation, defaults, and a compatibility story every time the shape changes. In-memory state has none of that.
  • A URL is a contract with every bookmark and every link ever sent. Renaming a parameter is a breaking change to users you cannot notify (Backward Compatibility: The Real Rules).
  • Some state genuinely does not belong there, and the discipline of deciding case by case is slower than the reflex of putting everything in one place.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe History API, session history restoration, referrer exposure and Back/Forward semantics are specified behaviour across Blink, Gecko and WebKit. Practical URL length limits are not specified and differ by browser and by intermediary, so treat "a few thousand characters" as a soft ceiling rather than a rule.
  • FRAMEWORK-SPECIFICEvery router exposes this, with different defaults for the push/replace decision. React Router and TanStack Router give a useSearchParams-style pair and push by default unless told to replace; Vue Router takes a query object on router.push versus router.replace; SvelteKit reads page.url.searchParams and writes with goto(url, { replaceState }); Angular's Router uses queryParamMap with queryParamsHandling and replaceUrl. Code that assumes one library's default push/replace behaviour produces a differently-broken Back button in each of the others.
  • SPEC-EVOLVINGThe Navigation API is replacing ad-hoc History API usage for interception, traversal and scroll control, and its availability differs across engines today. Router internals built on it will change; the design principle — what belongs in the URL — does not.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — a URL is a durable, shareable reference to a view that other clients may have changed since the link was made; opening an old link is a read against a moved target.