Tiny query-string parse/serialize with nested objects, [] arrays, numeric coercion, and browser helpers for window.location — one default export, zero dependencies.
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| 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. |
npm install @mongez/query-stringyarn add @mongez/query-stringpnpm add @mongez/query-stringimport 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.
parseandtoQueryStringare pure data functions — they run in Node, workers, or any non-browser runtime.all/get/toString/updatereferencewindow.locationandwindow.historyand will throw outside the browser. Guard withtypeof window !== "undefined"if the same module loads on both sides.
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("?"); // {}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 coercedPreserve 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.
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 spaceA 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] }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 } } }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 }
getuses truthy semantics. The fallback also fires for0,"",false, andnullparsed values. For a strict "is this key present?" check, usekey in queryString.all()instead.
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 throughValues 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"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
nullandundefinedround-trip as strings, not values.parsereads"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.
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.
These four require window and document — client-side only.
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 winsCurrent 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"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
updatedoes NOT firepopstate.history.replaceStateis silent — listeners onpopstate(browser routers, store subscribers) won't react. If you mirror URL state into another store, subscribe to your store rather thanpopstate. For a real history entry that does fire navigation, usehistory.pushStatedirectly withqueryString.toQueryString(params).
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 });
}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);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"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 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.
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.
| 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. |
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).
MIT — see LICENSE.