npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

use-zod-params

v1.0.0

Published

Type-safe URL search parameter state management for Next.js App Router, powered by Zod schemas.

Readme

use-zod-params

Type-safe URL search-param state for the Next.js App Router, powered by Zod schemas.

Pagination, sorting, faceted filters, debounced search — the state your data grids and dashboards need lives in the URL, where it is shareable, bookmarkable, SSR-able, and survives refreshes. This library makes reading and writing it fully type-safe.

const { state, setState, isPending, reset } = useUrlState(productSearch);
//      ^ { page: number; q: string; status: ("active" | "pending")[]; ... }

setState({ page: 2 });                          // → /products?page=2
setState({ q: value }, { debounceMs: 300 });    // debounced search input
reset();                                        // → /products

Features

  • 🔒 Fully typed — state and setters inferred from your Zod schema (z.infer), Zod 3 (≥ 3.25) and Zod 4 both supported.
  • 🧭 Deep App Router integration — useSearchParams / useRouter / usePathname, push vs replace, scroll: false, transitions.
  • ⚡ Instant UI via useOptimistic — state updates immediately, isPending tracks the in-flight server round-trip (React 18: graceful degradation).
  • 🧹 Clean URLs — values equal to their schema default are stripped; an all-defaults state is just /products.
  • 🔁 SSR without hydration mismatches — the same parser runs on server and client; deep links render fully on the server.
  • ⏱️ Built-in debouncing & batching — no lodash; same-tick updates coalesce into one history entry.
  • 🛡️ Never crashes on bad input — malformed params fall back per key to schema defaults.
  • 🤝 Plays nice with strangers — params it doesn't manage (utm_*, other features) are preserved on every update.
  • 📦 ESM + CJS, full .d.ts, ~6 KB min+gz, zero runtime dependencies.

Installation

npm install use-zod-params zod
# peer deps: next >= 14.1, react >= 18.2, zod >= 3.25

Quick start

1. Define your URL state in a shared file — import from use-zod-params/server:

// app/products/search-params.ts
import { z } from 'zod';
import { createUrlState } from 'use-zod-params/server';

export const productSearch = createUrlState(
  z.object({
    page:    z.coerce.number().int().min(1).default(1),
    perPage: z.coerce.number().int().max(100).default(20),
    q:       z.string().default(''),
    status:  z.array(z.enum(['active', 'pending', 'archived'])).default([]),
    sort:    z.object({
      col: z.enum(['name', 'price', 'createdAt']),
      dir: z.enum(['asc', 'desc']),
    }).default({ col: 'createdAt', dir: 'desc' }),
    from:    z.coerce.date().optional(),
  }),
);

