The URL is a state manager

If a piece of state describes what the user is looking at — filters, search terms, a selected row — it belongs in the URL. I’ve shipped this twice in production: admin filters held in query params via nuqs, and an open-source library that serialises AG Grid filter models into readable query strings. Both replaced screenshots with links.

What belongs in the URL, and what doesn’t

The test I use: does this state describe the view, or the session? A date filter, a search term, a status toggle, the selected tab — those describe the view. Two people looking at the same URL should see the same thing, so the URL is where that state lives. Which dropdown is currently open, what’s focused, half-typed input — that’s interaction state, and it dies with the render. Auth and identity belong to the session, never the URL.

Putting view state in the URL buys three behaviours you otherwise have to build: refresh keeps the user where they were, the back button works as a history of views rather than a trap, and any view can be handed to a colleague as a link. The last one is the quiet productivity win. Before I built ag-grid-url-sync, the way analysts shared a filtered grid was a screenshot plus a numbered list of instructions for reproducing it.

One param per control: nuqs in an admin

The first production version of this pattern was the admin for a bookings app I built for a restaurant client. The filter bar started life as a native GET form, and that design contained a bug worth understanding: a form submit re-serialises every field into the query string at once, which couples all the controls together. Clear one input and submit, and you’ve rewritten the others too. The URL was being treated as a single write-once blob.

The replacement inverts that: each control owns exactly one query param, bound with one useQueryState hook from nuqs. Changing the date filter touches ?date= and nothing else. Empty values drop out of the URL automatically (clearOnDefault), so a clean view is a clean URL, and each update uses history: "replace" so twenty filter tweaks don’t become twenty back-button stops.

Two configuration details did most of the debugging for me, so they’re worth naming. First, nuqs defaults to shallow URL updates — the address bar changes but the router never hears about it, so server loaders don’t re-run and the data on screen silently stops matching the URL. Server-rendered apps need shallow: false, set once on the adapter rather than per-hook. Second, the adapter is router-specific (nuqs/adapters/react-router/v7 in this case) — wire the wrong one and everything type-checks while nothing updates.

26 filter operations in a query string: ag-grid-url-sync

The harder version of the problem is when the state isn’t five scalar filters but a nested object. AG Grid’s filter model is a tree: per-column filter types, operators, multiple conditions joined by AND/OR. ag-grid-url-sync serialises all 26 native filter operations into query strings designed to be read by humans first — ?f_age_gt=30&f_name_contains=smith — because a URL someone can read is a URL someone will trust and edit by hand.

Designing the grammar was most of the work, and most of the grammar decisions were about restraint: flat keys with a f_ prefix rather than an encoded JSON blob, operator names spelled out rather than symbols that need percent-encoding, one param per column condition so partial edits stay local — the same one-param-per-control principle as the admin filter bar, arrived at independently.

That grammar buys readability with length, and it’s worth being honest about the trade. ?f_age_gt=30 costs more characters than a packed blob would, and every spelled-out operator name costs more again; a compressed JSON payload would fit far more grid state into the same URL. I took the verbose side deliberately, because the failure mode I was designing against was an analyst not trusting a URL enough to edit it — and an opaque blob fails that test immediately, while a long URL only fails once it hits the ceiling. Which it can: this is the one place in either project where the length limit below is a real constraint rather than a theoretical one. The full design is written up in the library’s reference post.

The costs: encoding, history, and back-button semantics

The pattern isn’t free, and the costs cluster in three places.

Encoding. URLs are a hostile serialisation target: reserved characters, percent-encoding, plus-versus-space ambiguity, and a practical length ceiling around 2,000 characters before proxies and social-media unfurlers start truncating. Scalar filters never get near the ceiling; serialised grid state can. The mitigation is the same in both projects — drop defaults, keep names short, and refuse to encode what doesn’t describe the view.

History semantics. Every URL write is a choice between push and replace, and the wrong default ruins the back button in one of two directions: push everything and back walks through every keystroke; replace everything and back skips views the user thinks of as places. My rule: discrete view changes (a tab, a page) push; continuous refinements (typing, toggling filters) replace.

Coordination. The URL is global, so two components that both write it can race, and the symptom would be filters that quietly undo each other. I designed against this rather than debugged it: one param per control partitions ownership so most writes can’t collide by construction, and the writes that inherently touch several params at once — a “clear all” button, say — go through a single code path instead of each control clearing itself. It’s the cost I’d watch most closely on a larger surface than either of these, because it’s the one that produces a bug you can’t reproduce on demand.

When I’d reach for it again

The heuristic that survives both projects: anything a user might screenshot to describe, they should be able to link instead. Filtered tables, search results, date-ranged dashboards, admin queues — URL. Anything you’d be embarrassed to see pasted into a chat channel — not URL.

It’s also the rare state-management choice that gets simpler as the app grows: no store, no context, no serialisation layer you own, because the platform ships one. The browser has had a shareable, bookmarkable, back-button-aware state container since the mid-1990s. Most of the work is deciding to use it.

Revisions

  1. Published. Three pre-publication corrections: the closing claim dated the browser's shareable, back-button-aware state container to 1991, which overstated it — the back button and GET-form query state arrived mid-1990s, so the date moved; the ag-grid-url-sync section deferred to other writing without carrying its own evidence, so it now states the readability-versus-URL-length trade the grammar actually makes and why I took the verbose side; and the write-contention cost was written as though observed, so it now says plainly that I designed against the race rather than debugged it.
  2. Wrote the full essay body.
  3. Created.