Skip to main content

Guide

nuqs vs use-query-params vs next/navigation 2026

nuqs vs use-query-params vs next/navigation useSearchParams compared for URL state management in Next.js. Type safety, server components, and SSR in 2026.

·PkgPulse Team·
0

nuqs vs use-query-params vs next/navigation: URL State 2026

TL;DR

Storing state in the URL — filters, pagination, search terms, tabs — is a solved problem in 2026 but the right tool depends heavily on your stack. nuqs is the purpose-built URL state library for React and Next.js — type-safe query params that work in App Router server components, support shallow routing, and have parsers for all primitive types and custom types; it's the clear choice for Next.js 14+ projects. use-query-params is the battle-tested React Router/Next.js library from Charge Labs — mature, widely used, and framework-agnostic, but requires more setup in App Router and doesn't have first-class server component support. next/navigation's useSearchParams is the built-in primitive — no extra dependency, always compatible, but requires manual string parsing and lacks the ergonomics of dedicated libraries. For Next.js App Router: nuqs. For React Router or framework-agnostic projects: use-query-params. For simple single-param cases where you want zero dependencies: useSearchParams directly.

Key Takeaways

  • nuqs is App Router native — works in Server Components, Client Components, and Server Actions
  • nuqs has typed parsersparseAsInteger, parseAsFloat, parseAsBoolean, parseAsArrayOf
  • use-query-params is framework-agnostic — works with React Router, Next.js Pages Router
  • useSearchParams requires Suspense — App Router requires wrapping for SSR safety
  • nuqs supports shallow routing{ shallow: true } updates URL without navigation event
  • nuqs has parseAsJson — arbitrary JSON objects in URL params with schema validation
  • All three read from window.location.search — same underlying URL mechanism

URL State Patterns

Single search/filter param    → useSearchParams (simple enough)
Multiple typed params         → nuqs (type safety + DX)
Complex object in URL         → nuqs parseAsJson
React Router project          → use-query-params
Next.js App Router filters    → nuqs (best SSR support)
Server Component reads params → nuqs useQueryState / searchParams prop
Pagination (page, perPage)    → nuqs with parseAsInteger

nuqs: Type-Safe URL State for Next.js

nuqs provides typed, parsed, and serialized URL query params — think useState but synced to the URL and compatible with Next.js App Router server components.

Installation

npm install nuqs

App Router Setup

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

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

Basic Usage

// components/SearchBar.tsx
"use client";
import { useQueryState, parseAsString } from "nuqs";

export function SearchBar() {
  // Synced to ?q= URL param
  const [query, setQuery] = useQueryState("q", parseAsString.withDefault(""));

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

Typed Parsers

"use client";
import {
  useQueryState,
  parseAsInteger,
  parseAsFloat,
  parseAsBoolean,
  parseAsStringLiteral,
  parseAsArrayOf,
  parseAsString,
} from "nuqs";

export function ProductFilters() {
  // ?page=1 (integer, default 1)
  const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));

  // ?minPrice=10.5 (float)
  const [minPrice, setMinPrice] = useQueryState("minPrice", parseAsFloat.withDefault(0));

  // ?inStock=true (boolean)
  const [inStock, setInStock] = useQueryState("inStock", parseAsBoolean.withDefault(false));

  // ?sort=price-asc (string literal union)
  const [sort, setSort] = useQueryState(
    "sort",
    parseAsStringLiteral(["price-asc", "price-desc", "newest", "rating"] as const)
      .withDefault("newest")
  );

  // ?categories=shoes,boots,sandals (array)
  const [categories, setCategories] = useQueryState(
    "categories",
    parseAsArrayOf(parseAsString).withDefault([])
  );

  return (
    <div>
      <input
        type="number"
        value={page}
        onChange={(e) => setPage(parseInt(e.target.value))}
      />
      <label>
        <input
          type="checkbox"
          checked={inStock}
          onChange={(e) => setInStock(e.target.checked)}
        />
        In Stock Only
      </label>
      <select value={sort} onChange={(e) => setSort(e.target.value as any)}>
        <option value="newest">Newest</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
      </select>
    </div>
  );
}

useQueryStates (Multiple Params)

"use client";
import { useQueryStates, parseAsInteger, parseAsString, parseAsArrayOf } from "nuqs";

