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.
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.
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:
// 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:
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:
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.
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):
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
- 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. useQueryStatefrom nuqs is a drop-inuseStatereplacement. UseparseAsBoolean,parseAsInteger, andparseAsStringLiteralfor non-string values. Leave plain strings undecorated.- Use
?view=stepto 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
- nuqs documentation: Complete API reference for useQueryState, server-side parsing, and Next.js App Router integration.
- URL as State -- Kent C. Dodds: A talk on why the URL is underused as a state mechanism in modern React apps. Note: verify this YouTube link before publishing.
- Linking and Navigating -- Next.js Docs: How the App Router handles navigation and why
replacevspushmatters for URL state.
Keep reading