Frontend routing

Query params in React & Next.js.

Reflecting search, sort, and filter state in the URL is the right pattern — but it’s also where double-encoding, scroll jumps, and infinite re-render loops creep in. Here’s the correct way to read and write query parameters, and the traps to avoid.

The core pattern

In the Next.js App Router, useSearchParams() returns a read-only URLSearchParams. You never mutate it directly — you build a fresh copy, change it, and write it back through the router:

'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';

function Filters() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  function setFilter(key, value) {
    const params = new URLSearchParams(searchParams); // copy
    if (value) params.set(key, value);
    else params.delete(key);
    replace(`${pathname}?${params.toString()}`, { scroll: false });
  }
  // ...
}

Three things make this correct: it copies the read-only params before editing, it uses replace (not push) so filter changes don’t flood history, and it passes { scroll: false } so the page doesn’t jump to the top.

Trap 1 — double-encoding with encodeURIComponent

URLSearchParams already percent-encodes each value once when you call .set() and .toString(). Wrapping the value in encodeURIComponent first encodes it twice:

// Wrong — value gets encoded twice
params.set('q', encodeURIComponent(term));   // "caf%25C3%25A9"

// Right — URLSearchParams encodes exactly once
params.set('q', term);                        // "caf%C3%A9"

The symptom is %2520 where you expected %20, or %25C3%25A9 for an accented character. If you’ve got a value stuck in that state, our decoder with “Decode recursively” unwraps both layers. Reserve encodeURIComponent for the case where you assemble a query string by hand, without URLSearchParams.

Trap 2 — the infinite re-render loop

The classic freeze: an effect writes the URL and also depends on something recreated every render, so the URL write triggers a render that recreates the dependency that re-runs the effect. A common trigger is depending on the whole searchParams object, or an inline filters object:

// Risky — params object identity changes each render
useEffect(() => {
  const params = new URLSearchParams(searchParams);
  params.set('page', page);
  replace(`${pathname}?${params.toString()}`, { scroll: false });
}, [searchParams, page, pathname, replace]); // searchParams churns

Fixes: depend on primitive values (the specific param string you read, not the object), do the URL write inside the event handler rather than an effect where possible, and wrap any reused builder in useCallback so its identity is stable:

const createQueryString = useCallback((name, value) => {
  const params = new URLSearchParams(searchParams.toString());
  params.set(name, value);
  return params.toString();
}, [searchParams]);

// Then, in a handler (not an effect):
<button onClick={() => replace(pathname + '?' + createQueryString('sort', 'asc'), { scroll: false })}>

Trap 3 — debounce text inputs before writing the URL

Writing the URL on every keystroke of a search box hammers the router and history. Debounce the input, then update the URL once the user pauses:

// Update the URL ~300ms after the user stops typing
const onChange = useDebouncedCallback((term) => {
  const params = new URLSearchParams(searchParams);
  if (term) params.set('q', term); else params.delete('q');
  replace(`${pathname}?${params.toString()}`, { scroll: false });
}, 300);

Plain React Router

Outside Next.js, React Router exposes useSearchParams too — but its setter is writable, so the pattern is slightly shorter. The same encoding rule applies: let the setter encode once, don’t pre-encode with encodeURIComponent.

import { useSearchParams } from 'react-router-dom';

const [searchParams, setSearchParams] = useSearchParams();
// setSearchParams accepts an object or URLSearchParams and encodes once
setSearchParams(prev => {
  prev.set('sort', 'asc');
  return prev;
}, { replace: true });

Sources & standards

Honest scope: framework APIs change between major versions — the App-Router patterns here reflect Next.js 13+ (where shallow was removed). Check your installed version’s docs, and note that URLSearchParams uses form-encoding (+ for space), which differs from encodeURIComponent (%20).

Common questions

About query params.

No — and doing both is a bug. URLSearchParams encodes values for you exactly once when you call .set() and .toString(). If you also run encodeURIComponent on the value first, you get double-encoding: a space becomes %2520 instead of %20. Use encodeURIComponent only when you’re building a query string by hand, not alongside URLSearchParams.

Usually an unstable dependency. If a useEffect writes to the URL and also depends on an object or array that’s recreated every render (including the searchParams object itself in some patterns), each URL change triggers a render, which recreates the dependency, which re-runs the effect. Read the current params inside the handler, depend on primitive values, and memoize with useCallback so the identity is stable.

Pass { scroll: false } to router.push or router.replace in the Next.js App Router. Without it, the router scrolls to the top on navigation, which feels broken when the user is mid-list applying a filter. Use replace rather than push for filter/sort changes so you don’t flood the browser’s back-button history.

Not in the App Router. The shallow option existed in the older Pages Router; in the App Router it was removed. To update the URL without re-running server data fetching, use the Web History API directly (window.history.replaceState) or a dedicated library like nuqs. For most filter UIs, router.replace with { scroll: false } is the straightforward choice.

Use replace. Every push adds a browser-history entry, so a user who applies five filters then hits Back has to click through all five states before leaving the page. replace updates the URL in place, keeping the back button meaningful. Reserve push for navigations the user should be able to reverse step by step.

Related

Try the tool.

About this page

Written and maintained by the urlencodedecode.com team. Every technical claim on this page is verified against primary sources — the RFCs (3986, 3629, 4648, 7578), the WHATWG URL Standard, and official vendor or language documentation — rather than second-hand summaries. When a source contradicts a common assumption, we follow the source and note the discrepancy. Corrections: contactus@urlencodedecode.com.