@trydig/next-searchparams
v0.1.0
Published
Drop-in replacement for Next.js useSearchParams that returns a mutable URLSearchParams.
Maintainers
Keywords
Readme
@trydig/next-searchparams
"use client";
import { useSearchParams } from "@trydig/next-searchparams";
function Filter() {
const params = useSearchParams();
return <input onChange={(e) => params.set("filter", e.target.value)} />;
}Drop-in replacement for Next.js useSearchParams that returns a mutable URLSearchParams. Mutations (set / delete / append) batch into a single URL update via microtask.
- Next.js ≥ 15, React ≥ 19, TypeScript ^6
- Client-only (
"use client") - Zero deps beyond
next/reactpeers
Why not the built-in hook?
Next.js's useSearchParams returns a read-only URLSearchParams. To update the URL you do this dance:
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
function Filter() {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
function setFilter(value: string) {
const next = new URLSearchParams(params);
next.set("filter", value);
router.push(`${pathname}?${next.toString()}`);
}
// ...
}Problems:
Boilerplate. Clone → mutate → stringify → push. Every callsite.
No batching. Two updates in same tick = two pushes = two history entries + two server roundtrips.
Full server roundtrip on every change.
router.pushre-runs RSC even when only client state cares about param. Slow for tight interactions (typeaheads, sliders, tab state).Stale closures. Read params from React state, mutate, write — concurrent updates clobber each other.
Bailout to client-side rendering. Next.js's
useSearchParamsforces entire route into CSR at build time unless every consumer wrapped in<Suspense>. Miss one boundary → whole page deopts from static to dynamic, build warns:useSearchParams() should be wrapped in a suspense boundary at page "/...". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailoutWhy: built-in hook reads server-injected param state, so server must wait for client. This hook reads
window.location.searchviauseSyncExternalStorewith empty server snapshot — no server dependency, no bailout, no Suspense walls. Static routes stay static.
This hook fixes all four:
"use client";
import { useSearchParams } from "@trydig/next-searchparams";
function Filter() {
const params = useSearchParams();
return <input onChange={(e) => params.set("filter", e.target.value)} />;
}params.set(...) queues; microtask flushes once with the merged result; URL updates via history.pushState (shallow, default) — no RSC roundtrip unless you opt in.
Install
bun add @trydig/next-searchparams
# or npm / pnpm / yarnUsage
Read
Same shape as the built-in — URLSearchParams instance, re-renders on URL change:
const params = useSearchParams();
const q = params.get("q");Mutate
params.set("page", "2");
params.append("tag", "react");
params.delete("filter");
params.delete("tag", "react"); // delete specific valueMultiple calls in same tick batch into one history entry:
params.set("page", "1");
params.set("sort", "asc");
params.delete("cursor");
// → single pushState, single re-renderConfig
const params = useSearchParams({
replace: false, // pushState vs replaceState. default: false
shallow: true, // history API vs router.push (RSC refetch). default: true
scroll: true, // only applies when shallow=false. default: true
});| Option | Default | Effect |
| --------- | ------- | ------------------------------------------------------------------- |
| replace | false | true → no new history entry |
| shallow | true | true → history.pushState only. false → router.push (RSC) |
| scroll | true | Passed to router when shallow: false |
Set shallow: false when server components depend on the params and need to re-render.
Patch history early (recommended)
history.pushState / replaceState don't fire events natively. This package patches them to dispatch a urlchange event so the hook can subscribe. The hook patches lazily on first subscribe, but third-party code that pushes state before the hook mounts will be missed.
Call patchHistory() from instrumentation-client.ts for earliest patch:
// instrumentation-client.ts
import { patchHistory } from "@trydig/next-searchparams/patch";
patchHistory();Idempotent — safe to call multiple times.
How it works
useSyncExternalStoresubscribes topopstate+ customurlchange. Snapshot =window.location.search.- Returns a
Proxy<URLSearchParams>wrapping a freshURLSearchParams(search). Interceptsset/delete/append, pushes into a queue, schedulesflush()viaqueueMicrotask. Other methods pass through. flush()readswindow.location.searchfresh (not the React snapshot — avoids stale state), applies queued actions, writes URL viahistory.pushState/replaceState(shallow) orrouter.push/replace(non-shallow). Re-flushes if queue grew during apply.configRefread at flush time → latest props win.- Safari workaround:
URLSearchParams.has(key, value)second arg unsupported in Safari → polyfilled viagetAll().includes().
Caveats
- Client-only.
getServerSnapshotreturns""— SSR sees empty params. Read params on the server from the route'ssearchParamsprop, not this hook. - Shallow updates skip RSC. With
shallow: true(default), server components do not re-fetch. If a server component depends on the param, useshallow: falseor read params from the route props server-side. - Mutation is async.
params.set("x", "1"); params.get("x")on the next line returns the old value — the proxy wraps a snapshot. Read from the next render.
Lint rules
Both ship in this package — point your linter at the existing install, no extra deps.
ESLint (flat config)
// eslint.config.js
import trydig from "@trydig/next-searchparams/eslint-plugin";
export default [
trydig.configs.recommended,
];Or wire the rule manually:
import trydig from "@trydig/next-searchparams/eslint-plugin";
export default [
{
plugins: { "@trydig/next-searchparams": trydig },
rules: {
"@trydig/next-searchparams/prefer-trydig-search-params": "warn",
},
},
];Autofix rewrites import { useSearchParams } from "next/navigation" → import { useSearchParams } from "@trydig/next-searchparams". Other specifiers (useRouter, usePathname) stay on next/navigation.
Biome (GritQL plugin)
Biome ≥ 2.x. Reference the shipped plugin from biome.json:
{
"plugins": [
"./node_modules/@trydig/next-searchparams/biome/plugins/no-next-search-params.grit"
]
}Emits a diagnostic on the offending import. No autofix — Biome plugin autofix not stable yet.
Development
bun install
bunx tsc --noEmit # typecheck
bun test # tests (none yet)Two files, tightly coupled — see CLAUDE.md for invariants before changing flush logic.
License
UNLICENSED. INTERNAL TRYDIG USE ONLY.
