ProgrUmar Logo
Module 8: State Management & Client Patterns

URL State with nuqs

Duration: 13 mins

URL as State

nuqs gives you a useState-like API that serialises state into query params. The URL becomes the single source of truth — share a filtered table or paginated list just by copying the URL.

URL State with nuqs

When building search filters, table pagination, sort toggles, or drawer interfaces, storing state in React's useState or client memory stores like Zustand is an anti-pattern. If a user finds a specific filter view they want to share with a teammate, copying the browser URL will result in the teammate landing on the default, unfiltered page. Storing state directly in the URL query string solves this, making every view bookmarkable and shareable. nuqs (formerly next-usequerystate) is a lightweight library that provides a type-safe, hook-based API to sync React state with browser URL parameters. In this lesson, we will cover how to configure typed queries, write search filters, and handle server-side pre-rendering integration.


1. Installing nuqs in Next.js 15

To integrate type-safe query parameters, install the package in your project:

npm install nuqs

In Next.js 15, nuqs requires a Root Provider wrapper inside your layout to synchronize URL transitions correctly with the App Router's navigation router:

// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app';

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

2. Basic String State Syncing with useQueryState

The useQueryState hook behaves exactly like React's standard useState hook, but serializes values directly into the browser URL:

// components/SearchFilter.tsx
'use client';

import { useQueryState } from 'nuqs';

export function SearchFilter() {
  // Syncs '?search=...' query parameter in the browser address bar
  const [search, setSearch] = useQueryState('search', { defaultValue: '' });

  return (
    <div className="p-4">
      <input
        type="text"
        value={search}
        onChange={(e) => setSearch(e.target.value || null)} // Passing null deletes parameter from URL
        placeholder="Filter courses..."
        className="border p-2 rounded"
      />
      <p>Active query: {search}</p>
    </div>
  );
}

3. Parsing Typed Parameters (Numbers, Booleans, and JSON)

URL query strings are natively always string types. If you need to manage integers (like page offsets), booleans (like checkbox filters), or arrays, nuqs provides pre-built serializers to cast values safely:

// components/Pagination.tsx
'use client';

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

export function Pagination() {
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
  const [showArchived, setShowArchived] = useQueryState('archived', parseAsBoolean.withDefault(false));

  return (
    <div className="flex gap-4 p-4">
      <button onClick={() => setPage((p) => Math.max(1, p - 1))}>Prev</button>
      <span>Page {page}</span>
      <button onClick={() => setPage((p) => p + 1)}>Next</button>
      
      <label>
        <input
          type="checkbox"
          checked={showArchived}
          onChange={(e) => setShowArchived(e.target.checked)}
        />
        Show Archived
      </label>
    </div>
  );
}

4. Shallow Routing vs. Server-Side Data Refetches

By default, nuqs updates query parameters using shallow routing (shallow: true). This means when you call setQuery, the URL updates in the browser address bar, but Next.js does not trigger server-side re-renders or fetch calls.

When to use shallow: false: If your Server Component retrieves data using search parameter values directly, you must disable shallow routing. This forces Next.js to request the server layout again and update page layouts with the new query parameters:

// components/CourseCatalogFilter.tsx
'use client';

import { useQueryState } from 'nuqs';

export function CourseCatalogFilter() {
  const [sort, setSort] = useQueryState('sort', {
    defaultValue: 'popular',
    shallow: false, // Forces Server Components to refetch data on change
  });

  return (
    <select value={sort} onChange={(e) => setSort(e.target.value)}>
      <option value="popular">Most Popular</option>
      <option value="newest">Newest</option>
    </select>
  );
}

5. Grouping Multiple States with useQueryStates

When managing multiple filters (e.g. search term, sort order, and page number), calling multiple setters consecutively causes redundant URL update cycles. Drizzle allows you to group states using the useQueryStates hook, applying batches in a single transition:

// components/AdvancedFilter.tsx
'use client';

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

const filterParser = {
  query: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1),
  sort: parseAsString.withDefault('popular'),
};

