Skip to content

URL State and Router Integration

advanced16 min read

The URL Is State You're Ignoring

Here's a litmus test for your app: can a user copy the URL, send it to a colleague, and the colleague sees the exact same view? Same filters, same sort order, same page, same expanded panels?

If the answer is no, you have state that should be in the URL but isn't. Every time you store a filter in useState instead of searchParams, you break three web fundamentals:

  1. Shareability — the URL doesn't capture the current view
  2. Bookmarkability — saving the URL doesn't save the state
  3. Back/forward navigation — the browser can't undo state changes

The URL is the original state manager. It's been syncing state across clients since 1991. And for an entire category of state — filters, pagination, sort order, tabs, expanded sections — it's still the best tool.

Mental Model

Think of the URL as a serialized snapshot of your view. Every query parameter is a piece of state, and the URL is the single source of truth. When the URL changes, the view updates. When the view needs to change, you update the URL. There's no separate store, no sync logic, no "two sources of truth" bugs. The URL IS the state, and React reads from it like any other state source.

useSearchParams in Next.js

The built-in hook for reading URL search parameters:

'use client';

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

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

  const category = searchParams.get('category') ?? 'all';
  const sort = searchParams.get('sort') ?? 'relevance';
  const page = Number(searchParams.get('page')) ?? 1;

  function updateParam(key: string, value: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set(key, value);
    if (key !== 'page') params.set('page', '1');
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <div>
      <select value={category} onChange={(e) => updateParam('category', e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      <select value={sort} onChange={(e) => updateParam('sort', e.target.value)}>
        <option value="relevance">Relevance</option>
        <option value="price">Price</option>
        <option value="newest">Newest</option>
      </select>
    </div>
  );
}

This works, but it has problems: manual string parsing, no type safety, verbose update logic, and every router.push triggers a full server-side re-render in the App Router.

Quiz
In Next.js App Router, what happens when you call router.push() to update search params?

nuqs: Type-Safe URL State

nuqs (pronounced 'nukes') is a library that makes URL search params behave like React state with full type safety. It's used by Vercel, Sentry, Supabase, and Clerk.

'use client';

import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs';

const sortOptions = ['relevance', 'price', 'newest'] as const;

function ProductFilters() {
  const [category, setCategory] = useQueryState('category', { defaultValue: 'all' });
  const [sort, setSort] = useQueryState(
    'sort',
    parseAsStringEnum(sortOptions).withDefault('relevance')
  );
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));

  return (
    <div>
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      <select value={sort} onChange={(e) => setSort(e.target.value as typeof sortOptions[number])}>
        <option value="relevance">Relevance</option>
        <option value="price">Price</option>
      </select>
      <button onClick={() => setPage((p) => p + 1)}>Next Page</button>
    </div>
  );
}

The API feels exactly like useState, but the state lives in the URL. parseAsInteger automatically converts the string "3" to the number 3. parseAsStringEnum restricts values to the allowed set.

Shallow Updates

By default, nuqs updates the URL without triggering a server re-render (shallow mode). This is critical for responsive filters:

const [query, setQuery] = useQueryState('q', {
  shallow: true,
  throttleMs: 300,
});

shallow: true means the URL updates, the browser history entries are created, but Next.js doesn't re-render Server Components. Combined with throttleMs, you get debounced URL updates while the user types — no server round-trips until they stop.

When you actually need server data based on the new params, set shallow: false:

const [page, setPage] = useQueryState('page', {
  ...parseAsInteger.withDefault(1),
  shallow: false,
});
Quiz
You use nuqs with shallow: true for a search input. The URL updates as the user types, but the product list doesn't update. Why?

Encoding Complex State in URLs

Sometimes you need more than simple key-value pairs. Here's how to handle complex state:

Arrays

import { parseAsArrayOf, parseAsString } from 'nuqs';

const [tags, setTags] = useQueryState(
  'tags',
  parseAsArrayOf(parseAsString, ',').withDefault([])
);
// URL: ?tags=react,typescript,nextjs
// Value: ['react', 'typescript', 'nextjs']

JSON-Encoded Objects

import { parseAsJson } from 'nuqs';
import { z } from 'zod';

