Ever wanted users to share their exact setup in your web tool — without a database, accounts, or server?
Here's a pattern I use in my tools: encode the entire app state into the URL. One click, one link, fully restored.
The Problem
You build a client-side tool. Users configure settings, enter data, tweak options. Then they want to share their exact configuration with a teammate.
Traditional solution: database + user accounts + share endpoints. That's a whole backend for one feature.
The Solution: URL State Encoding
function shareURL() {
// Collect current state
const state = {
groups: getKeywordGroups(),
match: currentMatchType,
separator: document.getElementById("separator").value
};
// Encode as base64 URL parameter
const encoded = btoa(JSON.stringify(state));
const url = `${location.origin}${location.pathname}?q=${encoded}`;
navigator.clipboard.writeText(url).then(() => {
showToast("Shareable link copied!");
});
}
On page load, check for the parameter and restore:
const params = new URLSearchParams(location.search);
const shared = params.get("q");
if (shared) {
try {
const state = JSON.parse(atob(shared));
restoreState(state);
} catch (e) {
loadDefaults();
}
}
That's it. ~20 lines of code. No server, no database, no auth.
Why This Works Well
- Zero infrastructure — The URL is the storage
- Instant sharing — Copy link → paste → done
- Bookmarkable — Users can save specific configurations
- Debuggable — Support tickets include the exact state
- Offline-friendly — Works without any network
Gotchas
- URL length limits — Most browsers handle 2,000+ characters, but keep state compact
-
base64 bloat — JSON + base64 adds ~33% overhead. For large states, consider
pako(gzip) - Breaking changes — Version your state format or add fallback parsing
- Sensitive data — base64 is encoding, not encryption. Don't put secrets in URLs
Adding Persistent Presets (Bonus)
Combine URL sharing with localStorage presets for power users:
function savePreset(name) {
const presets = JSON.parse(
localStorage.getItem("presets") || "{}"
);
presets[name] = getCurrentState();
localStorage.setItem("presets", JSON.stringify(presets));
}
function loadPreset(name) {
const presets = JSON.parse(
localStorage.getItem("presets") || "{}"
);
if (presets[name]) restoreState(presets[name]);
}
Now users can:
- Save configurations locally (survives page refresh)
- Share configurations via URL (works across devices)
- Load saved configurations instantly
All without a single API call.
Real-World Examples
1. Keyword Mixer — Full State in URL
I used this pattern in Keyword Mixer — a PPC keyword combination tool. Users set up keyword groups, match types, and negative keywords, then share the exact setup with their team via a single URL.
The ?q= parameter carries everything. Click a shared link → tool opens with the exact configuration → generate combinations immediately.
2. DonFlow — URL as a Trigger, Not Storage
For DonFlow (a budget tracker I built), I used a simpler variation: URL parameters as triggers rather than state containers.
Try it: donflow/?demo — one parameter loads a full demo experience with income, expenses, categories, and drift detection.
Why this variation matters: When your state is too large for URLs (DonFlow tracks months of transactions), use the URL as a trigger that tells the app what to do, not what to show. The actual data lives in IndexedDB.
The spectrum:
| State Size | Strategy | Example |
|---|---|---|
| < 1KB | URL parameter (base64) | Keyword Mixer configs |
| 1-10KB | URL trigger + localStorage | Presets, themes |
| 10KB+ | URL trigger + IndexedDB | DonFlow financial data |
When to Use This
✅ Configuration-heavy tools (settings, filters, presets)
✅ Small-to-medium state (< 1KB JSON)
✅ Tools where sharing = collaboration
✅ Demo/onboarding flows (URL triggers)
❌ Large datasets without triggers (use IndexedDB + export instead)
❌ Sensitive data (use encryption or server-side)
❌ Frequently changing state (use WebSocket or SSE)
Going Deeper
If you want to see the full zero-backend architecture in action — IndexedDB, CSV parsing, budget drift detection, all in the browser — I wrote about building DonFlow with zero backend.
Also check out 27+ free browser tools — all using variations of this URL state pattern.
What patterns do you use for sharing state in client-side apps? Have you tried the "URL as trigger" approach? I'd love to hear other techniques.
📚 More Build-in-Public
- I Built a Finance App With Zero Backend — DonFlow uses this URL-as-trigger pattern for its demo mode
- I Built a Budget App With Zero AI, Zero Backend, Zero Tracking — The philosophy behind keeping everything client-side
- I Built 23 Free Developer Tools in 2 Weeks — Many of these tools use URL state for sharing
- I Built 27 Browser Games in 2 Weeks — Same ship-fast philosophy, applied to games
- Why I Chose IndexedDB Over a Backend
Top comments (3)
This is a really underrated pattern. I use something similar in my own browser-based tools, and it makes sharing and debugging much easier. One thing I learned the hard way: always add a small version field in the state, otherwise old shared URLs can break when you update the tool.
The versioning tip is a great catch — I learned that one the hard way too. Even a small
v=1prefix in the URL hash saves you from breaking shared links when the state schema changes. It's such a simple pattern but almost nobody talks about it. Thanks for sharing your experience with browser-based tools!Nice writeup. I've used this exact pattern for a config playground tool at work and it's been a lifesaver for bug reports - instead of "it's broken" we get a URL with the exact state that reproduces the issue.
One gotcha I ran into: btoa/atob doesn't handle unicode well. If your state could contain non-ASCII characters (user input, etc), you'll want to do something like
btoa(unescape(encodeURIComponent(json)))on encode and the reverse on decode. Learned that one the hard way when a user had emoji in their config 😅The spectrum table (state size → strategy) is really useful. I'd maybe add one more tier: for states between like 2-8KB, you could use the URL fragment (#) instead of query params - fragments don't get sent to the server and have slightly more relaxed length limits in some browsers.