419 words
2 minutes

URLSearchParams Turns Plus Signs into Spaces: Preserve Literal +

2026-11-10
Frontend
JavaScript
/
Node.js
/
Troubleshooting
Contents

If new URLSearchParams('email=dev+qa@example.test').get('email') returns 'dev qa@example.test', the string constructor has already interpreted the plus as a space. The producer of the query needs to send %2B for a literal plus. When you have the original value in JavaScript, pass it directly to params.set('email', value) and let the API serialize it.

Check the raw query and the parsed value#

The WHATWG form parser replaces raw + with space before percent-decoding. That order lets %2B become a literal plus. The parser gives token=a+b and token=a%20b the same value, so the result of get() alone cannot tell you whether a space was intended. Check the incoming query at the boundary where it was received.

A different symptom points to a different stage. If the receiver sees the literal characters %2B and the wire value contains %252B, the sender may have passed a pre-encoded value to set(). The API stores that value as supplied and escapes the percent sign when it serializes the query. A value containing the actual text %2B legitimately takes that same path, so compare with the original input before treating it as a bug. WHATWG URLSearchParams

Input to JavaScriptOperationSerialized queryValue after parsing
Raw query token=a+bString constructortoken=a+ba b
Raw query token=a%20bString constructortoken=a+ba b
Raw query token=a%2BbString constructortoken=a%2Bba+b
Decoded value a+bset('token', value)token=a%2Bba+b
Pre-encoded value a%2Bbset('token', value)token=a%252Bba%2Bb

Test the query across a serialization boundary#

Save this as query-plus.mjs and run node query-plus.mjs. Each row checks both the serialized query and the value a fresh parser reads. The final assertion checks a URL with an existing parameter and a token containing both a plus and a space.

import assert from 'node:assert/strict';
const cases = [
[new URLSearchParams('token=a+b'), 'token=a+b', 'a b'],
[new URLSearchParams('token=a%20b'), 'token=a+b', 'a b'],
[new URLSearchParams('token=a%2Bb'), 'token=a%2Bb', 'a+b'],
];
for (const [value, wire, expected] of [
['a+b', 'token=a%2Bb', 'a+b'],
[encodeURIComponent('a+b'), 'token=a%252Bb', 'a%2Bb'],
]) {
const params = new URLSearchParams();
params.set('token', value);
cases.push([params, wire, expected]);
}
for (const [params, wire, expected] of cases) {
assert.equal(params.toString(), wire);
assert.equal(new URLSearchParams(wire).get('token'), expected);
}
const url = new URL('https://example.test/search?page=2');
url.searchParams.set('token', 'a+b c');
assert.equal(url.search, '?page=2&token=a%2Bb+c');
assert.equal(new URL(url.href).searchParams.get('token'), 'a+b c');
console.log('Six round-trip cases passed');

The fixture checks Node.js URL handling without making a network request. If the URL is correct but the application still sees a different value, run the same input through the actual receiving framework and check for an extra decoding step.

Use set() when the key should have one value. Use append() when adding another value for the same key is intentional, and read it back with getAll(). See how to preserve repeated query values when handing the parsed result to application code.

References:

URLSearchParams Turns Plus Signs into Spaces: Preserve Literal +
https://laplusda.com/en/posts/urlsearchparams-plus-sign-space/
Author
Zero
Published at
2026-11-10
License
CC BY-NC-SA 4.0