<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/nuqs-vs-use-query-params-vs-next-navigation-url-state-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/nuqs-vs-use-query-params-vs-next-navigation-url-state-2026/raw.md -->
<!-- Source path: content/guides/nuqs-vs-use-query-params-vs-next-navigation-url-state-2026.mdx -->

---
og_image: "/images/guides/nuqs-vs-use-query-params-vs-next-navigation-url-state-2026.webp"
title: "nuqs vs use-query-params vs next/navigation 2026"
description: "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."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["nextjs", "typescript", "react", "routing"]
---

# 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

```bash
npm install nuqs
```

### App Router Setup

```tsx
// 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

```tsx
// 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

```tsx
"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)

```tsx
"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

```tsx
// 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

```tsx
"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)

```tsx
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

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

### Setup

```tsx
// 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>
  );
}
```

```tsx
// 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

```tsx
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)

```tsx
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

```tsx
// 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

```tsx
// 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 |

---

## 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](/guides/zustand-vs-jotai-vs-valtio-react-state-management-2026) for client-side state that complements URL state, or [TanStack Query vs SWR vs React Query](/guides/tanstack-query-vs-swr-server-state-react-2026) for server state that often drives the same filter/pagination UI.*

*See also: [React vs Vue](/compare/react-vs-vue) and [React vs Svelte](/compare/react-vs-svelte)*
