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

@trydig/next-searchparams

v1.0.0

Published

Drop-in replacement for Next.js useSearchParams that returns a mutable URLSearchParams.

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 / react peers

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:

  1. Boilerplate. Clone → mutate → stringify → push. Every callsite.

  2. No batching. Two updates in same tick = two pushes = two history entries + two server roundtrips.

  3. Full server roundtrip on every change. router.push re-runs RSC even when only client state cares about param. Slow for tight interactions (typeaheads, sliders, tab state).

  4. Stale closures. Read params from React state, mutate, write — concurrent updates clobber each other.

  5. Bailout to client-side rendering. Next.js's useSearchParams forces 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-bailout

    Why: 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 renders null, 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 / yarn

Setup

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 | null

Mutate

params.set("page", "2");
params.append("tag", "react");
params.delete("filter");
params.delete("tag", "react"); // delete specific value

Multiple calls in same tick batch into one history entry:

params.set("page", "1");
params.set("sort", "asc");
params.delete("cursor");
// → single pushState, single re-render

Config

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: false makes the whole flush non-shallow
  • any action asking for a push (the default) beats replace: true
  • scroll comes from whichever action opted into shallow: 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 if SearchParamsProvider isn't above it.
  • Client-only. SSR renders with empty params. Read params on the server from the route's searchParams prop, 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 useLayoutEffect guarantees 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, read searchParams in a server component instead.
  • Mount-time effects see "" once. Consumer effects fire on the hydration pass before the sync lands. Key effects and memos on params.get("key") (a string), not on params (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 _rsc request is made. Verified in example/tests against a dynamic route (/deep), since a static route can't distinguish. If a server component depends on the param, use shallow: false or read the route's searchParams prop.
  • 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 = ... and location.assign(...) cause full page loads.
  • Escape hatch. If the pre-paint sync flush ever shows up in a profile with many heavy consumers, swapping useLayoutEffect for useEffect trades 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.