// Define param shape once
const filterParsers = {
  page: parseAsInteger.withDefault(1),
  q: parseAsString.withDefault(""),
  sort: parseAsString.withDefault("newest"),
  categories: parseAsArrayOf(parseAsString).withDefault([]),
  minPrice: parseAsInteger.withDefault(0),
  maxPrice: parseAsInteger.withDefault(10000),
};

export function ProductSearch() {
  const [filters, setFilters] = useQueryStates(filterParsers);

  // filters is fully typed: { page: number, q: string, categories: string[], ... }

  function resetFilters() {
    // Clear all params to defaults
    setFilters({
      page: 1,
      q: "",
      categories: [],
      minPrice: 0,
      maxPrice: 10000,
    });
  }

  return (
    <div>
      <input
        value={filters.q}
        onChange={(e) => setFilters({ q: e.target.value, page: 1 })}
      />
      <span>Page {filters.page} of results for "{filters.q}"</span>
      <button onClick={resetFilters}>Clear Filters</button>
    </div>
  );
}

Server Component Integration

// app/products/page.tsx — Server Component reads params directly
import { createSearchParamsCache, parseAsInteger, parseAsString } from "nuqs/server";

// Define parsers for server-side use
const searchParamsCache = createSearchParamsCache({
  page: parseAsInteger.withDefault(1),
  q: parseAsString.withDefault(""),
  sort: parseAsString.withDefault("newest"),
});

interface Props {
  searchParams: Record<string, string | string[] | undefined>;
}

export default async function ProductsPage({ searchParams }: Props) {
  // Typed parsing — same parsers as client components
  const { page, q, sort } = searchParamsCache.parse(searchParams);

  // Use params in server-side data fetch
  const products = await fetchProducts({ page, q, sort });

  return (
    <div>
      <ProductFilters />  {/* Client component with useQueryStates */}
      <ProductGrid products={products} />
    </div>
  );
}

Shallow Routing and Options

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

export function Pagination({ totalPages }: { totalPages: number }) {
  const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));

  return (
    <div>
      <button
        onClick={() => setPage(page - 1, { shallow: true })}  // No server re-render
        disabled={page <= 1}
      >
        Previous
      </button>
      <span>{page} / {totalPages}</span>
      <button
        onClick={() => setPage(page + 1, { shallow: false })} // Full navigation
        disabled={page >= totalPages}
      >
        Next
      </button>
    </div>
  );
}

Custom Parser (JSON Objects)

import { parseAsJson, useQueryState } from "nuqs";
import { z } from "zod";

const ViewSchema = z.object({
  zoom: z.number(),
  center: z.object({ lat: z.number(), lng: z.number() }),
  layers: z.array(z.string()),
});

type View = z.infer<typeof ViewSchema>;

const defaultView: View = {
  zoom: 10,
  center: { lat: 37.7749, lng: -122.4194 },
  layers: ["roads", "labels"],
};

export function MapControls() {
  const [view, setView] = useQueryState(
    "view",
    parseAsJson(ViewSchema.parse).withDefault(defaultView)
  );

  return (
    <div>
      <span>Zoom: {view.zoom}</span>
      <button onClick={() => setView({ ...view, zoom: view.zoom + 1 })}>+</button>
      <button onClick={() => setView({ ...view, zoom: view.zoom - 1 })}>-</button>
    </div>
  );
}

use-query-params: Framework-Agnostic URL State

use-query-params is the mature solution for React Router and Pages Router apps — battle-tested since 2019 with support for complex param types.

Installation

npm install use-query-params
# For Next.js Pages Router:
npm install use-query-params next-query-params

Setup

// pages/_app.tsx — Next.js Pages Router
import type { AppProps } from "next/app";
import { QueryParamProvider } from "use-query-params";
import { NextAdapter } from "next-query-params";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryParamProvider adapter={NextAdapter}>
      <Component {...pageProps} />
    </QueryParamProvider>
  );
}
// For React Router v6
import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
import { BrowserRouter } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <QueryParamProvider adapter={ReactRouter6Adapter}>
        <Routes />
      </QueryParamProvider>
    </BrowserRouter>
  );
}

Basic Usage

import { useQueryParam, StringParam, NumberParam, BooleanParam } from "use-query-params";

export function SearchAndFilter() {
  const [query, setQuery] = useQueryParam("q", StringParam);
  const [page, setPage] = useQueryParam("page", NumberParam);
  const [inStock, setInStock] = useQueryParam("inStock", BooleanParam);

  return (
    <div>
      <input
        value={query ?? ""}
        onChange={(e) => setQuery(e.target.value || undefined)}
        placeholder="Search..."
      />
      <label>
        <input
          type="checkbox"
          checked={inStock ?? false}
          onChange={(e) => setInStock(e.target.checked ? true : undefined)}
        />
        In Stock
      </label>
      <span>Page {page ?? 1}</span>
    </div>
  );
}

