Shareable Deep Links with Query Params

By the end of this guide you will be able to serialise application state into a query string, write it to the address bar without polluting the back stack, and rebuild that exact state when someone pastes the link into a fresh tab.

← Back to Deep Linking Implementation

A shareable deep link is only useful if it round-trips: the URL you copy must reconstruct the same filters, sort order, and pagination the original viewer saw. That contract sits squarely on top of History API & State Management, because the URL is the single source of truth a recipient receives — there is no in-memory state to inherit. Get the serialisation, encoding, and parsing symmetric and the link travels through email clients, chat apps, and bookmarks intact.

Prerequisites

Core Concept

A shareable deep link is a URL whose query string is a complete, encoded projection of the state needed to reconstruct a view. The link is “deep” because it points past the application’s entry route into a specific configuration; it is “shareable” because every byte a recipient needs lives in the string itself. Two pure functions define the contract: a serialiser that turns typed state into a query string, and a parser that turns the query string back into typed state. If parse(serialise(s)) does not equal s for every valid state, the link is broken.

The address bar is the boundary, so encoding matters more than it does for internal navigation. Spaces, ampersands, commas, and non-ASCII characters must be percent-encoded, which is exactly what URLSearchParams does for you. Reserve the History API decision for write time: use replaceState for incremental refinements like toggling a filter, and pushState only when a change is significant enough to deserve its own back-button stop — the same boundary you would draw when working with Dynamic Route Segments that carry meaning in the path.

There is one design decision worth settling before you write any code: which slice of state belongs in the URL at all. Anything a recipient must see to reproduce the view — active filters, sort order, the current page — is shareable and belongs in the query string. Anything purely incidental to one session — a tooltip’s open state, an unsubmitted form draft, the exact scroll offset — is ephemeral and should stay in component state or sessionStorage. Serialising ephemeral data bloats links, leaks irrelevant detail to whoever you share with, and couples your URL schema to UI internals that change often. Drawing that line up front keeps the serialiser small and the contract stable.

Implementation

The pair below is deliberately symmetric — serialise and parse are inverses, and a single schema describes what crosses the boundary.

// TypeScript 5.x — framework-agnostic, no dependencies
type SortOrder = "price_asc" | "price_desc" | "newest";

interface ViewState {
  category: string | null;
  sort: SortOrder;
  page: number;
  tags: string[]; // repeated key, not a JSON blob
}

const DEFAULTS: ViewState = { category: null, sort: "newest", page: 1, tags: [] };

function serialise(state: ViewState): string {
  const params = new URLSearchParams();
  // Omit values equal to the default so links stay short and stable
  if (state.category) params.set("category", state.category);
  if (state.sort !== DEFAULTS.sort) params.set("sort", state.sort);
  if (state.page !== DEFAULTS.page) params.set("page", String(state.page));
  // Repeat the key per tag — URLSearchParams percent-encodes each value
  for (const tag of state.tags) params.append("tag", tag);
  return params.toString(); // e.g. "category=shoes&sort=price_asc&tag=red&tag=wide"
}

function parse(search: string): ViewState {
  const params = new URLSearchParams(search);
  const sort = params.get("sort");
  const pageRaw = Number(params.get("page"));
  return {
    category: params.get("category"), // null when absent — matches DEFAULTS
    // Validate against the union; never trust the string from a shared link
    sort: sort === "price_asc" || sort === "price_desc" || sort === "newest"
      ? sort
      : DEFAULTS.sort,
    // Guard against NaN, zero, and negative pages from hand-edited URLs
    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULTS.page,
    tags: params.getAll("tag"), // [] when no tag keys are present
  };
}

Two details in serialise carry their weight. Omitting default values keeps the canonical link for an untouched view empty rather than a long string of redundant pairs, which makes links shorter and lets two equivalent states produce byte-identical URLs. Appending one key per tag, rather than joining tags into a single comma-delimited value, means each tag is independently percent-encoded and a recipient can hand-edit the link without learning a private delimiter convention. The mirror image of both choices lives in parse: absent keys resolve to the same defaults, and getAll collects the repeated keys back into an array.

Writing the link to the address bar is a separate concern from generating it. Build a full URL so the origin and path are preserved, then choose the History method by intent. Setting url.search overwrites only the query, leaving the path and any hash fragment untouched, which matters when the deep link also targets a specific route or anchor.

// TypeScript 5.x — browser History API
function applyState(state: ViewState, mode: "push" | "replace" = "replace"): void {
  const url = new URL(window.location.href);
  url.search = serialise(state); // overwrite query, keep path + hash
  const next = url.toString();
  // replaceState for refinements avoids a back-button stop per keystroke
  if (mode === "push") history.pushState(state, "", next);
  else history.replaceState(state, "", next);
}

Verification

Confirm the round-trip in DevTools by serialising a non-default state, parsing the result back, and asserting structural equality.

// TypeScript 5.x — paste into the DevTools console (types stripped)
const sample = { category: "shoes", sort: "price_asc", page: 3, tags: ["red", "wide"] };
const restored = parse(serialise(sample));
console.assert(
  JSON.stringify(restored) === JSON.stringify(sample),
  "round-trip mismatch",
  restored,
);
// Then: copy window.location.href into a new tab and confirm parse() drives render

For an end-to-end check, open the page, apply filters, copy the URL, and paste it into a clean private window. The view must render identically with no flash of default state, which proves parsing runs before first paint. Reload the tab and exercise the back/forward buttons; restored state should match what popstate event handling delivers from event.state, with the parsed query string as the fallback when that payload is null.

Gotchas

  • Arrays via repeated keys, not JSON. Stuffing JSON.stringify(state) into one parameter produces brittle, hard-to-edit URLs and double-encodes reserved characters. Use params.append per value and params.getAll to read them.
  • Defaults must be omitted symmetrically. If serialise drops a default value but parse does not restore the same default for an absent key, two equivalent states produce different links and break caching and deduplication.
  • Never trust a pasted query string. A shared or hand-edited link can carry an invalid sort, a negative page, or junk keys. Validate every field in parse and fall back to defaults, or a malformed link crashes the recipient’s first render.
  • replaceState does not fire popstate. Writing the URL during a filter change updates the bar silently; only back/forward navigation triggers restoration, so your parse-on-load path must be the authority, not the write path.

FAQ

Why do my shareable deep links lose query parameters on refresh? The state lives only in memory or local storage and is never read back from the URL on load. Parse window.location.search during initialisation and use it to drive the first render, and call replaceState whenever shareable state changes so the bar always reflects the view.

Should I use pushState or replaceState for query param updates? Use replaceState for incremental refinements like toggling a filter, changing a sort, or moving a slider, so each tweak does not add a back-button stop. Reserve pushState for changes significant enough to be their own history entry, such as opening a distinct record or switching a major view.

How do I encode arrays and nested objects in a query string? Encode arrays as repeated keys with URLSearchParams.append and read them back with getAll; this percent-encodes each value automatically and stays human-editable. Flatten nested objects into dot-notation keys rather than embedding a JSON blob, which double-encodes reserved characters and produces fragile URLs.

What happens if someone edits the URL by hand before sharing it? Treat every incoming query string as untrusted input. Validate each field in your parser — check unions, guard numbers against NaN and negatives, and drop unknown keys — then fall back to defaults so a malformed link still renders a sane view instead of throwing.

Is there a length limit I should worry about for shared links? Keep query strings comfortably under about 2000 characters so they survive email clients, chat previews, and older crawlers. If state genuinely exceeds that, store the bulk in sessionStorage or behind a short token mapped server-side, and keep only an identifier in the URL.