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
- 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.
- Wrote the full essay body.
- Created.