npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mongez/query-string

v1.2.3

Published

Tiny query-string parse/serialize with nested-object and array support, default-export API, plus a browser helper for reading and replacing the current URL.

Downloads

661

Readme

@mongez/query-string

Tiny query-string parse/serialize with nested objects, [] arrays, numeric coercion, and browser helpers for window.location — one default export, zero dependencies.

npm license bundle size downloads


Why @mongez/query-string?

Native URLSearchParams is verbose, string-only on output, and has no concept of nested objects or [] arrays — every filter handler ends up wrapping it in helpers. qs (npm) does nesting but ships ~10kB and uses different bracket semantics. query-string (npm) is close in spirit but flattens arrays by repeated keys (tag=a&tag=b) rather than the [] convention most server frameworks expect. @mongez/query-string is the smallest layer that does the four things a filter URL actually needs: parse, serialize, read the current URL, and rewrite it without a reload — with tags[]=a&tags[]=b arrays, user[name]=alice nesting, and "42"42 numeric coercion baked in.

import queryString from "@mongez/query-string";

queryString.parse("?page=2&tags[]=a&tags[]=b&user[name]=alice");
// → { page: 2, tags: ["a", "b"], user: { name: "alice" } }

queryString.toQueryString({ page: 2, tags: ["a", "b"] });
// → "page=2&tags[]=a&tags[]=b"

queryString.update({ tag: "books", page: 1 });
// window.location.search becomes "?tag=books&page=1" — no reload

Features

| Feature | Description | |---|---| | Numeric coercion | "42" parses to 42, "3.14" to 3.14, "-5" to -5. Strict numeric forms only — "true", "NaN", "Infinity" stay as strings. | | [] arrays | tags[]=a&tags[]=b parses to { tags: ["a", "b"] }; the serializer emits the same shape from { tags: [...] }. | | Bracket nesting | user[name]=alice&user[age]=30{ user: { name: "alice", age: 30 } }. Any depth supported. | | Percent-encoded output | Values containing &, =, spaces, and other reserved characters are encoded via encodeURIComponent on serialize. | | null round-trips | { v: null } serializes as v=null (no key drop) — pair with a parser-side decoder if you need to recover the value. | | Browser helpers | all / get / toString / update read and rewrite window.location.search via history.replaceState. | | Default-export object | One import, no named-export juggling. Destructure if you prefer. | | Zero dependencies | No runtime or peer deps. One source file plus a parser module. |


Installation

npm install @mongez/query-string
yarn add @mongez/query-string
pnpm add @mongez/query-string

Quick start

import queryString from "@mongez/query-string";

// 1. Parse a query string into a typed object.
queryString.parse("?page=2&tags[]=a&tags[]=b&user[name]=alice");
// → { page: 2, tags: ["a", "b"], user: { name: "alice" } }

// 2. Serialize an object back into a query string.
queryString.toQueryString({ page: 2, tags: ["a", "b"] });
// → "page=2&tags[]=a&tags[]=b"

// 3. Read from the current URL (browser only).
queryString.all();             // window.location.search → object
queryString.get("page", 1);    // single key with fallback
queryString.toString();        // search string without leading "?"

// 4. Write to the current URL without a page reload (browser only).
queryString.update({ page: 3 });

Every method is reachable off the default export. Destructure if you like:

const { parse, toQueryString, get, update } = queryString;

Browser vs anywhere. parse and toQueryString are pure data functions — they run in Node, workers, or any non-browser runtime. all / get / toString / update reference window.location and window.history and will throw outside the browser. Guard with typeof window !== "undefined" if the same module loads on both sides.


Parsing — queryString.parse(text) and queryString.all(text?)

parse accepts any string (with or without a leading ?) and returns a Record<string, any>. all is the same function but defaults to window.location.search when called with no argument.

queryString.parse("foo=bar");        // { foo: "bar" }
queryString.parse("?foo=bar");       // { foo: "bar" }   — leading "?" stripped
queryString.parse("a=1&b=2");        // { a: 1, b: 2 }
queryString.parse("");               // {}
queryString.parse("?");              // {}

Numeric coercion

Values matching !isNaN(v - parseFloat(v)) become numbers; everything else stays a string:

queryString.parse("age=42");         // { age: 42 }
queryString.parse("pi=3.14");        // { pi: 3.14 }
queryString.parse("neg=-5");         // { neg: -5 }
queryString.parse("zip=007");        // { zip: 7 }       — leading zeros collapse via Number()
queryString.parse("x=NaN");          // { x: "NaN" }     — literal string
queryString.parse("a=true");         // { a: "true" }    — booleans NOT coerced

Preserve number-as-string transports under a different shape. If "007" matters (zip codes, version strings), keep them under a key the consumer parses specially (zip-007) or store them server-side as quoted JSON in a single value.