2. Parse on the server (SSR'd first paint, typed and validated):

// app/products/page.tsx  (Server Component)
import { productSearch } from './search-params';

export default async function Page({ searchParams }: PageProps) {
  const params = productSearch.parse(await searchParams); // fully typed
  const data = await fetchProducts(params);               // params are trusted
  return <ProductTable data={data} />;
}

3. Read & write in client components:

// app/products/product-table.tsx
'use client';
import { useUrlState } from 'use-zod-params';
import { productSearch } from './search-params';

export function ProductTable({ data }) {
  const { state, setState, isPending, reset, href } = useUrlState(productSearch);

  return (
    <div style={{ opacity: isPending ? 0.6 : 1 }}>
      <input
        defaultValue={state.q}
        onChange={(e) => setState({ q: e.target.value, page: 1 }, { debounceMs: 300 })}
      />
      <button onClick={() => setState((s) => ({ page: s.page + 1 }), { history: 'push' })}>
        Next page
      </button>
      <button onClick={() => reset()}>Clear filters</button>
    </div>
  );
}

The RSC + Client shared pattern (read this)

The single most important rule in this library:

Shared definition files import from use-zod-params/server. Client components import the hook from use-zod-params.

Why: the main entry statically imports next/navigation hooks, and the Next.js compiler rejects that import anywhere in the server module graph — even if no hook is ever called. use-zod-params/server contains zero React imports, so a createUrlState instance defined there can be imported by Server Components, Route Handlers, middleware, and client components alike. This is a build-time constraint of the App Router itself (every schema-based URL-state library splits the same way), not a style preference.

The payoff of the shared instance: server and client are guaranteed to parse with identical codec settings. One schema, one set of options, zero drift between what page.tsx renders and what the hook hydrates.

// ✅ shared file (imported by both worlds)
import { createUrlState } from 'use-zod-params/server';

// ✅ client component
import { useUrlState } from 'use-zod-params';
const { state } = useUrlState(productSearch);          // instance-bound options
const { state } = useUrlState(productSearch, { history: 'push' }); // + overrides

// ✅ server component
const params = productSearch.parse(await searchParams);

// ✅ typed links, anywhere (RSC included)
<Link href={productSearch.href('/products', { page: 3, status: ['active'] })} />
// → /products?page=3&status=active   (defaults stripped)

// ❌ don't: import { createUrlState } from 'use-zod-params' in a file
//    reachable from a Server Component — next build will refuse it.

The <Suspense> boundary requirement

useUrlState reads useSearchParams(), and Next.js requires any statically rendered page to wrap such components in a <Suspense> boundary — otherwise next build fails with useSearchParams() should be wrapped in a suspense boundary.

You are automatically fine when the page is dynamic, which is the normal case for this library: reading the searchParams prop in your page.tsx (as in the pattern above) already opts the route into dynamic rendering. But if a component calls useUrlState on a route that is otherwise static (no searchParams prop, no other dynamic APIs), wrap it:

import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<TableSkeleton />}>
      <ProductTable />
    </Suspense>
  );
}

useOptimistic + isPending: instant UI, honest spinners

In the App Router, changing search params triggers a server round-trip for the new RSC payload. Naively, that means a pagination click doesn't visibly change anything until the server responds. useUrlState fixes both halves:

  • Router calls are wrapped in startTransition, so updates never block the UI, and
  • inside that transition the hook feeds the next state to React 19's useOptimistic — so state reflects the click immediately, then re-bases onto the committed URL when the navigation lands. If the navigation is interrupted (e.g. the user hits back), the optimistic state is discarded automatically.
  • isPending comes from the same transition, which the App Router keeps pending until the new server payload commits. It reflects the actual navigation, not a local state update — exactly what you want driving a table's loading overlay:
const { state, setState, isPending } = useUrlState(productSearch);
return <div data-loading={isPending}>…</div>;

On React 18 (Next 14), useOptimistic doesn't exist; the hook detects this at module load and degrades gracefully — state updates when the navigation commits instead of instantly. No code changes needed.


API

useUrlState(schemaOrInstance, options?)

Returns { state, setState, isPending, reset, href }.

| Option | Type | Default | | |---|---|---|---| | history | 'push' \| 'replace' | 'replace' | push creates a history entry (good for pagination — back button walks pages) | | scroll | boolean | false | Next.js scroll-to-top after navigating | | startTransition | boolean | true | Disable to navigate outside a transition (also disables optimistic state) | | debounceMs | number | 0 | Debounce applied to every update | | arrayFormat | 'repeat' \| 'comma' | 'repeat' | See Arrays | | objectFormat | 'bracket' \| 'dot' \| 'json' | 'bracket' | See Nested objects |

setState(update, options?) — accepts a partial ({ page: 2 }) or a functional updater returning a partial ((s) => ({ page: s.page + 1 })). Per-call options: history, scroll, debounceMs. Multiple calls in the same tick batch into one navigation and one history entry; functional updaters compose across the batch. Setting a key to its schema default removes it from the URL; if the resulting URL is unchanged, no navigation happens at all.

reset(options?) — sets every key to its schema default, clearing all managed params (foreign params survive).

href(partial?) — returns the URL string a given update would navigate to, without navigating. For <Link>, prefetching, "open in new tab".

Rules: define the schema/instance at module scope (stable identity), and call the hook only in client components.

createUrlState(schema, options?) — from use-zod-params/server

Returns { schema, options, parse, serialize, href }:

  • parse(input, options?) — accepts URLSearchParams, a query string, or the record Next passes as the searchParams prop. Never throws on bad input.
  • serialize(state, options?) — returns URLSearchParams with defaults stripped, keys in schema order (stable, cache-friendly URLs).
  • href(pathname, partial?, options?) — typed link builder from defaults + partial.