Multiple Params (useQueryParams)

import { useQueryParams, StringParam, NumberParam, ArrayParam, withDefault } from "use-query-params";

export function ProductFilters() {
  const [query, setQuery] = useQueryParams({
    q: withDefault(StringParam, ""),
    page: withDefault(NumberParam, 1),
    categories: withDefault(ArrayParam, []),
    sort: withDefault(StringParam, "newest"),
  });

  function handleSearch(value: string) {
    setQuery({ q: value, page: 1 }, "pushIn");  // "pushIn" merges with existing params
  }

  return (
    <div>
      <input value={query.q} onChange={(e) => handleSearch(e.target.value)} />
      <span>Page {query.page}</span>
    </div>
  );
}

next/navigation useSearchParams: Built-in Primitive

The built-in approach — zero dependencies, always compatible, requires manual parsing.

Basic Usage

// app/products/page.tsx — requires Suspense wrapper
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { Suspense } from "react";

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

  const query = searchParams.get("q") ?? "";
  const page = parseInt(searchParams.get("page") ?? "1");
  const sort = searchParams.get("sort") ?? "newest";

  function updateParam(key: string, value: string | null) {
    const params = new URLSearchParams(searchParams.toString());
    if (value === null || value === "") {
      params.delete(key);
    } else {
      params.set(key, value);
    }
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <div>
      <input
        value={query}
        onChange={(e) => updateParam("q", e.target.value)}
        placeholder="Search..."
      />
      <select value={sort} onChange={(e) => updateParam("sort", e.target.value)}>
        <option value="newest">Newest</option>
        <option value="price-asc">Price Low</option>
        <option value="price-desc">Price High</option>
      </select>
    </div>
  );
}

// Must wrap with Suspense for SSR
export function ProductFilters() {
  return (
    <Suspense fallback={<div>Loading filters...</div>}>
      <ProductFiltersInner />
    </Suspense>
  );
}

Server Component Pattern

// app/products/page.tsx — Server Component (no hooks needed)
interface Props {
  searchParams: { [key: string]: string | string[] | undefined };
}

export default async function ProductsPage({ searchParams }: Props) {
  // Direct access — no hooks, no Suspense
  const query = typeof searchParams.q === "string" ? searchParams.q : "";
  const page = parseInt(typeof searchParams.page === "string" ? searchParams.page : "1") || 1;

  const products = await fetchProducts({ query, page });

  return (
    <div>
      <ProductFilters />
      <ProductGrid products={products} />
    </div>
  );
}

Feature Comparison

Featurenuqsuse-query-paramsuseSearchParams
Type safety✅ Parsers + TS✅ Typed params❌ Manual parsing
App Router support✅ First-class⚠️ Workarounds needed✅ Built-in
Server ComponentscreateSearchParamsCachesearchParams prop
Server Actions
React Router⚠️ Via adapter❌ (Next.js only)
Shallow routingpushIn✅ Manual
Array paramsparseAsArrayOfArrayParam❌ Manual
JSON paramsparseAsJsonJsonParam❌ Manual
Default values.withDefault()withDefault()❌ Manual
Bundle size~4kB~8kB0 (built-in)
npm weekly1.5M1MN/A (Next.js)
GitHub stars4.5k3.5kN/A

URL State and SEO Implications

URL state management has direct SEO impact that's easy to overlook during development. When filters and search terms live in the URL, users can bookmark specific filter combinations and share links to pre-filtered views — this is both a usability win and an SEO concern. Search engines index URL variations with query parameters, and if your filter combinations produce thousands of URL permutations (page=1..1000 × sort=4 options × category=20 options), you risk index bloat. The standard mitigation is using canonical URLs or noindex on paginated and filtered pages, which is independent of which URL state library you use. nuqs's shallow: true option is important here: it updates the URL without triggering a server re-render, keeping filter interactions fast while still maintaining bookmarkable state. For server-side rendered pages where the initial page load must reflect the URL state, nuqs's createSearchParamsCache is essential — it ensures the server renders the correct filtered content without a client-side hydration mismatch.

Security Considerations for URL-Derived State

