nuqs vs use-query-params vs next/navigation: URL State 2026
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 parsers —
parseAsInteger,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
| Feature | nuqs | use-query-params | useSearchParams |
|---|---|---|---|
| Type safety | ✅ Parsers + TS | ✅ Typed params | ❌ Manual parsing |
| App Router support | ✅ First-class | ⚠️ Workarounds needed | ✅ Built-in |
| Server Components | ✅ createSearchParamsCache | ❌ | ✅ searchParams prop |
| Server Actions | ✅ | ❌ | ❌ |
| React Router | ⚠️ Via adapter | ✅ | ❌ (Next.js only) |
| Shallow routing | ✅ | ✅ pushIn | ✅ Manual |
| Array params | ✅ parseAsArrayOf | ✅ ArrayParam | ❌ Manual |
| JSON params | ✅ parseAsJson | ✅ JsonParam | ❌ Manual |
| Default values | ✅ .withDefault() | ✅ withDefault() | ❌ Manual |
| Bundle size | ~4kB | ~8kB | 0 (built-in) |
| npm weekly | 1.5M | 1M | N/A (Next.js) |
| GitHub stars | 4.5k | 3.5k | N/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
searchParamsprop 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.