bitpack turns your app's state into a short URL code, and back. The only configuration is a schema (a list of fields and their allowed values) that lives in your bundle as a plain constant. No backend, no database, no lookup table.
A URL that captures app state usually ends up looking like this:
/app?theme=dark&sort=top&page=437&compact=true
It works in a browser, but the moment that URL leaves the address bar it starts losing. Share cards truncate it. SMS and DM previews ellipsize. QR codes pay for every character. Plenty of consumer apps fix this by swapping the long readable URL for a short opaque token. You've seen the pattern on TikTok shares, generative-art permalinks, and game-result links. That's what bitpack produces:
/app?c=A5iT1NGqsJ
Ten characters, always. The codes are also opaque: similar states produce unrelated codes (page=0 and page=1 look nothing alike). That's on purpose.
One TypeScript file, no runtime dependencies. Define a schema, build a codec, pack and unpack:
import { createCodec, bitsFor, type Schema } from "./bitpack";
// bitsFor(N) gives the bits needed to fit N values exactly.
// bitsFor(N, headroom) reserves room for `headroom` more values later.
const SCHEMA: Schema = [
{ key: "theme", bits: bitsFor(4, 4), values: ["light", "dark", "auto", "hc"] }, // 4 now + room for 4 more
{ key: "sort", bits: bitsFor(3, 1), values: ["new", "top", "hot"] }, // 3 now + room for 1
{ key: "page", bits: bitsFor(1000), min: 0, max: 999 }, // exact fit
{ key: "compact", bits: bitsFor(2), values: [false, true] }, // exact fit
// Headroom. Carve from here when you add fields; see "Adding a new option" below.
{ key: "_reserved", bits: 36, reserved: true },
];
// Field bits sum to 52. Plus checksumBits: 6 = totalBits: 58.
// The codec validates this sum at startup, so a wrong-shape schema
// throws immediately instead of silently producing bad codes.
export const codec = createCodec({
schema: SCHEMA,
totalBits: 58, // 10-char codes; freeze this once you ship
checksumBits: 6, // catches typo'd or truncated URLs
alphabet: "base62", // optional; "base64url" (default) also works but
// includes "-" and "_" in the alphabet
});
const code = codec.pack({
theme: "dark",
sort: "top",
page: 437,
compact: true,
});
// code === "A5iT1NGqsJ"
codec.unpack("A5iT1NGqsJ");
// { theme: "dark", sort: "top", page: 437, compact: true }totalBits: 58 and checksumBits: 6 are the recommended defaults. You get 10-character codes with room for about 4.5 quadrillion distinct states. The 6-bit checksum catches roughly 99% of corrupted or tampered codes.
How bits per field works: use bitsFor(N) for an enum with N values or bitsFor(max - min + 1) for an integer field, and bitsFor(N, headroom) when you want room to add more values later without violating the "never resize bits" rule. The math underneath is ceil(log2(N + headroom)): 2 values fit in 1 bit, 5 fit in 3 bits, 100 fit in 7 bits, 1000 fit in 10 bits. The codec throws at startup if you've picked too few bits for the values listed.
When you ship a new option, carve some _reserved bits into a new field. Old links keep working:
const SCHEMA: Schema = [
{ key: "theme", bits: bitsFor(4, 4), values: ["light", "dark", "auto", "hc"] },
{ key: "sort", bits: bitsFor(3, 1), values: ["new", "top", "hot"] },
{ key: "page", bits: bitsFor(1000), min: 0, max: 999 },
{ key: "compact", bits: bitsFor(2), values: [false, true] },
// New field, carved from _reserved. Put "false" first: it's the value
// older links (minted before monogram existed) will decode to.
{ key: "monogram", bits: bitsFor(2), values: [false, true] },
{ key: "_reserved", bits: 35, reserved: true }, // was 36, now 35 (monogram uses 1 bit)
];URLs minted before monogram existed still decode; the field comes back as false. New URLs encode the user's choice.
This works as long as you follow four rules:
- Once URLs have shipped, never change
totalBitsorchecksumBits. Every code generated so far has these values baked in; change them and old URLs decode to garbage. - Never reorder fields, and never change a field's
bits. Field positions are the contract. - When you add values to an existing enum, only append to the end. Old codes store the array index, not the value name. If you shuffle
['light', 'dark']to['dark', 'light'], every old link that saidtheme: 'light'now reads astheme: 'dark'. - When you add a new field, the first value in
valuesis what older links (which were minted without this field) will decode to. Pick accordingly. Addingfinish: ['matte', 'gloss']makes every old link read as matte; if you wanted them to read as gloss, list gloss first.
bitpack isn't encryption. Anyone who can read your JS bundle can decode a code back to the original state. Treat the encoded state as public.
It's also not a signature. The checksum catches accidental corruption (typos, truncated URLs, transmission errors) but not someone deliberately forging a code. If you need either property (secrecy or unforgeability), the codec alone isn't enough. Pair it with a server-side check.
Recommended defaults (10-char codes) vs URLSearchParams on the same four-field state:
| Metric | bitpack | URLSearchParams |
|---|---|---|
| Code length | 10 chars (constant) | ~38 chars (varies with state) |
| Catches typos | ~99% rejected | always accepted |
| Encode | ~0.5 µs | ~0.2 µs |
| Decode | ~0.7 µs | ~0.2 µs |
About 3.7x shorter and always fixed length. Per-call time is sub-microsecond either way. URLSearchParams is faster in raw throughput, but the difference is invisible in any UI code path. (Numbers are for the base62 alphabet; base64url unpacks a hair faster at ~0.5 µs.)
The repo includes a tiny browser demo (dev/demo/index.html) that wires a form to ?c=…. It uses the same schema as the example above, so setting theme=dark, sort=top, page=437, compact=true produces the same A5iT1NGqsJ code. The URL updates as you change options, and reloading the page restores the form from the URL.
git clone https://github.com/shabier/bitpack.git
cd bitpack
make test # 42 tests, all green
make serve # serves at http://localhost:5173Bun is the recommended dev tool (one binary, no node_modules). If you don't want Bun, bitpack.ts and dev/bitpack.test.ts are plain TypeScript with no runtime dependencies. Copy them into a project that has vitest, Jest, node --test, Deno, or anything else that reads TypeScript, and they run.
bitpack is a single file. Grab bitpack.ts from the repo:
https://github.com/shabier/bitpack/blob/main/bitpack.ts
Copy it into your project, import it from wherever you put it. About 4 KB gzipped. No runtime dependencies, no npm install, no transitive packages.
- Bounded fields only. Every field needs a known set of values or a numeric range. Free-text inputs, search queries, and free-form filter chips don't fit; they grow without a stable contract. If the state is more like "whatever the user types" than "one of a few choices," reach for
URLSearchParamsinstead. - Schema is the contract. Any code that produces or consumes these URLs needs the same schema. That works when it's all your own bundle; less well when third parties need to construct or parse the URLs themselves.
- Schema evolution comes with rules. The four rules above (and the
_reservedpool) are what keep old bookmarks decodable across schema versions. If your URLs are ephemeral (single session, throwaway), bitpack is overkill. - Treat the encoded state as public. See "What it isn't."
- State isn't statically typed against the schema.
codec.pack({ theme: "darkk" })typechecks but throws at runtime. The schema is runtime data, so the compiler can't catch out-of-domain values at the call site. - No clean way to widen an existing field. If
page: 0..999needs to becomepage: 0..9999, add a newpage2field plus a 1-bitpageVersionflag (both carved from_reserved) and branch on the flag at decode:state.pageVersion ? state.page2 : state.page. Old links defaultpageVersiontofalseand readpage; new clients flip the flag and readpage2. The simplerstate.page2 || state.pageshortcut is brittle when the user pickspage === 0, and??doesn't fix it because decoded fields are nevernull/undefined. See docs/design.md for the longer story.
docs/design.md— design rationale: why bit-budgeted, the migration model, the integrity stack, the widen-field pattern.docs/benchmarks.md— what was measured, how, and how to reproduce.docs/what-didnt-work.md— dead ends from building this, with what replaced each one.docs/story.md— the narrative: how the design landed where it is.bitpack.ts— the codec, with a docblock that covers the design in more detail.dev/bitpack.test.ts— 42 tests covering bijection, round-trip, tampering rejection, forward migration, the documented widen-field pattern, and fixtures pinned to the README examples.
MIT. See LICENSE for the full text.