Skip to content

Fix: nuqs Not Working — URL State Not Syncing, Type Errors, or Server Component Issues

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix nuqs URL search params state management — useQueryState and useQueryStates setup, parsers, server-side access, shallow routing, history mode, and Next.js App Router integration.

The Problem

useQueryState updates state but the URL doesn’t change:

import { useQueryState } from 'nuqs';

function SearchPage() {
  const [query, setQuery] = useQueryState('q');
  // setQuery('hello') updates the component but URL stays the same
}

Or the URL changes but the component doesn’t re-render:

const [page, setPage] = useQueryState('page', parseAsInteger);
// URL shows ?page=2 but component still renders page 1

Or using nuqs in a Server Component throws:

Error: useQueryState is a client hook and cannot be used in Server Components

Or default values are null instead of a fallback:

const [sort, setSort] = useQueryState('sort');
console.log(sort);  // null — not 'newest' as expected

The whole point of nuqs is shareable URL state. So when it breaks, your support inbox fills up with screenshots: “I sent my coworker a link to the filtered dashboard and they see the unfiltered version.” Or, “I bookmarked the search and it now loads with no filters applied.” The bug looks visual but the blast radius is collaboration itself — every internal report link, every customer-shared filter, every browser-tab restore.

The classic post-incident pattern is a missing NuqsAdapter. State works inside the component because React holds it in memory, but setQuery never reaches window.history.replaceState. The URL stays bare. A user copies the link, pastes it in Slack, and the recipient lands on an empty page. Catch this in CI with a Playwright test that sets a filter, reads window.location.search, and asserts the parameter is present.

A subtler incident: the URL updates but server data doesn’t refetch. With shallow: true, the URL changes but Next.js does not re-run the page’s Server Component. Users see stale results. The shared link then loads correctly (because the cold load reads searchParams server-side), which makes the bug seem intermittent. The fix is to set shallow: false for any state that influences server-rendered output, and to add a Playwright assertion that compares post-update DOM against the cold-loaded DOM.

Why This Happens

nuqs manages React state that’s synchronized with URL search parameters. It runs client-side and requires specific setup:

  • nuqs needs a provider in Next.js App RouterNuqsAdapter must wrap your app. Without it, state updates work in memory but don’t sync to the URL.
  • Parsers determine the typeuseQueryState('page') returns a string | null by default. For numbers, use parseAsInteger. For booleans, use parseAsBoolean. Without a parser, the value is always a string.
  • Server Components can’t use hooksuseQueryState is a React hook. For Server Components, read searchParams directly from the page props. nuqs provides createSearchParamsCache for type-safe server-side access.
  • Default values require .withDefault() — without it, the initial value is null when the URL parameter is absent. Chain .withDefault('value') to set a fallback.

A second class of issues comes from the gap between client state and server-rendered HTML. nuqs is a client-side library — it reads window.location.search and writes to it via the History API. But in Next.js App Router, the initial render runs on the server, where there is no window. If you read state with useQueryState and render results from that state, the first paint shows the default value and the second paint (after hydration) shows the URL value. Users see a flash of unfiltered content. The fix is to also parse searchParams server-side with createSearchParamsCache and pass the parsed values down — that way the first paint already matches the URL.

A third trap is throttling and history mode. Without throttleMs, every keystroke on a search input writes a new entry to the URL bar. Combined with history: 'push' (or any history mode that adds entries), the back button traps the user inside their own typing. Use history: 'replace' for input-as-you-type and reserve push for state transitions you actually want navigable, like switching between dashboard tabs or paginating.

Fix 1: Basic Setup with Next.js App Router

npm install nuqs
// app/layout.tsx — add the adapter
import { NuqsAdapter } from 'nuqs/adapters/next/app';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <NuqsAdapter>{children}</NuqsAdapter>
      </body>
    </html>
  );
}

// For Pages Router:
// import { NuqsAdapter } from 'nuqs/adapters/next/pages';
// Wrap in _app.tsx
// components/SearchFilter.tsx — client component
'use client';

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

const sortOptions = ['newest', 'oldest', 'popular'] as const;

function SearchFilter() {
  // String parameter — ?q=hello
  const [query, setQuery] = useQueryState('q', {
    defaultValue: '',
    shallow: false,  // Trigger server-side data refetch
  });

  // Integer parameter — ?page=2
  const [page, setPage] = useQueryState(
    'page',
    parseAsInteger.withDefault(1)
  );

  // Enum parameter — ?sort=newest
  const [sort, setSort] = useQueryState(
    'sort',
    parseAsStringEnum(sortOptions).withDefault('newest')
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value || null)}
        placeholder="Search..."
      />

      <select value={sort} onChange={(e) => setSort(e.target.value as typeof sort)}>
        {sortOptions.map(opt => (
          <option key={opt} value={opt}>{opt}</option>
        ))}
      </select>

      <div>
        <button
          onClick={() => setPage(p => Math.max(1, (p ?? 1) - 1))}
          disabled={page <= 1}
        >
          Previous
        </button>
        <span>Page {page}</span>
        <button onClick={() => setPage(p => (p ?? 1) + 1)}>
          Next
        </button>
      </div>

      {/* Reset all — set to null removes from URL */}
      <button onClick={() => {
        setQuery(null);
        setPage(null);
        setSort(null);
      }}>
        Clear Filters
      </button>
    </div>
  );
}

