@trydig/next-searchparams
v1.0.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: the built-in hook reads server-injected param state, so the server must wait for the client. This package calls it exactly once, inside a
<Suspense>boundary around a component that rendersnull, and fans the value out over context. The bailout still happens — but it's scoped to a zero-UI subtree, so your pages stay static. No Suspense walls at your callsites.
This hook fixes all five:
"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 / yarnSetup
Wrap your root layout's children once. SearchParamsProvider is a client component, but children passed through it stays server-rendered — wrapping the tree does not client-ify it.
// app/layout.tsx (still a server component)
import { SearchParamsProvider } from "@trydig/next-searchparams";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SearchParamsProvider>{children}</SearchParamsProvider>
</body>
</html>
);
}The provider is required — useSearchParams() throws without it rather than silently reporting empty params.
Usage
Read
Same shape as the built-in — URLSearchParams instance, re-renders on URL change:
const params = useSearchParams();
const q = params.get("q");Single key, if you prefer. Note it re-renders on any param change, not just this one:
const q = useSearchParam("q"); // string | nullMutate
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 |
Batching is app-wide, so a single flush can contain actions from several components with different configs. They merge by escalation, never last-wins:
- any action asking
shallow: falsemakes the whole flush non-shallow - any action asking for a push (the default) beats
replace: true scrollcomes from whichever action opted intoshallow: false
Escalation always picks the option that does more work: a spurious RSC refresh is recoverable, a missing one silently leaves server components stale.
Set shallow: false when server components depend on the params and need to re-render.
How it works
Reading. SearchParamsProvider renders a null component inside <Suspense fallback={null}> that makes the one real next/navigation useSearchParams() call in your app. A useLayoutEffect pushes params.toString() into context. Because Next's hook is the source of truth, every navigation path syncs for free: <Link>, router.push / replace, history.pushState / replaceState (Next patches those itself), and back/forward.
Context holds the search string, not a params object — so setState bails out on an identical value and pathname-only navigation costs consumers nothing.
Writing. useSearchParams() returns a Proxy<URLSearchParams> over a fresh URLSearchParams(search). set / delete / append push onto an app-wide queue and schedule one flush() per microtask; every other method passes straight through to the target.
flush() reads window.location.search fresh — never the React snapshot, or concurrent mutations clobber each other — applies the queued actions, then writes the URL via history.pushState / replaceState (shallow) or router.push / replace (non-shallow). If the queue grew while applying, it re-flushes.
flush() then also writes the new search string into provider state directly. This is deliberate: Next reflects history writes through a startTransition'd dispatch, so relying on its hook alone would make your own mutations interruptible — a typeahead would drop characters. Self-initiated writes update urgently; Next's hook remains the reconciler for everything else.
Safari workaround: URLSearchParams.has(key, value)'s second argument is unsupported there, so it's polyfilled via getAll().includes().
Caveats
- Provider required.
useSearchParams()throws ifSearchParamsProviderisn't above it. - Client-only. SSR renders with empty params. Read params on the server from the route's
searchParamsprop, not this hook. - The first painted frame has empty params, and that's unavoidable. A statically prerendered shell can't know the params, so the HTML the browser paints first has none. The
useLayoutEffectguarantees the hydration commit already carries the real value — you get"" → "hello"once, with no extra stale frame after hydration. If param-derived content must be in the first paint or visible to crawlers, readsearchParamsin a server component instead. - Mount-time effects see
""once. Consumer effects fire on the hydration pass before the sync lands. Key effects and memos onparams.get("key")(a string), not onparams(identity). - No selector granularity. Any param change re-renders every consumer. Memo heavy children.
- Shallow updates skip RSC. With
shallow: true(default), server components do not re-run and no_rscrequest is made. Verified inexample/testsagainst a dynamic route (/deep), since a static route can't distinguish. If a server component depends on the param, useshallow: falseor read the route'ssearchParamsprop. - Mutation is async.
params.set("x", "1"); params.get("x")on the next line returns the old value — the proxy wraps a snapshot. Read it on the next render. - Only history writes are tracked.
location.search = ...andlocation.assign(...)cause full page loads. - Escape hatch. If the pre-paint sync flush ever shows up in a profile with many heavy consumers, swapping
useLayoutEffectforuseEffecttrades a one-frame param lag for non-blocking updates.
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.
The autofix does not add the provider — set up SearchParamsProvider first, or the rewritten callsites will throw.
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 the package
bun run build # emit dist/
bun run test # build-output assertions (directive, static routes)
bun run test:e2e # Playwright against example/ (production build)First run needs cd example && bun install — the harness has its own lockfile.
example/ is a Next App Router app used as the test harness. It resolves the package through a tsconfig paths alias to ../dist, so it exercises the built output; test:e2e rebuilds first.
See CLAUDE.md for the invariants before touching flush logic.
License
UNLICENSED. INTERNAL TRYDIG USE ONLY.
