use-zod-params
v1.0.0
Published
Type-safe URL search parameter state management for Next.js App Router, powered by Zod schemas.
Maintainers
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(); // → /productsFeatures
- 🔒 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,pushvsreplace,scroll: false, transitions. - ⚡ Instant UI via
useOptimistic— state updates immediately,isPendingtracks 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.25Quick 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 fromuse-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— sostatereflects 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. isPendingcomes 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?)— acceptsURLSearchParams, a query string, or the record Next passes as thesearchParamsprop. Never throws on bad input.serialize(state, options?)— returnsURLSearchParamswith 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
setStatecalls — produce exactly one history entry.
Next.js caveats, collected
use-zod-params/serverin shared files (see the shared pattern).<Suspense>arounduseUrlStateconsumers on statically rendered routes (see above).- Schemas at module scope — an inline schema re-parses every render and defeats memoization.
- URL length — browsers and CDNs cap URLs (~2 KB is the safe zone). URL state is for filters and view state, not payloads.
- 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