Fix 2: Multiple Parameters with useQueryStates

'use client';

import { useQueryStates, parseAsInteger, parseAsBoolean, parseAsStringEnum } from 'nuqs';

function ProductFilters() {
  const [filters, setFilters] = useQueryStates({
    q: { defaultValue: '' },
    page: parseAsInteger.withDefault(1),
    perPage: parseAsInteger.withDefault(20),
    sort: parseAsStringEnum(['price', 'name', 'rating']).withDefault('name'),
    inStock: parseAsBoolean.withDefault(false),
    minPrice: parseAsInteger,
    maxPrice: parseAsInteger,
  }, {
    shallow: false,  // Refetch data on change
  });

  // filters.q, filters.page, filters.sort, etc. — all typed
  // URL: ?q=shoes&page=2&sort=price&inStock=true&minPrice=20

  return (
    <div>
      <input
        value={filters.q}
        onChange={(e) => setFilters({ q: e.target.value || null, page: 1 })}
      />

      <label>
        <input
          type="checkbox"
          checked={filters.inStock}
          onChange={(e) => setFilters({ inStock: e.target.checked, page: 1 })}
        />
        In Stock Only
      </label>

      <select
        value={filters.sort}
        onChange={(e) => setFilters({ sort: e.target.value as any })}
      >
        <option value="name">Name</option>
        <option value="price">Price</option>
        <option value="rating">Rating</option>
      </select>

      {/* Reset all filters */}
      <button onClick={() => setFilters(null)}>Clear All</button>
    </div>
  );
}

Fix 3: Server-Side Access

// lib/searchParams.ts — define parsers once, use on server and client
import { createSearchParamsCache, parseAsInteger, parseAsString, parseAsStringEnum } from 'nuqs/server';

export const searchParamsCache = createSearchParamsCache({
  q: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1),
  sort: parseAsStringEnum(['newest', 'oldest', 'popular']).withDefault('newest'),
});
// app/posts/page.tsx — Server Component
import { searchParamsCache } from '@/lib/searchParams';
import { SearchFilter } from '@/components/SearchFilter';

export default async function PostsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[]>>;
}) {
  // Parse and validate search params on the server
  const { q, page, sort } = searchParamsCache.parse(await searchParams);

  // Fetch data with validated params
  const posts = await db.query.posts.findMany({
    where: q ? like(posts.title, `%${q}%`) : undefined,
    orderBy: sort === 'newest' ? desc(posts.createdAt) : asc(posts.createdAt),
    limit: 20,
    offset: (page - 1) * 20,
  });

  return (
    <div>
      <SearchFilter />  {/* Client component uses useQueryState */}
      <PostList posts={posts} />
    </div>
  );
}

Fix 4: Custom Parsers

import { createParser } from 'nuqs';

// Date parser — ?date=2024-03-15
const parseAsDate = createParser({
  parse: (value) => {
    const date = new Date(value);
    return isNaN(date.getTime()) ? null : date;
  },
  serialize: (date) => date.toISOString().split('T')[0],
});

// Array parser — ?tags=react,typescript,nextjs
const parseAsCommaArray = createParser({
  parse: (value) => value.split(',').filter(Boolean),
  serialize: (arr) => arr.join(','),
});

// JSON parser — ?config={"theme":"dark","lang":"en"}
const parseAsJson = <T>() => createParser<T>({
  parse: (value) => {
    try {
      return JSON.parse(value);
    } catch {
      return null;
    }
  },
  serialize: (obj) => JSON.stringify(obj),
});

// Usage
function FilteredPage() {
  const [date, setDate] = useQueryState('date', parseAsDate);
  const [tags, setTags] = useQueryState('tags', parseAsCommaArray.withDefault([]));

  return (
    <div>
      <input
        type="date"
        value={date?.toISOString().split('T')[0] ?? ''}
        onChange={(e) => setDate(e.target.value ? new Date(e.target.value) : null)}
      />

      {tags.map(tag => (
        <span key={tag}>
          {tag}
          <button onClick={() => setTags(tags.filter(t => t !== tag))}>×</button>
        </span>
      ))}
    </div>
  );
}

Fix 5: History Mode and Shallow Routing

'use client';

import { useQueryState, parseAsInteger } from 'nuqs';

