Keeping app state in the URL (no backend)

Both Height Comparison and the Text Chord Player keep all of their state in the URL. There is no server, no database, and no login. Copy the address bar and you have shared the exact thing you were looking at; bookmark it and it comes back after a reload. This post is the how, and the parts that bite you.

Why the URL

For a small client-side tool the URL is a surprisingly complete state store:

  • It is already shareable and bookmarkable — no "share" feature to build.
  • It survives a reload and the back/forward buttons.
  • It costs nothing to host and never goes down.
  • The user can see and edit it, which is honest: there is no hidden state.

The price is that everything you put there is public, ASCII-ish, and length limited. More on that at the end.

Write with replaceState, not pushState

State changes on almost every keystroke. If each change pushed a history entry, one edit session would bury the user's previous page under fifty back-button presses. Use history.replaceState, which swaps the current entry in place:

history.replaceState(null, "", "?" + params.toString());

The tools call this on every input event. The browser history stays exactly one entry deep for the tool page, and the address bar is always current.

Reach for URLSearchParams first

The chord player has five independent scalar fields — chords, tempo, meter, pattern, volume — so it uses URLSearchParams directly and does nothing clever:

// write
const q = new URLSearchParams();
q.set("c", chords.trim());
q.set("t", String(tempo));
q.set("m", meter);
history.replaceState(null, "", location.pathname + "?" + q.toString());

// read
const q = new URLSearchParams(location.search);
const chords = q.has("c") ? q.get("c") : DEFAULT_CHORDS;

URLSearchParams handles the percent-encoding of the chord text (which contains spaces, |, #, newlines) for you. If your state is a handful of named values, stop here.

Packing a list into one parameter

Height Comparison is different: it holds an ordered list of people, each with a user-typed name, a height, and a colour. That does not map onto flat key/value pairs, so the whole list goes into one parameter with its own syntax:

?people=Alice:170:7c9eff,Bob:160:ffb86b

Commas separate people, colons separate fields. The moment a field is user-controlled text, you have a delimiter-collision problem: what if someone names a person A, B or 10:30?

The fix is to percent-encode each name yourself on the way out, and to parse the raw query string on the way in — splitting on literal , and : before decoding:

// write: encode each name, then join with the structural delimiters
const value = people
  .map((p) => `${encodeURIComponent(p.name)}:${p.height}:${p.color}`)
  .join(",");
history.replaceState(null, "", value ? `?people=${value}` : location.pathname);

// read: match the raw value, split, THEN decode each name
const raw = location.search.match(/[?&]people=([^&]*)/);
const people = (raw ? raw[1].split(",") : [])
  .map((entry) => {
    const [name, height, color] = entry.split(":");
    const h = parseFloat(height);
    if (!name || !isFinite(h) || h <= 0) return null;
    return { name: decodeURIComponent(name), height: h, color: normalizeColor(color) };
  })
  .filter(Boolean);

A literal comma or colon in a name is now %2C / %3A in the URL, so splitting on the bare characters can never mistake it for structure. Decoding happens once, per field, after the split.

Note what this code does not do: run the value through URLSearchParams. If you did, URLSearchParams would encode the % signs again and you would get %252C on the next round trip. Hand-rolled encoding and URLSearchParams do not compose — pick one per parameter.

Treat the URL as untrusted input

Anything reached by URL will eventually be hit with a truncated, hand-edited, or years-out-of-date version of itself. Validate every field as you read it, and degrade instead of throwing:

  • Numbers: parse, check isFinite, clamp to a sane range. The chord player clamps tempo to 30–300 and falls back to 90 if it is not a number.
  • Enums: check against a whitelist. meter and pattern are looked up in a map; anything else becomes the default.
  • Lists: validate per item and drop the bad ones (.filter(Boolean) above). If nothing survives, fall back to the built-in default set rather than showing an empty tool.

The goal is that no URL, however mangled, produces a broken screen — worst case you get the default state.

Removing a feature without breaking old links

The chord player used to have an instrument picker with its own i parameter. When it was removed, the read side simply stopped looking at i. Old shared links that still carry &i=2 keep working; the unknown parameter is ignored and everything else loads normally.

This is the nice property of URL state: readers and writers are decoupled in time. As long as you only ever add parameters and ignore unknown ones, every link you have ever emitted stays valid.

When not to put state in the URL

  • Length. Keep the whole URL under ~2000 characters to be safe across browsers, servers, and chat apps that turn links into cards. A dozen people or a few bars of chords is fine; a whole document is not.
  • Secrets. URLs leak — into browser history, server logs, Referer headers, and analytics. Never put anything private in one.
  • Big or binary state. If you are reaching for LZString or base64 blobs, consider localStorage for the working copy and keep the URL for sharing only.

For a tool whose entire state is "a few people" or "a chord progression and four knobs", though, the URL is the whole backend you need.