mrkeyoor.com_
Thu 06 Aug 07:39 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5parse and stringify have looked the same since v5 and old option names still work, but three majors in four years each broke something structural: v8 went ESM-only, v9 raised the floor to Node 18, and both required build changes rather than code changes.
Docs4/5One README that documents every option with a runnable example and a printed result, plus explicit sections on nesting, falsy values, and the plus-sign FAQ; there is no searchable docs site, so finding an option means Ctrl+F on a long page.
Maintenance4/59.4.1 shipped 28 June 2026, the repo was pushed the same day, and the backlog is 1 open issue (2 counting PRs); it is one maintainer treating the library as feature-complete, so expect fixes and dependency bumps rather than new capability.
Ecosystem5/5About 23.4M weekly downloads and 6.9k stars, and it is the query layer inside a long tail of routers, SDKs, and admin templates, so it is usually already in your lockfile whether you added it or not.

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
Skip it if

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

PackageRegistryPick it when
qsnpmYou need nested objects, deep bracket syntax, or Express/Rails-compatible parsing, and you accept a much bigger surface plus depth-limit tuning
fast-querystringnpmYou parse and stringify flat query strings in a hot Node path and want raw speed over options
picoquerynpmYou want nesting support and a smaller footprint than qs, with an API close to this one