parseSearchParams(schema, input, options?), serializeSearchParams(schema, state, options?), and getDefaults(schema) are also exported standalone from both entries.


Serialization reference

| Zod type | URL representation | |---|---| | z.string() | as-is (URL-encoded) | | z.number() / z.coerce.number() | 42, 3.14 | | z.boolean() | true / false | | z.date() / z.coerce.date() | ISO 8601; UTC-midnight dates shorten to 2026-07-03 | | z.enum(...) / string literals | the literal value | | z.bigint() | decimal string | | z.array(...) | see below | | nested z.object(...) | see below | | .optional() / value absent | key omitted | | value equals schema default (deep-equal) | key stripped |

z.coerce is optional: the parser pre-decodes strings based on the schema type, so plain z.number() / z.boolean() / z.date() work. This also fixes the classic z.coerce.boolean() footgun where the string "false" would coerce to true.

Arrays

Default is 'repeat', matching native URLSearchParams semantics:

?status=active&status=pending        arrayFormat: 'repeat' (default)
?status=active,pending               arrayFormat: 'comma'

Caveats worth knowing:

  • Empty arrays: repeat format has no representation for an empty array — if [] is not the schema default, the key is omitted and parses back to the default. Comma format can represent it (?status=) and round-trips [] exactly. If "explicitly empty" differs from your default, use 'comma'.
  • Comma format requires that values never contain commas (a dev-mode warning fires if they do). Stick with 'repeat' unless URL compactness matters.

Nested objects

?sort[col]=price&sort[dir]=desc      objectFormat: 'bracket' (default)
?sort.col=price&sort.dir=desc       objectFormat: 'dot'
?sort={"col":"price","dir":"desc"}  objectFormat: 'json'

Leaf values are decoded by their schema type (numbers, booleans, dates all work inside nested objects). Bracket-path keys attempting prototype pollution (sort[__proto__]=…) are ignored.

Dates

Serialized as ISO 8601. A date at exactly UTC midnight — the common output of date pickers — is shortened to YYYY-MM-DD for clean URLs, and parses back losslessly. Remember that new Date('2026-07-03') is UTC midnight; construct dates consistently in UTC if you rely on the short form.


Error handling: per-key resilience

Users edit URLs. Marketing appends garbage. The contract: parsing never throws, and one bad key never nukes the rest. Each top-level key validates independently — given ?page=banana&status=active, page falls back to its default while status parses normally. Failed keys fall back to their .default() (or .catch()) value; keys without one become undefined, so mark them .optional().

Debouncing & batching

  • setState({ q }, { debounceMs: 300 }) — trailing debounce, ideal for search inputs; each call resets the timer.
  • An immediate update flushes pending debounced updates with it — clicking "next page" mid-debounce produces one navigation containing both changes, never two.
  • A pending debounced update is flushed on unmount, so a half-typed filter isn't lost when the user navigates away.
  • All same-tick updates — from any number of setState calls — produce exactly one history entry.

Next.js caveats, collected

  1. use-zod-params/server in shared files (see the shared pattern).
  2. <Suspense> around useUrlState consumers on statically rendered routes (see above).
  3. Schemas at module scope — an inline schema re-parses every render and defeats memoization.
  4. URL length — browsers and CDNs cap URLs (~2 KB is the safe zone). URL state is for filters and view state, not payloads.
  5. React 18 / Next 14 — supported; optimistic updates degrade to commit-time updates.

Zod compatibility

zod@^3.25 || ^4 — the library introspects schemas through a version-agnostic layer and its entire test suite runs against both majors. Both z.coerce.* and plain types work.

Development

npm install
npm test        # Vitest unit suite (runs against Zod 3 AND Zod 4)
npm run build   # tsup → ESM + CJS + .d.ts
npm run e2e     # Playwright against the example app (production build)

The examples/data-table app is a full shadcn-style data table — pagination, sorting, faceted filters, debounced search, SSR'd first paint — and doubles as the E2E fixture.

License

MIT