Introduction
Have you ever written code like this to handle URL search params in React?
const searchParams = new URLSearchParams(window.location.search);
const page = Number(searchParams.get('page')) || 1;
const q = searchParams.get('q') || '';
It's simple, but writing this over and over as your app grows gets tedious. And the fallback behavior for an invalid value (like page=abc) tends to differ depending on who wrote the code.
A number of libraries already tackle this by combining a validation library like Zod with URL params. @standard-search-params/react takes the same approach, but it's built around a spec called Standard Schema instead of any single validation library.
npm install @standard-search-params/react
What is Standard Schema?
Standard Schema is a spec that lets different validation libraries — Zod, Valibot, ArkType, and others — expose a common interface. It's intentionally minimal: a validator just needs a ~standard property with a shared validate() method.
What makes this useful is that library authors no longer need to build separate integrations for Zod, Valibot, and so on. Build one tool against Standard Schema, and it works the same way regardless of which validator your consumers happen to use.
@standard-search-params/react is built so it works with any Standard Schema-compliant validator.
import { z } from 'zod';
import { useStandardSearchParams } from '@standard-search-params/react';
const schema = {
page: z.coerce.number().int().min(1),
q: z.string().min(1),
};
function SearchResults() {
const { validatedSearchParams, isSearchParamsReady } =
useStandardSearchParams(schema);
if (!isSearchParamsReady) return null;
return <div>page: {validatedSearchParams.page ?? 1}</div>;
}
If you'd rather use Valibot, swap z.coerce.number() for v.pipe(v.string(), v.transform(Number)) — the hook itself doesn't change at all. You can even mix Zod and Valibot in the same schema object.
import { z } from 'zod';
import * as v from 'valibot';
const schema = {
page: z.coerce.number().int(),
q: v.pipe(v.string(), v.minLength(1)),
};
Why per-field schemas instead of one big object schema?
Most validation hooks for URL params validate the whole query string against a single object schema. That approach has a weakness: one invalid field can invalidate everything.
?page=2&q=hello&sort=bad
If sort holds an unexpected value here and you're validating the whole object as one schema, a perfectly valid page and q can get thrown out along with it.
@standard-search-params/react avoids this by taking a plain object mapping each param name to its own schema, validated independently.
searchParams // { page: '2', q: 'hello', sort: 'bad' } (raw strings)
validatedSearchParams // { page: 2, q: 'hello' } (parsed, sort dropped)
Only sort gets excluded from validatedSearchParams; page and q come through fine. One broken param not taking down the rest matters more than it sounds once you're dealing with stale bookmarks or hand-edited URLs in a real product.
Client-side only, on purpose
This hook reads window.location.search, so it never runs during server rendering. That's an intentional design choice, not an oversight.
It's safe to render inside an SSR framework (no crash, no hydration mismatch): isSearchParamsReady stays false through the server render and the first client render, then flips to true once the client has read and validated the URL. There's necessarily a brief "not loaded yet" moment before that, though — that part can't be avoided.
If you need validated search params as part of the initial server-rendered output (e.g. a Next.js Server Component, which receives searchParams as a plain prop), skip the hook and validate that object directly with your schema instead. This hook is meant for client-rendered apps (SPAs) or client components that read params after mount.
Handling browser back/forward
v0.2.0 added two ways to react to the URL changing after mount.
For browser back/forward buttons, pass listenToPopstate:
const { validatedSearchParams } = useStandardSearchParams(schema, {
listenToPopstate: true,
});
It's false by default. That's intentional — the hook's core design is "read the URL once, on mount," and I didn't want to change that behavior silently for existing users. Automatic re-validation is something you opt into, not something that happens by default.
SPA route pushes (router.push(), navigate()) don't fire popstate, so there's a separate refresh() function for that case:
// Next.js
const { validatedSearchParams, refresh } = useStandardSearchParams(schema);
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
refresh();
}, [pathname, searchParams, refresh]);
A small safeguard for development
This hook reads the URL once, on mount. That means an inline schema object (a fresh reference every render) is totally fine — only the keys present on the very first render are ever read. But it also means there's a footgun if the set of schema keys itself needs to change at runtime — for example, validating extra params only for admin users.
To catch this early, there's a dev-only console.warn:
useStandardSearchParams: schema keys changed after mount
(was [page], now [page,q]), but this hook only reads the URL once,
on mount, so the new keys won't be read. If the set of keys genuinely
needs to change at runtime, remount the component (e.g. with a `key`
prop) — memoizing the schema object does not cause a re-parse.
As the message says, the fix is a key prop to force a remount — not useMemo. Memoizing the schema stabilizes its reference, but it does nothing to trigger a re-parse, so the warning spells out the actual fix rather than pointing toward an intuitive-but-wrong one.
Wrapping up
@standard-search-params/react is a small library built around a few ideas:
- Standard Schema support, so it works with Zod, Valibot, ArkType, or whatever you're already using
- Per-field validation, so one broken param doesn't take the rest down with it
- Client-side only, clearly documented rather than papered over
- Browser-back and SPA-router support, both opt-in on top of a safe default I'd rather keep the feature set small and the behavior predictable than pile on options.
Feedback and issues are always welcome.
Top comments (0)