function HistoryOptions() {
  // Default: replace history entry (back button skips intermediate states)
  const [tab, setTab] = useQueryState('tab', {
    history: 'replace',  // Default — doesn't add history entries
    defaultValue: 'overview',
  });

  // Push: each change adds a history entry (back button goes to previous state)
  const [page, setPage] = useQueryState('page', {
    ...parseAsInteger.withDefault(1),
    history: 'push',  // Back button goes to previous page
  });

  // Shallow: don't trigger Next.js server-side data fetching
  const [view, setView] = useQueryState('view', {
    defaultValue: 'grid',
    shallow: true,  // Client-only state, no server refetch
  });

  // Non-shallow: trigger server-side refetch (default in App Router)
  const [query, setQuery] = useQueryState('q', {
    defaultValue: '',
    shallow: false,  // Server component re-renders with new searchParams
  });

  // Throttle URL updates for rapid changes (e.g., range sliders)
  const [price, setPrice] = useQueryState('price', {
    ...parseAsInteger.withDefault(0),
    throttleMs: 300,  // Update URL at most every 300ms
  });

  return (
    <div>
      <input
        type="range"
        value={price}
        min={0}
        max={1000}
        onChange={(e) => setPrice(Number(e.target.value))}
      />
      <span>${price}</span>
    </div>
  );
}

Fix 6: Common Patterns

// Debounced search with nuqs
'use client';

import { useQueryState } from 'nuqs';
import { useState, useEffect } from 'react';

function DebouncedSearch() {
  const [query, setQuery] = useQueryState('q', {
    defaultValue: '',
    shallow: false,
    throttleMs: 500,  // Built-in throttle
  });

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value || null)}
      placeholder="Search..."
    />
  );
}

// Shareable filter URLs
function ShareFilters() {
  const [filters] = useQueryStates({
    q: { defaultValue: '' },
    category: { defaultValue: '' },
    sort: parseAsStringEnum(['price', 'rating']).withDefault('rating'),
  });

  function copyShareLink() {
    // URL is already updated — just copy it
    navigator.clipboard.writeText(window.location.href);
    toast.success('Link copied!');
  }

  return <button onClick={copyShareLink}>Share Filters</button>;
}

Still Not Working?

State updates but URL doesn’t changeNuqsAdapter is missing. Wrap your app in <NuqsAdapter> in the root layout. Without it, nuqs can’t interact with the router. For Next.js App Router, use nuqs/adapters/next/app. For Pages Router, use nuqs/adapters/next/pages.

URL changes but component doesn’t re-render — check the shallow option. With shallow: true (or default in some cases), Next.js doesn’t re-run the page’s server component. Set shallow: false to trigger server-side data refetching. For client-only state, ensure the component reads from useQueryState, not from searchParams.

Value is null instead of a default — chain .withDefault() on the parser: parseAsInteger.withDefault(1). Without it, absent URL params resolve to null. Setting to null removes the parameter from the URL.

Type mismatch — expected number, got string — use the correct parser. useQueryState('page') returns string | null. For numbers, use parseAsInteger or parseAsFloat. For booleans, use parseAsBoolean. Parsers handle serialization/deserialization between the URL string and the typed value.

Flash of default state on first paint — the server rendered with the parsed searchParams, but the client hydrated with the default value and then re-rendered when nuqs read the URL. Wire your Server Component to parse the same params with createSearchParamsCache and pass them down as initial props. Or render the filter UI only after useHydrated() returns true, accepting a small skeleton.

Back button traps the user inside their own typing — every keystroke pushed a history entry. Set history: 'replace' on free-text inputs and use throttleMs (300–500ms) so the URL only updates after the user pauses. Reserve history: 'push' for explicit navigation, like changing pages or switching tabs.

Shared URL shows different results than the live page — the user copied the URL while a client-only filter (shallow: true) was active, then opened it elsewhere. The cold load runs Server Components with the URL params and fetches the matching data; the live page never refetched. Either set shallow: false so the live page matches the cold load, or document explicitly that some filters are session-local.

URL parameter names collide with framework reserved keys — Next.js reserves query, slug, params, and a few others in its dynamic-route ecosystem. If you call useQueryState('slug', ...) on a route that already has a dynamic [slug] segment, the parsed value is ambiguous. Pick distinct names: searchSlug, tagFilter, q. Audit your route tree before launching a new filter to make sure no parameter shadows an existing dynamic segment.

Long arrays explode the URL beyond browser limits — comma-separated array parsers (?tags=react,typescript,...) are convenient until the URL exceeds the browser’s ~2000-character limit. Past that, Chrome silently truncates, Safari throws, and CDNs return 414. For long lists, use a JSON-encoded parser with a localStorage-backed alias: store the full filter in local state, write a short token to the URL, and resolve the token on load. Shareable links stay short.

For related state management and routing issues, see Fix: Zustand Not Working, Fix: Jotai Not Working, Fix: TanStack Router Not Working, and Fix: Next.js Params Should Be Awaited.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles