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.

Four encodings for the same structured value The same selection of three tags and a price range shown in four encodings: repeated keys, a comma delimiter, bracket notation, and a base64url blob. Each is annotated with its length, readability and the value that breaks it. tags: ["shoes", "boots", "sale"] · price: { min: 10, max: 99 } Repeated keys ?tag=shoes&tag=boots&tag=sale&min=10&max=99 standard · getAll() reads it · any character is safe · longest of the four Delimiter ?tags=shoes,boots,sale&min=10&max=99 breaks on any value containing a comma — e.g. a tag named "Shoes, Boots" Bracket notation ?tags[]=shoes&tags[]=boots&price[min]=10&price[max]=99 expresses nesting · not specified anywhere · parsers disagree on the details Compact blob ?s=eyJ0YWdzIjpbInNob2VzIiwiYm9vdHMiXX0 arbitrary structure · opaque to humans, analytics and support staff
Each encoding trades something: repeated keys trade length for safety, delimiters trade safety for brevity, blobs trade everything for expressiveness.

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.

Choosing an encoding by the shape of the value A decision path. An array of scalars uses repeated keys. A small flat object uses prefixed keys. A genuinely nested structure uses a versioned base64url blob. A delimiter is only acceptable when the value space is fully controlled. what shape is the value? array of scalars tags, ids, categories small flat object a range, a coordinate nested tree saved query, expression repeated keys ?tag=a&tag=b — safe for any character prefixed keys ?price_min=10&price_max=99 — still readable versioned base64url blob opaque, but the only option that scales to trees
Match the encoding to the shape: only genuinely nested state justifies giving up a readable URL.

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.set destroys repeated keys. It replaces every existing value for that key with one. Use delete then append.
  • URLSearchParams encodes spaces as +. It decodes both + and %20, so browsers round-trip fine, but a strict server-side parser may not.
  • btoa throws on non-Latin-1 input. Encoding through TextEncoder first, 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.
Hostile values tested against two encodings Five awkward values checked against a delimiter-based encoding and against repeated keys. Only the delimiter encoding fails, and it fails on the two most ordinary values: one containing the delimiter and an empty one. Which values break which encoding breaks a delimiter? breaks repeated keys? a value containing a comma yes — splits into two no a value containing a slash no no an empty value yes — vanishes on filter no, but decide its meaning a value containing an ampersand no — it is encoded no a 2,000-character value length, not correctness length, not correctness
Repeated keys survive every value in this table; the delimiter fails on the two most ordinary ones.

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.