DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

URL State in the Next.js App Router: How I Stopped Losing UI State on Refresh

Headline: URL state means storing UI state — filters, active tab, search query, pagination — in the URL query string instead of React useState, so a refresh, a shared link, or the back button restores exactly what the user saw. In the Next.js App Router I read it with useSearchParams() or the async searchParams prop, write it with router.replace(), and reach for nuqs when I want a type-safe, useState-shaped API. The trap that cost me an afternoon: a component calling useSearchParams() must sit inside a React <Suspense> boundary or the production build fails.

A dashboard I built kept its filter panel in useState. Users would filter to a subset, refresh to pull fresh data, and lose every selection. The fix was not more state management — it was less: move the state a user expects to survive a reload into the URL, where the browser persists it for free.

Key takeaways

  • URL state stores UI state in the URL query string, so a refresh, a shared link, or the back button restores what the user saw; useState is lost on every reload.
  • Read query state with useSearchParams() in a client component, or the async searchParams prop on a server component (a Promise you await in Next.js 15).
  • Write it with router.replace() and a URLSearchParams object; use replace not push so filter tweaks do not flood the back-button history.
  • The nuqs library gives a useState-shaped hook, useQueryState, with typed parsers like parseAsInteger instead of raw strings.
  • A component calling useSearchParams() must be wrapped in <Suspense> during static rendering, or the Next.js build errors.

When should I store state in the URL instead of useState?

Store state in the URL when the user expects it to survive a refresh, a shared link, or a bookmark — filters, the active tab, a search term, sort order, and pagination all qualify. Keep state in useState when it is ephemeral: a hover state, whether a dropdown is open, an unsaved form draft. My test is one question: if the user copies this URL and sends it to a colleague, should the colleague see the same view? If yes, it belongs in the URL. URL state also kills a class of sync bugs — there is one source of truth, the query string, instead of a useState value that drifts from the address bar.

How do I read search params in the Next.js App Router?

In a client component, call useSearchParams() from next/navigation, which returns a read-only URLSearchParams.

'use client';
import { useSearchParams } from 'next/navigation';

function StatusBadge() {
  const searchParams = useSearchParams();
  const status = searchParams.get('status') ?? 'all';
  return <span>{status}</span>;
}
Enter fullscreen mode Exit fullscreen mode

In a server component, skip the hook and read the searchParams prop. In Next.js 15 it is a Promise, so you await it and fetch the correct data before rendering.

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ status?: string }>;
}) {
  const { status = 'all' } = await searchParams;
  const rows = await getRows(status);
  return <Table rows={rows} />;
}
Enter fullscreen mode Exit fullscreen mode

How do I write to the URL without triggering a full navigation?

Build a fresh URLSearchParams from the current params, set your key, and call router.replace(). Seeding from the existing params preserves every other query key.

'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';

function StatusFilter() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  function setStatus(value: string) {
    const params = new URLSearchParams(searchParams);
    params.set('status', value);
    router.replace(`${pathname}?${params.toString()}`);
  }

  return (
    <select onChange={(e) => setStatus(e.target.value)}>
      <option value="all">All</option>
      <option value="open">Open</option>
    </select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use router.replace(), not router.push(). push adds a history entry per change, so the back button steps through each tweak; replace keeps one entry.

What is nuqs, and what does useQueryState add?

nuqs is a type-safe search-params state manager for React that turns the read-plus-router.replace dance into one useState-shaped hook. useQueryState(key, parser) returns a [value, setValue] tuple, but the value lives in the URL.

'use client';
import { useQueryState, parseAsInteger } from 'nuqs';

function ProductSearch() {
  const [query, setQuery] = useQueryState('q', { defaultValue: '' });
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));

  return (
    <input value={query} onChange={(e) => setQuery(e.target.value || null)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

The parsers are the win: parseAsInteger, parseAsBoolean, parseAsArrayOf, parseAsStringEnum, and parseAsIsoDateTime convert the URL string into the type your component wants and back on write. Setting a value to null clears the key. In nuqs v2 you wrap the app once in <NuqsAdapter> from nuqs/adapters/next/app, and batch several keys with useQueryStates.

Native searchParams or nuqs — which should I pick?

Concern Native useSearchParams nuqs
Types Strings only, parse yourself Typed parsers (int, bool, array, enum, date)
Setup None <NuqsAdapter> wrapper once
Write API Manual URLSearchParams + router.replace useState-like tuple
Many keys Hand-built query string useQueryStates batches them
Defaults Manual ?? fallback withDefault + clearOnDefault
Rapid writes Debounce yourself Built-in throttling

What breaks — Suspense, throttling, and stale server data?

The build aborts with "useSearchParams() should be wrapped in a suspense boundary". During static rendering, any component reading useSearchParams() must sit inside a <Suspense> boundary, because the params are only known at request time.

import { Suspense } from 'react';

<Suspense fallback={<FiltersSkeleton />}>
  <StatusFilter />
</Suspense>
Enter fullscreen mode Exit fullscreen mode

Two more edges. Writing to the URL on every keystroke spams history and re-renders — nuqs throttles by default and exposes throttleMs; with native code you debounce yourself. And whether a URL change re-runs server components depends on shallow routing: nuqs defaults to shallow: true (client-only URL update), so set shallow: false when a server component filters through the searchParams prop and needs the new value.

FAQ

Q: When should I keep state in the URL versus useState?
A: URL for state that should survive a refresh, shared link, or bookmark — filters, tabs, search, sort, pagination. useState for ephemeral UI like open menus, hover, or an unsaved form draft.

Q: Do I need nuqs, or is useSearchParams enough?
A: useSearchParams plus router.replace covers reading and writing. nuqs adds typed parsers, a useState-like API, multi-key batching, and throttling. One or two string params: native. Many typed params: nuqs.

Q: Why does my build fail with "useSearchParams() should be wrapped in a suspense boundary"?
A: During static rendering Next.js requires any component calling useSearchParams() to be inside a <Suspense> boundary, since the value is only known at request time. Wrap that component.

Q: Should I use router.push or router.replace for filter changes?
A: router.replace. push adds a history entry per change, so the back button walks through every tweak; replace keeps one entry.

Q: Does updating the URL re-run my server components?
A: With native router.replace, yes. With nuqs it depends on the shallow option: default shallow: true is client-only, shallow: false triggers the server refetch.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)