# Type-Safe URL State in React with nuqs (and Zod)

Description: Practical URL state in React: parsing, defaults, runtime validation with Zod, and the trade-offs of keeping state in the URL.
Author: Richard Biroš
Slug: type-safe-url-state-react-nuqs-zod
Canonical: https://www.biros.dev/blog/type-safe-url-state-react-nuqs-zod

Managing URL query parameters in modern React applications is surprisingly tricky.

Pagination, filters, sorting, tabs, search. All of these often live in the URL. That's great for shareability and persistence, but the developer experience quickly becomes painful.

- Everything is a string
- Parsing logic is duplicated
- Defaults are inconsistent
- Invalid values cause bugs
- Synchronizing state with the router is tedious

After repeatedly re-implementing the same logic across projects, I discovered **nuqs**, a small library that treats the URL as a first-class state container.

This article explains what nuqs is, why it's useful, and how to integrate it with **Zod** for runtime validation and type safety.

## Versions and Setup

The examples below target **nuqs 2.10.2**, **Zod 4.6.7**, and **React 19.2.3**. These are the versions checked for this migration, not a claim about the versions used in the original projects. This Astro portfolio renders the examples as static code; it does not install or run React, nuqs, or Zod.

In a React application, install the example dependencies:

```sh
npm install nuqs@2.10.2 zod@4.6.7
```

nuqs needs a framework adapter. For a client-rendered React application, wrap the components that use its hooks:

```tsx
import type { ReactNode } from "react";
import { NuqsAdapter } from "nuqs/adapters/react";

export function Providers({ children }: { children: ReactNode }) {
  return <NuqsAdapter>{children}</NuqsAdapter>;
}
```

For Next.js App Router, use `NuqsAdapter` from `nuqs/adapters/next/app` in the root layout instead. Components using `useQueryState` need the `"use client"` directive. Other routers have their own [adapter setup](https://nuqs.dev/docs/adapters).

## The Problem with Query Params

Most codebases start with something like this:

```tsx
const page = Number(searchParams.get("page") ?? 1);
const sort = searchParams.get("sort") ?? "asc";
const showArchived = searchParams.get("archived") === "true";
```

It works, but it comes with hidden issues:

- `Number()` can produce `NaN`
- Invalid values are silently accepted
- Defaults are repeated everywhere
- String types do not express valid sort values or page ranges
- Hard to keep consistent across pages

Over time, this becomes technical debt.

What we really want is something closer to React state.

## Enter nuqs

**nuqs** is a small library for managing URL query state in React and Next.js applications.

Instead of manually reading and writing query parameters, you use hooks that feel similar to `useState`.

```tsx
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
```

Updating state automatically updates the URL:

```tsx
setPage(2);
```

```text
?page=2
```

The key idea is simple but powerful: treat the URL as state.

## Why This Matters

Using the URL as state provides several benefits:

- Shareable application state
- Persistence across reloads
- Browser navigation support
- Single source of truth
- Predictable behavior

Without tooling, the developer experience is poor. nuqs solves that gap.

## Basic Example

Here's a simple pagination example:

```tsx
"use client";

import { useQueryState, parseAsInteger } from "nuqs";

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

  return (
    <button onClick={() => setPage((current) => current + 1)}>
      Next page ({page})
    </button>
  );
}
```

`page` is inferred as a number. Missing or unparseable values fall back to `1`; that default does not automatically rewrite the URL. Setting the value to `null` removes the parameter and restores the default. See [default behavior](https://nuqs.dev/docs/basic-usage).

`parseAsInteger` is a parser, not a positive-page validator: it accepts negative integers and uses integer parsing that can accept a numeric prefix. If `?page=2abc` or `?page=-1` should be rejected, define stricter validation rather than assuming that parsing enforces your domain rules.

## Validation with Zod

One important insight: query parameters are user input. Users can edit URLs manually, so validation matters.

If your project already uses **Zod**, integrating it with nuqs is straightforward.

```tsx
import { z } from "zod";
import { createParser } from "nuqs";

const sortSchema = z.enum(["asc", "desc"]);

const sortParser = createParser({
  parse: (value) => {
    const result = sortSchema.safeParse(value);
    return result.success ? result.data : null;
  },
  serialize: (value) => value,
});
```

[Custom parsers](https://nuqs.dev/docs/parsers/making-your-own) return `null` for invalid input. [Zod's `safeParse`](https://zod.dev/basics) lets this parser do so without throwing. `?sort=desc` becomes `"desc"`, while an unsupported value such as `?sort=newest` falls back to the configured default.

Then use it like any other parser:

```tsx
const [sort, setSort] = useQueryState("sort", sortParser.withDefault("asc"));
```

Benefits:

- Runtime validation
- Type inference
- Shared schemas across the app
- Safer defaults

The inferred state type is `"asc" | "desc"`. This validates incoming URL text; it does not make the URL a security boundary or replace server-side validation. For a simple literal union, nuqs also offers `parseAsStringLiteral(["asc", "desc"])`, so adding Zod solely for this enum may be unnecessary. A shared schema becomes more useful when validation grows beyond a simple choice.

## History and Server Updates

nuqs defaults to replacing the current history entry and updating the URL without a server round-trip. Use `history: "push"` when each committed change should be a destination for the browser Back button:

```tsx
const [page, setPage] = useQueryState(
  "page",
  parseAsInteger.withDefault(1).withOptions({ history: "push" }),
);
```

Pushing every keystroke can clutter browser history. Replacing entries is often more appropriate for live search. In a supported framework such as Next.js, `shallow: false` requests a server update when server-rendered data depends on the new query. Choose this deliberately: additional server work changes the cost of frequent updates. See the [history and shallow options](https://nuqs.dev/docs/options).

Keep related parameters consistent. For example, changing a filter may also require resetting pagination; `useQueryStates` can update several keys together. A type-correct URL can still describe an inconsistent application state.

## Real-World Use Cases

nuqs works particularly well for:

- Pagination
- Table filters
- Search queries
- Sorting
- Tabs
- Non-sensitive presentation flags
- Dashboard state

## nuqs vs query-string / qs

Libraries like **query-string** or **qs** solve a different problem. They convert objects to and from query strings. nuqs focuses on synchronizing app state with the URL.

| Feature                | query-string / qs | nuqs             |
| ---------------------- | ----------------- | ---------------- |
| Parse & stringify      | Yes               | Basic            |
| React integration      | No                | Yes              |
| State synchronization  | No                | Yes              |
| Defaults               | No                | Yes              |
| Typed domain values    | Manual validation | Parser inference |
| Validation integration | Manual            | Natural          |

## When NOT to Use nuqs

Like any tool, nuqs can be overused. Avoid it when:

- State is purely local UI
- Data is large or complex
- State contains secrets or personal data: URLs can appear in history, logs, and shared links
- URL readability matters more than convenience
- Performance constraints are extreme

## Lessons Learned

After using nuqs in production, the biggest improvements were:

- Less boilerplate
- Fewer bugs from invalid params
- More predictable behavior
- Better developer experience
- Consistent patterns across the codebase

## Final Thoughts

Managing query parameters manually is easy at first until it isn't. nuqs provides a pragmatic abstraction that makes URL state predictable, type-safe, and ergonomic in React applications.

It's not the only solution, but if your app relies heavily on URL-driven state, it's worth exploring.

## Resources

- nuqs GitHub: [https://github.com/47ng/nuqs](https://github.com/47ng/nuqs)
- Zod: [https://github.com/colinhacks/zod](https://github.com/colinhacks/zod)

If you deal with query parameters often, nuqs might save you a surprising amount of time and bugs.
