URL as State: Shareable, Bookmarkable, Back-Button-Aware

useState vanishes on refresh. The URL does not. Using query parameters as the source of truth gives you shareable links, browser history, and zero state loss on navigation -- all without a database.

June 7, 20264 min read

The URL is the oldest state container the web has. It predates React by decades. And it is the one container that survives page refresh, copy-paste, and email.

Everything put into useState is invisible to the URL. Everything put into the URL is invisible to useState. The result: forms that reset when you hit back, search results that disappear on refresh, filters that cannot be shared.

What URL State Solves

A flight search form. User fills in destination, dates, passenger count, then hits search.

They see results. They want to share those results with a colleague.

With useState:

  • Back button → form resets to empty
  • Refresh → results disappear
  • Copy URL → colleague gets an empty form
  • Bookmarking → useless

With query parameters as state:

  • Back button → form restores from URL, results still visible
  • Refresh → URL re-runs the search with the same inputs
  • Copy URL → colleague sees the exact same view
  • Bookmarking → works the way every web user expects

The browser history mechanism handles all of this for free. The URL is just not being used.

State stored in useState is private to the session; state stored in URL query parameters survives refresh, sharing, and back-button navigation with no extra server calls ExpandState stored in useState is private to the session; state stored in URL query parameters survives refresh, sharing, and back-button navigation with no extra server calls

nuqs: useQueryState as a Drop-In Replacement

nuqs (N-U-Q-S) is the most ergonomic library for this. The API mirrors useState exactly -- just with a URL key instead of an initial value:

TypeScript
// Before: useState const [destination, setDestination] = useState(''); const [startDate, setStartDate] = useState(''); const [isOneWay, setIsOneWay] = useState(false); // After: useQueryState import { useQueryState, parseAsBoolean } from 'nuqs'; const [destination, setDestination] = useQueryState('destination'); const [startDate, setStartDate] = useQueryState('startDate'); const [isOneWay, setIsOneWay] = useQueryState('isOneWay', parseAsBoolean.withDefault(false));

URL while the user types: ?destination=Osaka&startDate=2026-07-10&isOneWay=true

Every value is already a string in the URL, so plain strings need no parser. Booleans, numbers, and enums need one:

TypeScript
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral } from 'nuqs'; // Boolean: appears as ?isOneWay=true, absent when false (default) const [isOneWay] = useQueryState('isOneWay', parseAsBoolean.withDefault(false)); // Integer: appears as ?passengers=2 const [passengers] = useQueryState('passengers', parseAsInteger.withDefault(1)); // String enum: only valid values accepted const [sort] = useQueryState( 'sort', parseAsStringLiteral(['price', 'duration', 'departure'] as const).withDefault('price') );

Replace History, Not Push

By default, nuqs uses router.replace rather than router.push. This means every keystroke does not add a browser history entry. The user does not have to hit back twenty times to exit the form.

Each form interaction is treated as a single transaction -- the URL reflects current state, but history is not spammed.

Deriving State Instead of Duplicating It

Once a flight is selected from results, you only need its ID in the URL -- not the whole object. The full object is derived from the ID by looking it up in the results:

TypeScript
const [selectedFlightId, setSelectedFlightId] = useQueryState('flightId'); // Derived -- no separate state needed const selectedFlight = flights.find(f => f.id === selectedFlightId) ?? null;

This is the same principle from eliminating redundant state. The URL holds the minimal identifier; the component derives the rest.

Tracking View/Step

Multi-step flows need to know which step the user is on. Without a view parameter, refreshing mid-flow dumps the user back to step one.

TypeScript
import { parseAsStringLiteral } from 'nuqs'; const [view, setView] = useQueryState( 'view', parseAsStringLiteral(['search', 'results', 'confirm'] as const).withDefault('search') ); // Navigate between steps setView('results'); // URL becomes ?view=results&destination=...

Now refresh and share both work for every step of the flow.

This is explicit state over implicit state -- the same principle as finite states. The alternative -- inferring the current view from whether results exist -- breaks when users deep-link directly to a step. An explicit ?view=confirm parameter is unambiguous.

Validating URL State

Users can edit the URL directly. ?view=confirm&flightId=abc is reachable even if the user has not actually selected a flight through normal flow.

Validate on page load using Zod (same pattern as form validation):

TypeScript
import { z } from 'zod'; const FlightSearchParams = z.object({ destination: z.string().min(1), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), view: z.enum(['search', 'results', 'confirm']).default('search'), flightId: z.string().optional(), }); // If validation fails: redirect to /search with clean params const parsed = FlightSearchParams.safeParse(rawParams); if (!parsed.success) redirect('/search');

The same reducer rule applies: invalid transitions should be ignored. If the URL says ?view=confirm but flightId is missing, redirect to ?view=search.

The Essentials

  1. The URL is the source of truth the browser already knows how to handle. Back button, refresh, bookmarks, and share links all work correctly once form state lives in query parameters instead of useState.
  2. useQueryState from nuqs is a drop-in useState replacement. Use parseAsBoolean, parseAsInteger, and parseAsStringLiteral for non-string values. Leave plain strings undecorated.
  3. Use ?view=step to track position in multi-step flows. Explicit step state makes refresh, deep-linking, and sharing work. Inferring the current step from data presence is fragile.

Further Reading and Watching