Node ships two different ways to work with query strings — the legacy querystring module and the WHATWG URL/URLSearchParams globals shared with browsers — and they don't behave identically. Mixing them up without knowing where they diverge is where most Node-specific encoding surprises come from. The general encoding rules themselves are the same as anywhere else — see URL Encoding Explained for those.
Two APIs, not one
node:querystring— Node's original, Node-specific module. Still available, not formally deprecated, but Node's own documentation steers new code toward the WHATWG API below.URL/URLSearchParams— global in Node since v10, norequireneeded, and behaviorally identical to what runs in a browser. This is what new Node code should reach for, partly so query-string handling matches whatever's running client-side.
The surprise: they encode spaces differently
Given the same data, querystring.stringify and URLSearchParams.toString() produce different output for a space — one uses %20, the other +:
import querystring from "node:querystring";
querystring.stringify({ q: "hello world" });
// "q=hello%20world"
const params = new URLSearchParams({ q: "hello world" });
params.toString();
// "q=hello+world"Both are valid — +-for-space is the older application/x-www-form-urlencoded convention that URLSearchParams follows on purpose (see URL Encoding Explained for why that convention exists), while querystring.stringify escapes a space the same way encodeURIComponent does. Both decode back to the same value on the receiving end. The only place this actually bites is a diff, a test snapshot, or a cache key built from one API's output being compared against the other's — the strings won't match character-for-character even though they mean the same thing.
Repeated keys: querystring.parse groups them for you
This is the more useful difference, and it goes the opposite way from what you might expect — the legacy module is the one that's more convenient here:
querystring.parse("tag=js&tag=css");
// [Object: null prototype] { tag: [ 'js', 'css' ] } — already an array
new URLSearchParams("tag=js&tag=css").get("tag");
// "js" — only the first value; use .getAll("tag") for bothquerystring.parse groups every repeated key into an array automatically, with no separate method to remember. URLSearchParams.get() silently returns only the first match — reaching for .get() on a key that might repeat is the same footgun covered in How Query Parameters Work, and it applies just as much server-side in Node as it does in the browser.
The character-escaping itself matches
Aside from the space handling above, querystring.escape and encodeURIComponent encode the same set of characters the same way — there's no hidden difference in which characters get escaped between the two modules, just in how a literal space is represented in the output.
Building a request URL to call another service
For an outbound fetch call from server code, build the URL the same way you'd build one client-side — construct a URL, set parameters through searchParams, and let it handle the encoding rather than concatenating a template string:
const url = new URL("https://api.example.com/search");
url.searchParams.set("q", userInput);
url.searchParams.append("tag", "js");
url.searchParams.append("tag", "css");
const response = await fetch(url);
// GET https://api.example.com/search?q=...&tag=js&tag=cssThis sidesteps both gotchas above — searchParams handles the escaping in one place, and .append() for a repeated key means the receiving end's own parser decides how it wants to read duplicates back.
Try it yourself
Build a query string from key/value pairs or a JSON object with Query Parameter Builder, or break an existing one down — repeated keys grouped as arrays — with Query String Parser. To assemble a full request URL, see URL Builder. All run entirely in your browser.