Skip to main content

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

·PkgPulse Team

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

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.