URL decoding

Non-numeric values pass through decodeURIComponent:

queryString.parse("greeting=hello%20world");   // { greeting: "hello world" }
queryString.parse("path=%2Fhome%2Fuser");      // { path: "/home/user" }
queryString.parse("q=a+b");                    // { q: "a+b" }   — "+" is literal, NOT a space

Arrays — key[]=value

A key suffixed with [] collects repeated occurrences into an array. Each element is numeric-coerced individually:

queryString.parse("tags[]=a&tags[]=b&tags[]=c");   // { tags: ["a", "b", "c"] }
queryString.parse("ids[]=1&ids[]=2&ids[]=3");      // { ids: [1, 2, 3] }
queryString.parse("vals[]=1&vals[]=two&vals[]=3"); // { vals: [1, "two", 3] }

Nested objects — parent[child]=value

Bracket syntax expresses arbitrary nesting depth:

queryString.parse("user[name]=alice&user[age]=30");
// → { user: { name: "alice", age: 30 } }

queryString.parse("a[b][c]=1");
// → { a: { b: { c: 1 } } }

queryString.get(key, defaultValue?)

Reads one key from queryString.all() with a fallback:

// On URL: /products?page=2
queryString.get("page");              // 2
queryString.get("missing");           // null   (default default)
queryString.get("missing", 1);        // 1
queryString.get("missing", { x: 1 }); // { x: 1 }

get uses truthy semantics. The fallback also fires for 0, "", false, and null parsed values. For a strict "is this key present?" check, use key in queryString.all() instead.


Serializing — queryString.toQueryString(params)

Pass an object — get a query string with [] arrays, [parent][child] nesting, and encodeURIComponent-escaped values. Pass a string — it's returned verbatim (which is what makes queryString.update("foo=bar") work).

queryString.toQueryString({ foo: "bar" });                // "foo=bar"
queryString.toQueryString({ a: 1, b: 2 });                // "a=1&b=2"
queryString.toQueryString({});                            // ""

queryString.toQueryString({ tags: ["a", "b"] });          // "tags[]=a&tags[]=b"
queryString.toQueryString({ user: { name: "alice" } });   // "user[name]=alice"
queryString.toQueryString({ a: { b: { c: 1 } } });        // "a[b][c]=1"

queryString.toQueryString("already=encoded");             // "already=encoded"   — strings pass through

Percent-encoding

Values containing reserved characters are encoded so the round-trip back through parse is unambiguous:

queryString.toQueryString({ q: "a&b" });           // "q=a%26b"
queryString.toQueryString({ q: "a=b" });           // "q=a%3Db"
queryString.toQueryString({ q: "hello world" });   // "q=hello%20world"

Primitives

queryString.toQueryString({ on: true });           // "on=true"
queryString.toQueryString({ on: false });          // "on=false"
queryString.toQueryString({ v: null });            // "v=null"        — emitted as the literal string
queryString.toQueryString({ v: undefined });       // "v=undefined"   — same
queryString.toQueryString({ tags: [] });           // ""              — empty array drops the key

null and undefined round-trip as strings, not values. parse reads "null" and "undefined" back as those literal strings. If you need to distinguish "missing" from "explicitly null" in a URL-backed state, omit the key entirely for missing and pick a sentinel ("__null__") for explicit null.

Round-tripping

parse(toQueryString(obj)) is structurally equal to obj for safe shapes — flat keys, nested objects, arrays of strings or numbers:

const obj = { tag: "books", page: 2, ids: [1, 2, 3] };
queryString.parse(queryString.toQueryString(obj));
// → { tag: "books", page: 2, ids: [1, 2, 3] }

Numeric-looking strings coerce on the way back — { n: "42" } serializes as n=42, which parses to { n: 42 }. The asymmetry is intentional: the URL is a stringly-typed transport; the parser picks the most useful type.


Browser helpers

These four require window and document — client-side only.

queryString.all(searchParams?)

Same as parse, but defaults to window.location.search:

// On URL: /products?tag=books&page=2
queryString.all();           // { tag: "books", page: 2 }
queryString.all("?x=1");     // { x: 1 }   — explicit argument wins

queryString.toString()

Current window.location.search with the leading ? stripped. Returns "" when there's no query:

// On URL: /products?tag=books&page=2
queryString.toString();      // "tag=books&page=2"

queryString.update(params)

Replaces the URL's query via history.replaceState, keeping the pathname intact. Accepts an object or a pre-built string. An empty object/string clears the query:

queryString.update({ tag: "books", page: 3 });
// URL becomes /products?tag=books&page=3 — no reload, no new history entry

queryString.update("page=3&sort=asc");     // verbatim
queryString.update({});                    // clears the query

