Skip to content

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

FixDevs ·

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

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.

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.

For related state management issues, see Fix: Zustand Not Working and Fix: Next.js App Router Not Working.

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