const filterSchema = z.object({
  priceMin: z.number(),
  priceMax: z.number(),
  inStock: z.boolean(),
});

const [filters, setFilters] = useQueryState(
  'filters',
  parseAsJson(filterSchema.parse)
);
// URL: ?filters={"priceMin":0,"priceMax":100,"inStock":true}
URL length limits

URLs should stay under 2000 characters for broad compatibility. Don't encode large datasets or deeply nested objects. If your URL state exceeds a few hundred characters, consider whether all of it truly needs to be shareable.

Server Component Access

nuqs supports reading URL state in Server Components without prop drilling:

import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server';

const searchParamsCache = createSearchParamsCache({
  category: parseAsString.withDefault('all'),
  page: parseAsInteger.withDefault(1),
});

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const { category, page } = searchParamsCache.parse(await searchParams);
  const products = await fetchProducts({ category, page });
  return <ProductList products={products} />;
}

Type-safe, validated search params in Server Components — no manual parsing or as casts.

When URL State Beats React State

ScenarioReact State (useState/Zustand)URL State (nuqs/searchParams)
User shares current viewLost — recipient sees defaultsPreserved — URL captures everything
User bookmarks the pageLost — state resets on loadPreserved — state is in the URL
User hits back buttonLost — can't undo state changesWorks — browser history tracks URL changes
Page refreshLost — state resetsPreserved — URL survives refresh
SSR/SSGNot available on serverAvailable as searchParams in Server Components
Performance for rapid updatesInstant — no URL syncSlight overhead — URL serialization + history
Private state (sidebar toggle)Perfect — no URL pollutionOverkill — not everything belongs in the URL

The rule: if the user would benefit from sharing or bookmarking a specific view, the state belongs in the URL. Filters, sort order, pagination, active tab, search query, expanded/collapsed sections — all URL state candidates.

Quiz
Your product page has a 'Quick View' modal that shows product details. Should the modal's open/closed state be in the URL?

Common Patterns

Reset Page on Filter Change

When the user changes a filter, reset to page 1:

const [category, setCategory] = useQueryState('category');
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));

function handleCategoryChange(newCategory: string) {
  setCategory(newCategory);
  setPage(1);
}

Batch URL Updates

Update multiple params at once to avoid multiple history entries:

import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs';

const [filters, setFilters] = useQueryStates({
  category: parseAsString.withDefault('all'),
  sort: parseAsString.withDefault('relevance'),
  page: parseAsInteger.withDefault(1),
});

function resetAllFilters() {
  setFilters({
    category: 'all',
    sort: 'relevance',
    page: 1,
  });
}

useQueryStates batches all updates into a single URL change and a single history entry.

What developers doWhat they should do
Reading URL params into a Redux/Zustand store on mount, then using the store as the source of truth
Two sources of truth always drift. The URL will say ?page=3 but your store says page 5. Bugs that are incredibly hard to debug.
Read directly from the URL with useSearchParams or nuqs. The URL IS the source of truth.
Storing every piece of state in the URL (sidebar open, tooltip visible, dropdown expanded)
URL pollution makes URLs ugly, hard to read, and can hit length limits. Transient UI state (tooltips, dropdowns) doesn't belong in the URL.
Only store state that benefits from shareability, bookmarkability, or back-button navigation
Using router.push for every keystroke in a search input
Every router.push creates a history entry and potentially triggers a server re-render. 10 keystrokes = 10 history entries the user has to back through. Throttle or debounce.
Use nuqs with throttleMs or debounce the URL update
Key Rules
  1. 1URL state is for shareable, bookmarkable, back-button-navigable state: filters, sort, pagination, search, active tab.
  2. 2The URL IS the source of truth. Never copy URL params into a separate store.
  3. 3Use nuqs for type-safe URL state with automatic parsing, validation, and shallow updates.
  4. 4Shallow updates change the URL without server re-renders — essential for responsive filter UIs.
  5. 5Batch related URL updates (useQueryStates) to avoid multiple history entries.
  6. 6Not everything belongs in the URL. Transient UI state (tooltips, modals, dropdowns) should stay in React state.