← Back to writing

Writing / Essay

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

Practical URL state in React: parsing, defaults, runtime validation with Zod, and the trade-offs of keeping state in the URL.

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:

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:

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.

The Problem with Query Params

Most codebases start with something like this:

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.

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

Updating state automatically updates the URL:

setPage(2);
?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:

"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.

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.

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 return null for invalid input. Zod’s safeParse 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:

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:

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.

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

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