Encoding Arrays and Objects in Query Strings
After reading this you will be able to choose an encoding for multi-value and nested state in a URL, implement it so it round-trips exactly, and know which values will break each of the four common approaches.
← Back to Query String State Sync
Prerequisites
Core Concept
The query string is a flat list of key/value string pairs. Anything with structure has to be flattened into that shape and reconstructed on the way back, and the interesting question is which flattening to choose.
Repeated keys — ?tag=a&tag=b — are the standards-blessed form. URLSearchParams.getAll reads them natively, servers and frameworks understand them universally, and values may contain any character because each is percent-encoded independently. The cost is length: ten tags mean ten repetitions of the key.
A delimiter — ?tags=a,b,c — is compact and readable, and it is what most people reach for first. Its failure mode is exact: any value containing the delimiter breaks it. A category literally named “Shoes, Boots” round-trips as two values, and the bug surfaces months later when someone adds that category.
Bracket notation — ?filter[min]=10&filter[max]=99 — expresses nesting, and several server frameworks parse it automatically. It is not part of any specification, so behaviour differs between parsers, and deeply nested versions become unreadable quickly.
A compact encoding — base64url or a JSON blob in one parameter — handles arbitrary structure and is the only realistic option for genuinely nested state. It costs readability entirely: the URL becomes opaque, un-editable by hand, and impossible to reason about in an analytics report.
The rule that resolves most cases: use repeated keys for arrays of scalars, flat prefixed keys for small objects, and a compact encoding only when the state is genuinely a tree. Reach for a delimiter only when you control the value space completely — enum values, ids, ISO dates — and never for anything a human typed.
Implementation
// TypeScript 5.x — codecs for the three encodings worth using, no dependencies
/** Repeated keys — the default for arrays of scalars. */
export const repeatedKeys = {
write(params: URLSearchParams, key: string, values: readonly string[]): void {
// set() would collapse them to one, so delete then append.
params.delete(key);
for (const v of values) if (v !== "") params.append(key, v);
},
read(params: URLSearchParams, key: string): string[] {
return params.getAll(key).filter(Boolean);
},
};
/** Delimiter — ONLY for controlled value spaces (ids, enums, ISO dates). */
export function delimited(sep = ",") {
return {
write(params: URLSearchParams, key: string, values: readonly string[]): void {
const unsafe = values.find((v) => v.includes(sep));
if (unsafe !== undefined) {
// Fail loudly in development rather than silently splitting a value.
throw new Error(`value "${unsafe}" contains the delimiter "${sep}"`);
}
if (values.length === 0) params.delete(key);
else params.set(key, values.join(sep));
},
read(params: URLSearchParams, key: string): string[] {
return (params.get(key) ?? "").split(sep).filter(Boolean);
},
};
}
/** Flat prefixed keys — for small, fixed-shape objects. Readable and standard. */
export const prefixed = {
write(params: URLSearchParams, prefix: string, obj: Record<string, string | number>): void {
for (const [k, v] of Object.entries(obj)) params.set(`${prefix}_${k}`, String(v));
},
read(params: URLSearchParams, prefix: string, keys: readonly string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const k of keys) {
const v = params.get(`${prefix}_${k}`);
if (v !== null) out[k] = v;
}
return out;
},
};
When the state genuinely is a tree — a saved query builder, a nested boolean expression — a compact encoding is the honest choice. Use base64url so the value survives being a URL component without further escaping:
// TypeScript 5.x — base64url JSON, with a version byte and a size guard
const MAX_ENCODED = 1500; // stay well inside intermediary URL length limits
export function encodeBlob(value: unknown): string {
const json = JSON.stringify({ v: 1, d: value });
// TextEncoder → binary string → base64 → URL-safe alphabet.
const bytes = new TextEncoder().encode(json);
const binary = String.fromCharCode(...bytes);
const encoded = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
if (encoded.length > MAX_ENCODED) {
throw new Error(`encoded state is ${encoded.length} chars — store a reference instead`);
}
return encoded;
}
export function decodeBlob<T>(encoded: string | null): T | null {
if (!encoded) return null;
try {
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as { v: number; d: T };
// An old shared link with a shape we no longer understand: ignore, do not throw.
return parsed.v === 1 ? parsed.d : null;
} catch {
return null; // truncated, hand-edited, or from another application entirely
}
}
Note what the decoder does with bad input: it returns null rather than throwing. Every one of these values arrives from outside your application — a truncated link in a chat client, a URL someone edited by hand, a crawler probing patterns — and a decoder that throws turns a malformed parameter into a blank page.
Verification
// TypeScript 5.x — round-trip every hostile value you can think of
import { repeatedKeys, delimited, encodeBlob, decodeBlob } from "./codecs";
const hostile = ["Shoes, Boots", "50% off", "a/b", "emoji 🎧", " padded ", "", "ünïcodé"];
const params = new URLSearchParams();
repeatedKeys.write(params, "tag", hostile);
const back = repeatedKeys.read(params, "tag");
console.assert(JSON.stringify(back) === JSON.stringify(hostile.filter(Boolean)));
// The delimiter codec must REFUSE the comma value rather than splitting it.
let threw = false;
try { delimited(",").write(new URLSearchParams(), "tags", hostile); } catch { threw = true; }
console.assert(threw, "delimiter codec must reject values containing the delimiter");
// Blobs must survive a round trip and tolerate garbage.
const tree = { any: [{ of: ["arbitrary", { depth: 3 }] }] };
console.assert(JSON.stringify(decodeBlob(encodeBlob(tree))) === JSON.stringify(tree));
console.assert(decodeBlob("not-valid-base64!!") === null);
console.assert(decodeBlob(null) === null);
// @playwright/test v1.44 — the encoding must survive a real browser round trip
import { test, expect } from "@playwright/test";
test("a multi-value filter survives sharing", async ({ page, context }) => {
await page.goto("/products");
await page.getByLabel("Shoes, Boots").check();
await page.getByLabel("Sale").check();
const shared = await context.newPage();
await shared.goto(page.url());
await expect(shared.getByLabel("Shoes, Boots")).toBeChecked();
await expect(shared.getByLabel("Sale")).toBeChecked();
});
Gotchas
params.setdestroys repeated keys. It replaces every existing value for that key with one. Usedeletethenappend.URLSearchParamsencodes spaces as+. It decodes both+and%20, so browsers round-trip fine, but a strict server-side parser may not.btoathrows on non-Latin-1 input. Encoding throughTextEncoderfirst, as above, is what makes it safe for real text.- Standard base64 is not URL-safe.
+,/and=must be translated, or the value gets re-encoded and corrupted by intermediaries. - Order is not guaranteed to be preserved by every parser. If the order of an array is meaningful, say so explicitly in the encoding rather than relying on parameter order.
- Practical URL length limits are around 2,000 characters in intermediaries even where browsers allow more. State that outgrows that belongs behind a saved-view id.
- Never throw when decoding. Every value comes from outside; returning a default keeps a mangled link from becoming a blank page.
FAQ
Which encoding should I default to? Repeated keys for arrays, prefixed keys for small objects. Both are standard, readable, and safe for arbitrary values, which covers the overwhelming majority of real filter state.
Is qs-style bracket notation worth adopting? Only if your server framework already parses it and you value the automatic nesting. It is not specified anywhere, so two parsers can disagree about the same URL.
How do I keep a shared link short? Omit defaults, use short key names, and store large state behind a saved-view id rather than in the URL. A blob is compact for structure but grows quickly with content.
Can I put JSON directly in a parameter without base64? You can — percent-encoding handles it — but the result is far longer and much harder to read than the base64url form, and some tooling mangles the braces.
How do I evolve the shape later? Version it, as the blob codec does, and treat an unrecognised version as absent. That way a link shared a year ago degrades to defaults rather than crashing on a shape you no longer produce.
Related
- Query String State Sync — the codec contract these encodings plug into.
- Syncing Filters to the URL with URLSearchParams — the multi-value filters that need one of these encodings.
- Serialising Complex State into History Entries — where structured state goes when it should not be shareable.