Build Query Strings with URLSearchParams Without Double Encoding

A search for C++ reaches the server as C , or a filter containing & unexpectedly creates another parameter. These bugs often start when application values are interpolated into a query string before the query parser sees them.

Keep the distinction between a raw value and an already serialized query explicit. The examples below use JavaScript’s URL and URLSearchParams APIs in a browser or Node.js, without making network requests.

Reproduce the Plus Sign Bug

The string constructor parses query syntax. It does not treat the entire input as a raw value:

1
2
3
4
5
6
7
8
9
const term = "C++";
const broken = new URLSearchParams(`q=${term}`);

console.log(broken.get("q")); // "C  "

const correct = new URLSearchParams();
correct.set("q", term);
console.log(correct.toString()); // "q=C%2B%2B"
console.log(correct.get("q"));   // "C++"

URLSearchParams uses the application/x-www-form-urlencoded rules: parsing turns + into a space; serialization represents a literal plus as %2B and a space as +. The WHATWG URL Standard defines this behavior.

Pass raw names and values to set, append, or the object constructor. Reserve the string constructor for query text that is already serialized, such as q=C%2B%2B.

Pass Raw Values to the Serializer

Do not call encodeURIComponent before giving a value to URLSearchParams:

1
2
3
4
5
6
7
const term = "C++ & tools";
const correct = new URLSearchParams({ q: term });
const doubled = new URLSearchParams({ q: encodeURIComponent(term) });

console.log(correct.toString()); // "q=C%2B%2B+%26+tools"
console.log(doubled.toString()); // "q=C%252B%252B%2520%2526%2520tools"
console.log(doubled.get("q"));   // "C%2B%2B%20%26%20tools"

The second serializer escapes the percent signs introduced by the first encoder. One query parse then returns encoded-looking text instead of the original search term. The MDN percent-encoding guidance documents this boundary.

A literal value of %2B is different from a literal +. Serializing the former as %252B is correct: decoding it once must return the three original characters. Do not repair double encoding by repeatedly decoding arbitrary input; fix the point where a raw value was encoded twice.

Preserve Repeated Parameters Explicitly

Suppose an item endpoint accepts one tag parameter for each selected filter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const params = new URLSearchParams({ q: "sword" });
params.append("tag", "rare");
params.append("tag", "two handed");

console.log(params.toString()); // "q=sword&tag=rare&tag=two+handed"
console.log(params.get("tag")); // "rare"
console.log(params.getAll("tag")); // ["rare", "two handed"]

params.set("tag", "common");
console.log(params.getAll("tag")); // ["common"]

Use append to add another pair. set replaces the first matching value and removes other pairs with that name. Read repeated fields with getAll; get returns only the first value.

An object field containing an array does not define a repeated-key contract. For example, new URLSearchParams({ tag: ["rare", "fast"] }) produces tag=rare%2Cfast, as explained in the Node.js URLSearchParams object documentation . Loop over values with append when the server expects repeated keys. Confirm that contract with the endpoint rather than assuming it accepts comma-separated values or bracket notation.

Edit a Full URL Through Its searchParams

Use URL to separate the query from the path and fragment:

1
2
3
4
5
6
const url = new URL("/api/items?lang=en#results", "https://example.com");
url.searchParams.set("q", "C++ & tools");
url.searchParams.append("tag", "rare");

console.log(url.href);
// "https://example.com/api/items?lang=en&q=C%2B%2B+%26+tools&tag=rare#results"

The fragment stays after the query, and the existing lang parameter remains available. In an application, replace the example origin and endpoint with your actual service. A leading / resolves from the origin root.

new URLSearchParams(fullUrl) does not extract a full URL’s query. Use new URL(fullUrl).searchParams, or parse window.location.search when reading the current page.

Updating url.searchParams can rewrite existing query encoding, including changing %20 to +. Parsed values can remain equal while the URL text changes; see MDN’s URL interaction examples . If a service signs the exact query bytes, follow its signing and canonicalization rules before changing that URL.

Verify Values Through a Round Trip

Save this independent test as query-round-trip.mjs and run node query-round-trip.mjs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import assert from "node:assert/strict";

const samples = [
  "C++",
  "a b",
  "a&b=c#d?e",
  "%2B",
  "",
  "caf\u00e9",
  "\u{1F3AE}",
];

for (const value of samples) {
  const outgoing = new URLSearchParams();
  outgoing.set("q", value);
  const incoming = new URLSearchParams(outgoing.toString());
  assert.equal(incoming.get("q"), value);
  assert.deepEqual([...incoming.keys()], ["q"]);
}

const repeated = new URLSearchParams();
repeated.append("tag", "rare");
repeated.append("tag", "two handed");
const parsed = new URLSearchParams(repeated.toString());
assert.deepEqual(parsed.getAll("tag"), ["rare", "two handed"]);
assert.equal(parsed.get("missing"), null);

const empty = new URLSearchParams("q=");
assert.equal(empty.has("q"), true);
assert.equal(empty.get("q"), "");

console.log("Query round-trip checks passed.");

These assertions check that reserved characters stay inside one value and repeated filters survive serialization. An empty value and an absent parameter remain distinct through has and get.

For the real endpoint, add an integration test that compares the server’s parsed values with the submitted filters. A local round trip checks the client encoder and parser together; it cannot establish how a backend interprets repeated keys. Inspect the outgoing URL and the server’s parsed query separately when a filter behaves incorrectly.