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
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
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>
| 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;
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
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('')
);
}
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!"
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;
}
}
Key design choices:
-
encodeRecipeHashreturnsnullwhen exceeding the 2,000-character limit (caller shows an error) -
decodeRecipeHashnever throws -- it returnsnullfor 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;
}
}
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;
}
}, []);
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>
);
}
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');
};
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
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+btoasafely handles Unicode strings in Base64 -
URLSearchParamshandles 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
- PureMark Base64 Encoder/Decoder -- Recipe URLs in production
- PureMark JSON Formatter -- Cross-tool linking with Base64
- MDN: URL - hash -- URL hash specification
- MDN: btoa() -- Base64 encoding constraints
Top comments (0)