export function AdvancedFilter() {
  const [filters, setFilters] = useQueryStates(filterParser, { shallow: false });

  const handleClearAll = () => {
    // Reset all parameters to defaults in a single URL transition
    setFilters({ query: null, page: null, sort: null });
  };

  return (
    <div className="space-y-4">
      <input
        value={filters.query}
        onChange={(e) => setFilters({ query: e.target.value || null, page: 1 })}
      />
      <button onClick={handleClearAll}>Reset Filters</button>
    </div>
  );
}

6. Server-Side Parsing and Pre-fetching

To avoid layout shift jumps when pre-rendering pages containing query parameters on the server, parse the incoming search parameters inside your async Server Component page wrapper and pass them to the adapters:

// app/catalog/page.tsx (Server Component)
import { CourseCatalogFilter } from '@/components/CourseCatalogFilter';

interface PageProps {
  searchParams: Promise<{ sort?: string }>;
}

export default async function CatalogPage({ searchParams }: PageProps) {
  const resolvedParams = await searchParams;
  const sort = resolvedParams.sort || 'popular';

  // Fetch data on the server based on the active URL parameters
  const courses = await fetch('https://api.progrumar.com/courses?sort=' + sort).then(r => r.json());

  return (
    <main className="p-8">
      <h1>Course Directory</h1>
      <CourseCatalogFilter />
      {/* Render course cards... */}
    </main>
  );
}

7. Debouncing Heavy Search Input Updates

Triggering database queries on every character keystroke inside a search input (e.g. typing "nextjs") triggers five consecutive server requests, degrading server response latency.

To optimize performance, debounce your state setters, updating the URL query string only after the user stops typing:

// components/DebouncedSearch.tsx
'use client';

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

export function DebouncedSearch() {
  const [query, setQuery] = useQueryState('query', { defaultValue: '', shallow: false });
  const [localInput, setLocalInput] = useState(query);

  useEffect(() => {
    const handler = setTimeout(() => {
      setQuery(localInput || null);
    }, 400); // Wait 400ms after last keystroke before modifying URL

    return () => clearTimeout(handler);
  }, [localInput, setQuery]);

  return (
    <input
      value={localInput}
      onChange={(e) => setLocalInput(e.target.value)}
      placeholder="Type search terms..."
    />
  );
}

8. Gotchas of Hydration Warnings inside Suspense

Because URL parameters only resolve inside the client browser at runtime, calling useQueryState in deep layout trees forces Next.js to de-optimize static rendering.

Fix: Always wrap Client Components that consume nuqs query hooks inside a React Suspense boundary wrapper, allowing the page outline to render statically while parameters hydrate:

// Wrapper inside parent page template
import { Suspense } from 'react';
import { SearchFilter } from '@/components/SearchFilter';

export default function SearchLayout() {
  return (
    <Suspense fallback={<div>Loading search tools...</div>}>
      <SearchFilter />
    </Suspense>
  );
}

9. Common Gotchas

  • Forgetting the NuqsAdapter Wrapper: If you import and call useQueryState without placing <NuqsAdapter> in your root layout configuration, your application will crash immediately on load.
  • Infinite Fetch Cycles with shallow: false: If a Server Component triggers a state write back to the URL, and that URL rewrite triggers another query fetch inside the Server Component, you can initiate infinite loop request cycles. Ensure layout updates only execute on user action.

Key Takeaways

  • Use URL query parameters for filter, sorting, and pagination state to ensure links are bookmarkable.
  • Install and wrap your root layout configuration inside NuqsAdapter.
  • Apply useQueryState to sync variables to single URL query parameters type-safely.
  • Use shallow: false to force Next.js to execute server-side data fetches again when the URL updates.
  • Group multiple URL parameter updates using useQueryStates to avoid redundant navigation transitions.

Mastering client state management and URL synchronization ensures your application interfaces are fast, intuitive, and shareable. But as your codebase scales, you must ensure code changes do not break existing data flows. In the next module, we transition to **Testing** with a lesson on **Unit Testing with Vitest**, configuring test runners to validate code modules with minimal overhead.

Chat with us