DevTools Hub

Search tools

Search for a developer tool

URL Encoding in React

Part of the Encoding Toolkit

The encoding rules themselves don't change in React — see URL Encoding Explained for those. What's specific to React (and Next.js, React Router, and similar routers) is where the framework quietly handles encoding for you, and where it very much doesn't — and mixing those two up produces some of the most confusing bugs in a React codebase, because the URL looks fine right up until it isn't.

Link href does not reliably encode for you

A common assumption is that <Link href={...}> (Next.js, React Router, or a plain <a>) sanitizes whatever string you give it. It doesn't — the href is passed through to the browser mostly as-is, and the browser's own URL parser only fixes some of what's wrong with it:

new URL("/search?q=hello world", location.origin).href
// .../search?q=hello%20world   — the browser auto-fixed the space

new URL("/search?q=cats&dogs=1", location.origin).href
// .../search?q=cats&dogs=1     — NOT fixed: & is read as a second parameter

A space gets silently corrected because the URL parsing algorithm percent-encodes it automatically during resolution. A reserved character like & does not — it's syntactically valid where it sits, so nothing flags it, and it silently splits your one query value into two parameters. Interpolating a value straight into a href template string works fine in testing with simple values and breaks the moment a real one contains &, #, or ?:

// Wrong — breaks if `query` contains &, #, ?, or a literal /
<Link href={`/search?q=${query}`}>Search</Link>

// Right — encode the dynamic piece before it goes into the template
<Link href={`/search?q=${encodeURIComponent(query)}`}>Search</Link>

The same applies to a dynamic path segment (/products/${slug}) — encode slug before interpolating it, the same way you would in any other language. Nothing about it being a React component changes the rule.

useSearchParams() hands you already-decoded values

Both Next.js's next/navigation and React Router's useSearchParams are backed by URLSearchParams, which decodes on read. Calling decodeURIComponent on a value you got from .get() decodes it a second time — for most values that's harmless (nothing left to decode), but it'll throw or quietly corrupt a value that happened to contain what looks like a stray %XX sequence:

const searchParams = useSearchParams();
const q = searchParams.get("q"); // already decoded — use it directly
// decodeURIComponent(q) here is redundant, and occasionally wrong

The same is true of a dynamic route segment a router hands you as a param object — by the time it reaches your component, it's already been through the router's own decode step.

The double-encode footgun, specifically in React state → URL sync

A common pattern is keeping a piece of state in sync with a query parameter — a search box, a filter dropdown — by writing it into URLSearchParams and pushing the result. If the value was manually encoded before being handed to .set(), it gets encoded again, since URLSearchParams encodes whatever you give it regardless of whether it's already encoded:

const params = new URLSearchParams(searchParams);
params.set("q", encodeURIComponent(query)); // wrong — encoded twice
// "hello world" -> encodeURIComponent -> "hello%20world"
//                -> params.set encodes that too -> "hello%2520world"

params.set("q", query); // right — pass the raw value, let URLSearchParams encode it once
router.push(`${pathname}?${params.toString()}`);

This is the single most common React-specific encoding bug: reaching for encodeURIComponent out of habit right before handing a value to an API that already encodes for you. URLSearchParams never expects pre-encoded input — give it raw values and let it do the one layer of encoding itself.

Building the query string once, not per-key

When several state values need to land in the URL together, build one URLSearchParams instance and set everything on it before converting to a string, rather than concatenating key=value pairs by hand — it's the difference between correct encoding on every value automatically and re-implementing that logic (badly) with template strings:

function buildUrl(pathname: string, filters: Record<string, string>) {
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(filters)) {
    if (value) params.set(key, value);
  }
  const qs = params.toString();
  return qs ? `${pathname}?${qs}` : pathname;
}

Try it yourself

Test what a value actually encodes to with URL Encode, or check a URL you've already built for missing or doubled-up encoding with URL Inspector — it flags exactly the double-encoded pattern from the example above. All run entirely in your browser.

Related tools