URL parameters are user-controllable input and must be treated with the same validation rigor as any external input. nuqs's typed parsers provide a significant security benefit: parseAsInteger rejects non-numeric values and returns the default instead of passing NaN to your data fetching logic. Without a validation layer, raw useSearchParams().get('page') can return arbitrary strings that downstream code might handle unsafely. use-query-params's typed parameter classes (NumberParam, BooleanParam) provide similar protection. When using parseAsJson or parseAsJsonEnum in nuqs, always pair it with a Zod schema to validate the structure — a user can manually craft any JSON in the URL, and trusting it without validation can cause runtime errors or unexpected behavior in complex filter objects. The maxLength or validation constraints on string parameters are also worth considering for search inputs that get passed to database queries.

Shallow Routing and Performance Nuances

The performance implications of URL updates depend heavily on whether they trigger server re-renders. In Next.js App Router, any URL change that isn't marked as shallow causes the Server Component tree to re-render on the server, which is expensive for filter-heavy pages with database queries. nuqs's { shallow: true } option is therefore critical for rapid interactions like typing in a search box or adjusting a price range slider — you update the URL after debouncing or on blur, not on every keystroke. use-query-params's pushIn mode and Next.js's router.replace both update the URL without triggering full page navigation, but the interaction with App Router's server rendering is more manual than nuqs's first-class shallow support. For applications where URL state drives expensive server-side queries, batching multiple parameter updates into a single URL change (nuqs's useQueryStates handles this) prevents multiple re-renders when the user changes several filters.

TypeScript and Zod Integration Patterns

nuqs's parser API integrates cleanly with Zod for custom enum types and complex validated structures. The parseAsStringLiteral parser accepts a const array of strings, which TypeScript narrows to a union type — sort becomes 'newest' | 'price-asc' | 'price-desc' rather than string. For complex filter objects, parseAsJson(MyZodSchema.parse) combines URL serialization with runtime validation in one declaration, and the resulting type is exactly z.infer<typeof MyZodSchema>. This pattern is more ergonomic than use-query-params's JsonParam which returns unknown and requires manual narrowing. For teams standardizing on Zod throughout the stack (form validation, API validation, URL validation), nuqs's Zod integration makes URL state consistent with the broader validation pattern. The Zod schema for URL params can often be reused for the corresponding server action input schema, ensuring client-side URL state and server-side query parameters use identical validation logic.

Migration from Pages Router to App Router

One of the most practically valuable aspects of nuqs is its handling of the Next.js Pages Router to App Router migration path. Teams migrating incrementally can run both routers in the same codebase, and nuqs provides adapters for both (nuqs/adapters/next/app and nuqs/adapters/next/pages). use-query-params has more friction during App Router adoption because its design predates Server Components and requires workarounds to integrate with the new data fetching model. The raw useSearchParams hook is available in both routers but requires the Suspense wrapper in App Router for SSR safety — a common source of hydration errors during migration. For teams in the middle of a Pages Router to App Router migration, nuqs's explicit adapter model makes the transition cleaner than trying to make use-query-params work across both routing systems simultaneously.

When to Use Each

Choose nuqs if:

  • Using Next.js App Router (Pages Router works too via adapter)
  • Server Components need to read the same typed params as client components
  • Multiple URL params with different types (integer, float, boolean, array, JSON)
  • Modern TypeScript-first DX with .withDefault() and typed parsers

Choose use-query-params if:

  • React Router v6 project (best adapter support)
  • Next.js Pages Router with established use-query-params patterns
  • Need ArrayParam, JsonParam, DelimitedArrayParam with custom delimiters
  • Framework-agnostic codebase shared across Next.js and React Router

Choose useSearchParams directly if:

  • 1-2 simple string params that don't need type parsing
  • Avoiding extra dependencies in a lean project
  • Server Components reading searchParams prop is sufficient (zero client-side code needed)
  • Prototype or small feature where full library is overkill

Methodology

Data sourced from nuqs official documentation (nuqs.47ng.com), use-query-params documentation (github.com/pbeshai/use-query-params), Next.js App Router documentation (nextjs.org/docs), npm weekly download statistics as of February 2026, GitHub star counts as of February 2026, and community discussions from the Next.js Discord and r/nextjs.


Related: Zustand vs Jotai vs Valtio for client-side state that complements URL state, or TanStack Query vs SWR vs React Query for server state that often drives the same filter/pagination UI.

See also: React vs Vue and React vs Svelte

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.