query-string
query-string turns the text after the ? in a URL into a plain JavaScript object and back again. It is four core functions: parse, stringify, parseUrl, and stringifyUrl, plus pick and exclude for filtering parameters out of a full URL. The reason people install it instead of using the built-in URLSearchParams is the option surface: six different array encodings (repeated keys, foo[], foo[0], comma-separated, custom separator, colon-list), automatic coercion of numbers and booleans, a per-key types schema, sorted output so two equivalent objects always produce the same string, and skipNull plus skipEmptyString for dropping dead parameters. It deliberately does not support nested objects, because nesting is not specified anywhere and every library does it differently. Note the hyphen: the unrelated querystring package is a different, deprecated thing.
The right pick when a backend forces a specific array encoding or you need typed, deterministic query strings; for anything simpler, URLSearchParams already ships in your runtime. Check that your build can handle a pure-ESM dependency before you commit.
Use it if
- You need array parameters in a specific wire format that URLSearchParams cannot produce, such as foo[]=1&foo[]=2 or foo=1,2,3, and the backend will not accept anything else
- You want parse to hand back typed values instead of strings, either globally with parseNumbers and parseBooleans or per key with the types option so an id like 01234 stays a string while age becomes a number
- You want deterministic query strings for cache keys, snapshot tests, or URL deduplication: stringify sorts keys by default, so {b:1,a:2} and {a:2,b:1} produce the same output
- You are editing URLs rather than just reading them: parseUrl, stringifyUrl, pick, and exclude keep the path and the fragment intact while you change parameters
- You want null and empty-string parameters dropped from the output without writing the filtering loop yourself
- You only read a couple of parameters. URLSearchParams is built into every browser and Node, costs zero bytes, and the README itself points you there first
- You are stuck on CommonJS. Versions 8 and up are pure ESM, so require('query-string') throws; the last CJS release is 7.1.3 and it will not get fixes
- You need nested objects like a[b][c]=1. This package refuses to support nesting on purpose, so you either encode a JSON string into one parameter or switch to qs
- Your target runtime is older than Node 18 or a browser older than the current Chrome, Firefox, and Safari; that is the stated support window and nothing else is tested
- You are trimming your dependency tree. At roughly 2.8 KB gzipped the code is small, but it drags in three more packages (decode-uri-component, filter-obj, split-on-first) for behavior most pages never touch, and each one is another single-maintainer link in your supply chain
- You want a maintained option queue. The repo has 1 open issue (2 counting PRs), which sounds great until you file a feature request: this is a finished library, not an evolving one
Setup reality
npm install query-string and that is genuinely it: types ship in the package, there are no peer dependencies, and there is no config file. The pain is the module format. Since 8.0.0 this is pure ESM with no CJS entry, so a Jest suite running through babel-jest, a Node script without "type": "module", or an older Next.js server component will fail with ERR_REQUIRE_ESM. The usual fixes are pinning 7.1.3, adding it to transpilePackages, or using a dynamic import(). Second surprise: parse returns an object created with Object.create(null), so it has no prototype and calling result.hasOwnProperty('foo') throws. Use Object.hasOwn or the in operator. Third: array handling is opt-in per call, so if you stringify with arrayFormat: 'bracket' and parse without it, you get keys literally named foo[] back.
Patterns
Parse a query string into an objectparse-basic
import queryString from 'query-string';
const parsed = queryString.parse('?foo=bar&page=2');
//=> {foo: 'bar', page: '2'}
// leading ? or # is stripped for you
queryString.parse(location.search);
queryString.parse(location.hash);Values are strings unless you ask otherwise, and the returned object has a null prototype, so parsed.hasOwnProperty('foo') throws a TypeError. Use Object.hasOwn(parsed, 'foo') or 'foo' in parsed.
Build a query string from an objectstringify-basic
import queryString from 'query-string';
queryString.stringify({foo: 'unicorn', page: 2});
//=> 'foo=unicorn&page=2'
// keys are sorted by default; turn it off to keep insertion order
queryString.stringify({b: 1, a: 2}, {sort: false});
//=> 'b=1&a=2'Sorting is on by default, which is what makes output deterministic for cache keys. Only strings, numbers, bigints, booleans, null, undefined, and arrays of those are allowed; passing a plain object or a Date throws until you add a replacer.
Get numbers and booleans instead of stringstyped-values
queryString.parse('?age=20&admin=true', {
parseNumbers: true,
parseBooleans: true,
});
//=> {age: 20, admin: true}
// per-key control beats the global flags
queryString.parse('?phone=%2B380951234567&id=01234&age=20', {
parseNumbers: true,
types: {phone: 'string', id: 'string'},
});
//=> {phone: '+380951234567', id: '01234', age: 20}parseNumbers is the classic footgun: zip codes, order ids, and phone numbers silently lose leading + and leading zeros. Pin those keys to 'string' in types, which always wins over the global flags.
Match the array encoding your backend expectsarray-format
const opts = {arrayFormat: 'bracket'};
queryString.stringify({ids: [1, 2, 3]}, opts);
//=> 'ids[]=1&ids[]=2&ids[]=3'
queryString.parse('ids[]=1&ids[]=2', opts);
//=> {ids: ['1', '2']}
// other options: 'index', 'comma', 'separator', 'bracket-separator',
// 'colon-list-separator', and the default 'none' (repeated keys)
queryString.stringify({ids: [1, 2, 3]}, {arrayFormat: 'comma'});
//=> 'ids=1,2,3'arrayFormat must be identical on both parse and stringify. Mismatch it and you get a key literally named 'ids[]' holding a string. 'comma' also loses type information: [1, null, ''] round-trips back as ['1', '', ''].
Split a full URL into path and queryparse-url
queryString.parseUrl('https://example.com/search?q=printers&page=2');
//=> {url: 'https://example.com/search', query: {q: 'printers', page: '2'}}
queryString.parseUrl('https://example.com/docs?a=1#install', {
parseFragmentIdentifier: true,
});
//=> {url: 'https://example.com/docs', query: {a: '1'}, fragmentIdentifier: 'install'}The fragment is dropped unless you pass parseFragmentIdentifier: true, so a naive parseUrl then stringifyUrl round-trip quietly deletes the #anchor from your URL.
Merge parameters into an existing URLstringify-url
queryString.stringifyUrl({
url: 'https://example.com/search?q=old&keep=yes',
query: {q: 'new', page: 2},
});
//=> 'https://example.com/search?keep=yes&page=2&q=new'
queryString.stringifyUrl({
url: 'https://example.com/docs',
query: {tab: 'api'},
fragmentIdentifier: 'install',
});
//=> 'https://example.com/docs?tab=api#install'Keys in query override same-named keys already in url, and everything else survives. Output is sorted, so the merged URL will not preserve the original parameter order.
Whitelist or drop parameters on a URLpick-exclude
queryString.pick('https://example.com?utm_source=x&id=7#top', ['id']);
//=> 'https://example.com?id=7#top'
queryString.exclude(
'https://example.com?utm_source=x&utm_medium=y&id=7',
name => name.startsWith('utm_'),
);
//=> 'https://example.com?id=7'Both take either an array of keys or a (key, value) predicate, and both keep the fragment. This is the shortest way to strip tracking parameters before writing a canonical URL.
Drop empty filter values from a URLskip-empty
queryString.stringify(
{q: 'printers', brand: '', minPrice: null, page: 1},
{skipNull: true, skipEmptyString: true},
);
//=> 'page=1&q=printers'undefined values are always skipped; null and '' are kept unless you opt in. Without these two flags a cleared search form produces ?brand=&minPrice= and your backend has to treat empty strings as absent.
Serialize Dates and other non-primitivescustom-serialization
queryString.stringify(
{since: new Date('2026-01-15T10:30:00Z'), name: 'John'},
{
replacer: (key, value) =>
value instanceof Date ? value.toISOString() : value,
},
);
//=> 'name=John&since=2026-01-15T10%3A30%3A00.000Z'Without a replacer, passing a Date or any plain object throws rather than producing [object Object]. Returning undefined from the replacer removes that pair entirely, which doubles as a filter.
Carry nested data without nesting supportnested-objects
// this library will not encode a[b]=1; encode JSON instead
const qs = queryString.stringify({
view: 'grid',
filters: JSON.stringify({brand: 'canon', inStock: true}),
});
//=> 'filters=%7B%22brand%22%3A%22canon%22%2C%22inStock%22%3Atrue%7D&view=grid'
const back = queryString.parse(qs);
const filters = JSON.parse(back.filters);Nesting is refused by design because no spec defines it. If a backend genuinely requires a[b][c]=1, this is the wrong package and you want qs.
Use it from CommonJS without downgradingesm-in-cjs
// require('query-string') throws ERR_REQUIRE_ESM on v8+
async function buildUrl(params) {
const {default: queryString} = await import('query-string');
return queryString.stringify(params);
}
// Jest: let it through the transform instead
// transformIgnorePatterns: ['node_modules/(?!(query-string|decode-uri-component|split-on-first|filter-obj)/)']The transform pattern has to list the transitive dependencies too, not just query-string, or the failure just moves one level down. Pinning 7.1.3 also works but freezes you on an unmaintained line.
Keep a literal + in a parsed valueplus-sign-decoding
queryString.parse('?phone=+15551234567');
//=> {phone: ' 15551234567'} // + decoded as a space
// encode it properly when building the URL
queryString.stringify({phone: '+15551234567'});
//=> 'phone=%2B15551234567'
queryString.parse('?phone=%2B15551234567');
//=> {phone: '+15551234567'}In application/x-www-form-urlencoded a bare + means space, so this is correct behavior and the FAQ says so. Fix it at the producer by percent-encoding, not by post-processing the parsed value.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| qs | npm | You need nested objects, deep bracket syntax, or Express/Rails-compatible parsing, and you accept a much bigger surface plus depth-limit tuning |
| fast-querystring | npm | You parse and stringify flat query strings in a hot Node path and want raw speed over options |
| picoquery | npm | You want nesting support and a smaller footprint than qs, with an API close to this one |