update does NOT fire popstate. history.replaceState is silent — listeners on popstate (browser routers, store subscribers) won't react. If you mirror URL state into another store, subscribe to your store rather than popstate. For a real history entry that does fire navigation, use history.pushState directly with queryString.toQueryString(params).


Recipes

Build a paginated filter URL

The list-page classic — tag, sort, and page number all live in the URL so a refresh restores the view and the URL is shareable:

import queryString from "@mongez/query-string";

type Filters = {
  tag?: string;
  sort?: "price-asc" | "price-desc" | "newest";
  page?: number;
};

function readFilters(): Filters {
  return queryString.all() as Filters;
}

function writeFilters(next: Filters) {
  queryString.update(next as Record<string, any>);
}

// User picks a tag — reset to page 1 so the new filter starts fresh.
function applyTag(tag: string) {
  const current = readFilters();
  writeFilters({ ...current, tag, page: 1 });
}

// User clicks "next page".
function goToPage(n: number) {
  const current = readFilters();
  writeFilters({ ...current, page: n });
}

Round-trip filters from URL to state and back

When a list-page store needs to hydrate from the URL on mount and write back on changes, the URL is the source of truth on first paint and the store wins after:

import queryString from "@mongez/query-string";

const store = {
  filters: {} as Record<string, any>,
  setFilters(next: Record<string, any>) {
    this.filters = next;
    queryString.update(next);              // mirror into URL
  },
};

// Hydrate at mount — call once.
function hydrateFromUrl() {
  store.filters = queryString.all();
}

// Catch real back/forward navigation. `update` doesn't fire `popstate`,
// so this only re-reads when the user uses the browser buttons.
window.addEventListener("popstate", hydrateFromUrl);
window.addEventListener("DOMContentLoaded", hydrateFromUrl);

Toggle a multi-select facet

Each tag toggles in and out of the URL's tags[] array. The serializer emits one entry per array element:

function getSelectedTags(): string[] {
  const tags = queryString.get("tags") as string[] | string | null;
  if (tags == null) return [];
  return Array.isArray(tags) ? tags : [tags];   // a single tag parses as a 1-element array
}

function toggleTag(tag: string) {
  const current = getSelectedTags();
  const next = current.includes(tag)
    ? current.filter(t => t !== tag)
    : [...current, tag];
  queryString.update({ ...queryString.all(), tags: next });
}

// "/list?tags[]=books" → toggleTag("fiction") → "/list?tags[]=books&tags[]=fiction"

Push a history entry instead of replacing

update is hard-coded to replaceState so filter changes don't pollute the back-stack. When a navigation should be a real entry (e.g. moving to a saved view), build the URL by hand and call pushState:

function navigateToSavedView(name: string, params: Record<string, any>) {
  const qs = queryString.toQueryString(params);
  const url = `${location.pathname}${qs ? "?" + qs : ""}`;
  history.pushState({ view: name }, "", url);
  window.dispatchEvent(new PopStateEvent("popstate"));
}

The synthetic popstate lets routers and subscribers react as if the user navigated. Use sparingly — it's a workaround, not part of the package's contract.

Parse a server request URL

parse doesn't touch window, so it works for SSR, route handlers, and tests:

import queryString from "@mongez/query-string";

function getFiltersFromRequest(reqUrl: string): Record<string, any> {
  const queryIndex = reqUrl.indexOf("?");
  if (queryIndex < 0) return {};
  return queryString.parse(reqUrl.substring(queryIndex));
}

getFiltersFromRequest("/api/products?tag=books&page=2");
// → { tag: "books", page: 2 }

Do NOT call all / get / toString / update on the server — they reach for window.location and window.history and will throw.


TypeScript

The default export is a single object with seven methods, all internally typed:

import queryString from "@mongez/query-string";

const filters = queryString.parse("?tag=books&page=2");
// filters: Record<string, any>

Values come back as any because URL parsing is inherently dynamic — coerce or validate at the call site for stronger guarantees (e.g. zod on the result of parse).

The two internal parsers are also reachable for callers that want to bypass the facade:

import { toObjectParser, toStringParser } from "@mongez/query-string/src/query-string-parsers";

These are implementation details, not part of the stable surface — prefer the default export.


Related packages

| Package | Use when you need | |---|---| | @mongez/concat-route | URL and path building — join, normalize, slugify segments. Pairs naturally with queryString.toQueryString to compose full URLs. | | @mongez/react-router | Router primitives for React apps. Drop queryString into route handlers for filter-driven views. | | @mongez/cache | Browser cache layer — pair with a URL hydrator when the URL is too short to hold the full filter state. |


Further reading

  • llms-full.txt — exhaustive single-file API surface for tool-assisted development.
  • CHANGELOG.md — release notes and documented quirks.
  • skills/ — per-topic deep dives (parse, serialize, recipes, overview).

License

MIT — see LICENSE.