DEV Community

tommy
tommy

Posted on

Share Tool State via URL Hash Alone -- Designing 'Recipe URLs'

Ever been in this situation?

"Hey, can you check this Base64-decoded result for me?"

You paste a Base64 string into Slack, tell your teammate "decode this and look at the output." They open a decoder, paste the string, hit decode. Three steps just to share a single transformation result.

What if sending a URL instantly reproduced the same tool in the same state?

https://base64.puremark.app/#d=SGVsbG8sIFdvcmxkIQ%3D%3D&m=encode
Enter fullscreen mode Exit fullscreen mode

Open that link and a Base64 tool launches in Encode mode with "Hello, World!" already entered and the encoded result displayed.

I call this pattern a "Recipe URL." This article walks through the design decisions and implementation.

Try it yourself -- go to PureMark Base64, encode something, and hit "Share." A Recipe URL is generated and copied to your clipboard.

What You'll Learn

  • A design pattern for encoding tool state into URL hashes
  • How to Base64-encode Unicode strings in a URL-safe way
  • A serverless architecture for state sharing
  • Concrete implementation code in React + TypeScript

Prerequisites

  • Basic TypeScript / React knowledge
  • Understanding of URL structure (hash, search params)

Why URL Hash?

It's never sent to the server

The hash portion of a URL (everything after #) is not included in HTTP requests. This is defined in the RFC.

https://example.com/path?query=value#fragment
                                      ^^^^^^^^
                                      Never sent to the server
Enter fullscreen mode Exit fullscreen mode

This means user data never touches a server. Period.

For web tools, whether input data gets sent to a server is a critical privacy concern. With URL hashes, there's no data leakage path by design.

No URL shortener needed

A server-side approach (example.com/share/abc123) requires a backend with a database to store shared states. That means infrastructure to build and maintain.

The URL hash approach works on a fully static site. Cloudflare Pages or Vercel free tier -- no problem.

It's just a URL

A URL hash is part of a regular URL, so it works everywhere: Slack, Teams, email, GitHub issues. No special client required.

Recipe URL Design

Hash Parameter Structure

#d=<Base64-encoded data>&m=<mode>&o=<options>
Enter fullscreen mode Exit fullscreen mode
Parameter Content Required
d Input data (Base64-encoded) Yes
m Mode (encode, decode, format, etc.) Yes
o Tool-specific options (Base64-encoded JSON) No

We use URLSearchParams directly. No custom parser needed.

Size Limit

Browser and proxy URL length limits vary, but in practice 2,000 characters is a safe upper bound. If the hash exceeds this, show an error to the user.

const MAX_HASH_LENGTH = 2000;
Enter fullscreen mode Exit fullscreen mode

If you eventually need to handle larger data, you could compress with pako before Base64-encoding. But for now, simplicity wins.

Implementation

Unicode-Safe Base64 Encoding

btoa() only handles the Latin-1 range (0x00--0xFF). Pass a Unicode string and you get InvalidCharacterError:

btoa("Hello"); // Works fine
btoa("some unicode text"); // Might throw InvalidCharacterError
Enter fullscreen mode Exit fullscreen mode

The solution: convert to UTF-8 bytes via encodeURIComponent before passing to btoa:

/** Unicode-safe Base64 encode */
function toBase64(str: string): string {
  return btoa(
    encodeURIComponent(str).replace(
      /%([0-9A-F]{2})/g,
      (_, p1) => String.fromCharCode(parseInt(p1, 16))
    )
  );
}

/** Unicode-safe Base64 decode */
function fromBase64(b64: string): string {
  return decodeURIComponent(
    Array.from(atob(b64), (c) =>
      '%' + c.charCodeAt(0).toString(16).padStart(2, '0')
    ).join('')
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works:

Encode:
  "Hello, World!"
  -> encodeURIComponent -> "Hello%2C%20World!"
  -> convert %XX to bytes -> binary string
  -> btoa -> Base64 string

Decode:
  Base64 string
  -> atob -> binary string
  -> each byte to %XX format -> "Hello%2C%20World!"
  -> decodeURIComponent -> "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

You could also use TextEncoder / TextDecoder, but the approach above is shorter and has broader browser compatibility.

Encode / Decode Functions

export interface RecipeState {
  data: string;
  mode: string;
  options?: Record<string, unknown>;
}

export function encodeRecipeHash(
  data: string,
  mode: string,
  options?: Record<string, unknown>,
): string | null {
  const params = new URLSearchParams();
  params.set('d', toBase64(data));
  params.set('m', mode);
  if (options && Object.keys(options).length > 0) {
    params.set('o', toBase64(JSON.stringify(options)));
  }
  const hash = params.toString();
  if (hash.length > MAX_HASH_LENGTH) return null;
  return hash;
}

export function decodeRecipeHash(hash: string): RecipeState | null {
  try {
    const clean = hash.startsWith('#') ? hash.slice(1) : hash;
    if (!clean) return null;
    const params = new URLSearchParams(clean);
    const d = params.get('d');
    const m = params.get('m');
    if (!d || !m) return null;
    return {
      data: fromBase64(d),
      mode: m,
      options: params.has('o')
        ? JSON.parse(fromBase64(params.get('o')!))
        : undefined,
    };
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key design choices:

  • encodeRecipeHash returns null when exceeding the 2,000-character limit (caller shows an error)
  • decodeRecipeHash never throws -- it returns null for any malformed input (safe even if someone hand-edits the URL)

Generating and Copying the Share URL

export async function copyShareUrl(
  data: string,
  mode: string,
  options?: Record<string, unknown>,
): Promise<boolean> {
  const hash = encodeRecipeHash(data, mode, options);
  if (!hash) return false;
  const url = `${window.location.origin}${window.location.pathname}#${hash}`;
  try {
    await navigator.clipboard.writeText(url);
    return true;
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Using window.location.origin means it works seamlessly in both development (localhost:5173) and production (base64.puremark.app).

Using It in React Components

Restoring state on page load:

useEffect(() => {
  const recipe = getRecipeFromUrl();
  if (recipe) {
    setInput(recipe.data);
    setMode(recipe.mode as Mode);
    trackShareUrlOpen('base64');
    restoredFromRecipe.current = true;
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

The restoredFromRecipe flag prevents the clipboard auto-read feature (zero-click) from overwriting data that was shared via a Recipe URL.

Share button:

function ShareButton({ data, mode }: ShareButtonProps) {
  const [copied, setCopied] = useState(false);

  const handleShare = async () => {
    const ok = await copyShareUrl(data, mode);
    if (ok) {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    }
  };

  return (
    <button onClick={handleShare}>
      {copied ? 'Copied!' : 'Share'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

See this in action -- go to PureMark JSON Formatter, format some JSON, and hit the "Share" button. A Recipe URL is generated and ready to send.

Cross-Tool Linking

Here's where Recipe URLs get really interesting: passing data between tools.

For example, when the Base64 tool decodes something and detects it's JSON:

const openInJsonFormatter = () => {
  const hash = encodeRecipeHash(output, 'format');
  if (!hash) return;
  window.open(`https://json.puremark.app/#${hash}`, 'puremark-json');
};
Enter fullscreen mode Exit fullscreen mode

Click "Open in JSON Formatter" and the JSON Formatter opens with the decoded result already formatted. No copy-paste needed.

Data flow:

Base64 Decoder (base64.puremark.app)
  -> Detects decoded output is JSON
  -> Shows "Open in JSON Formatter" button
  -> Click
  -> Opens json.puremark.app/#d=<JSON>&m=format
  -> JSON Formatter displays formatted result
Enter fullscreen mode Exit fullscreen mode

Data flows between tools on different subdomains, mediated only by a URL hash. No backend. No API.

Summary

  • Recipe URLs encode tool state into URL hashes, enabling serverless state sharing
  • encodeURIComponent + btoa safely handles Unicode strings in Base64
  • URLSearchParams handles parsing -- no custom parser needed
  • A 2,000-character limit keeps URLs practical, with explicit errors when exceeded
  • The same pattern enables cross-tool linking -- passing data between independent tools via URL

This pattern applies to any static web app where you want to share input state: playgrounds, converters, formatters, calculators. If your tool has state worth sharing, Recipe URLs are a zero-infrastructure way to make it happen.

See Recipe URLs in action: PureMark Base64 / PureMark JSON Formatter -- decode Base64, detect JSON, and hand it off to the formatter with one click.


References

Top comments (0)