Send the Scenario, Not the Screenshot
Why the sandbox tools on this site put their whole state in the URL: scenarios that travel as editable links, plus the React hook and the App Router trap behind it.
Most numbers die in screenshots. Someone models a scenario in a calculator, screenshots the result, pastes it into a deck — and the conversation that follows argues with a picture. Nobody can change an input, test an assumption, or see what happens when the weights move. The analysis is frozen at the moment someone pressed PrtScn.
A URL that carries the entire scenario is a different object. The recipient opens it, sees exactly your inputs, changes the one they disagree with, and sends the link back. The disagreement becomes legible: not "your number is wrong" but "here's the same model with my assumption — look at which input we actually differ on."
That's a shareable scenario: self-contained, reproducible, and editable by whoever receives it. Every interactive tool in this site's sandbox works this way — the build-vs-buy ROI calculator, the decision matrix, and the AI readiness assessment all keep their full state in the URL. The buy-vs-build article explains why that matters for the conversations these tools are built to structure; this post covers the engineering that makes the shared link trustworthy.
(Updated August 2026 — reframed around how the sandbox tools actually get used; the hook and the App Router lessons below are unchanged.)
What the URL Has to Carry
Sending the scenario only works if the link is the whole state:
/sandbox/roi?v=1&buildInitialCost=150000&teamSize=5&salary=120000
(Those are the calculator's illustrative defaults, not figures from any real project.) Everything the recipient needs is in the address: no login, no saved session, no "it looks different on my machine." Bookmark it and the scenario survives. Put two links side by side and you have two positions, diffable by eye in the query string.
That requirement — the URL is the source of truth — drives every design decision in the hook that powers all three tools.
The Hook
interface UseUrlStateOptions<T> {
key: string; // URL param key
defaultValue: T; // Initial state
encoding: "simple" | "base64"; // Encoding strategy
version?: number; // Schema version (default: 1)
}
interface UseUrlStateReturn<T> {
state: T; // Current state
setState: (newState: T) => void; // Update state
copyLink: () => Promise<boolean>; // Copy URL to clipboard
isFromUrl: boolean; // True if loaded from URL
}
Consumers stay simple:
function ROICalculator() {
const { state, setState, copyLink, isFromUrl } = useUrlState({
key: "roi",
defaultValue: { buildInitialCost: 100000, teamSize: 3 },
encoding: "simple",
version: 1,
});
return (
<div>
{isFromUrl && <Banner>Loaded from shared link</Banner>}
<input
value={state.buildInitialCost}
onChange={(e) => setState({ ...state, buildInitialCost: Number(e.target.value) })}
/>
<button onClick={copyLink}>Copy Link</button>
</div>
);
}
The isFromUrl flag matters more than it looks: when someone opens your link, the banner tells them they're looking at your scenario, not the defaults. A shared scenario that silently blends into the default view hasn't been shared at all.
Two Encodings, One Rule
Flat numeric state — scores, weights, dollar inputs — uses simple encoding: one query param per field. The URL stays human-readable, which is the point; a skeptical recipient can see teamSize=5 in the address bar and change it to 8 by hand. All three sandbox tools use this mode.
Nested structures use base64 encoding: the whole state as one opaque param. It handles anything, but the URL stops being self-explanatory — you trade legibility for generality. If a tool's state can be flattened, flatten it; a scenario humans can read beats one only the parser can.
Debounce, Replace, Don't Scroll
Typing in an input must not spam the URL:
updateTimeoutRef.current = setTimeout(() => {
const params = new URLSearchParams();
params.set("v", String(version));
for (const [k, v] of Object.entries(newState)) {
params.set(k, String(v));
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, 300);
Three deliberate choices: 300ms debounce (responsive, but batches keystrokes), router.replace (the back button should leave the page, not undo forty keystrokes), and scroll: false (URL updates must not move the viewport).
The Part That Actually Bit Me: App Router Infinite Loops
The naive wiring creates an infinite loop:
// ❌ Don't do this - infinite loop!
useEffect(() => {
updateUrl(state);
}, [state, updateUrl]); // updateUrl depends on router/pathname which change on URL update
updateUrl depends on router and pathname; updating the URL produces new router/pathname objects; the effect fires again. The fix is storing them in refs that don't participate in dependency arrays:
const routerRef = useRef(router);
const pathnameRef = useRef(pathname);
useEffect(() => {
routerRef.current = router;
pathnameRef.current = pathname;
}, [router, pathname]);
const updateUrl = useCallback((newState: T) => {
// ... debounce logic ...
routerRef.current.replace(
`${pathnameRef.current}?${params.toString()}`,
{ scroll: false }
);
}, [version, encoding, key]); // No router/pathname in deps!
Next.js Gotcha
useRouter(), usePathname(), and useSearchParams() return new objects on every render in Next.js App Router. Using them directly in dependency arrays causes unexpected re-renders.
A companion guard skips the very first render — on mount the hook reads the URL, it must not immediately rewrite it:
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
updateUrl(state);
}, [state, updateUrl]);
One more App Router requirement: useSearchParams needs a Suspense boundary under static export, so each sandbox page wraps its tool in one.
Shared Links Are a Published API
Here's the consequence of sharing URLs that most implementations skip: the moment someone bookmarks your link, your state shape is a published contract. Rename cost to buildCost and every link in every old email silently breaks — or worse, half-parses.
That's what the v parameter is for:
// v1: { cost: number }
// v2: { buildCost: number, buyCost: number }
// When a v1 URL is loaded by v2 code, it falls back to defaults
// rather than trying to parse incompatible data
Falling back to defaults is the honest failure mode — the recipient sees the tool, not a corrupted half-scenario that looks like a real position. And when an old link matters enough, the version gate is exactly where migration logic belongs:
if (urlVersion === "1" && version === 2) {
return migrateV1ToV2(parsedState);
}
This is schema-migration thinking applied to something as small as a query string. The size of the system doesn't change the obligation: if people depend on the shape of your data, you version the shape.
Closing the Loop
The copyLink helper serializes the current state and writes the URL to the clipboard, returning success so the UI can confirm — because "did the copy actually happen" is the difference between sending a scenario and sending nothing. And the whole round-trip is testable end to end: a Playwright spec sets inputs, captures the URL, navigates away and back, and asserts the state survived. If the scenario can't survive a round-trip in CI, it can't survive being emailed.
The pattern costs one hook and pays for itself the first time a discussion moves from "whose screenshot is right" to "which input do we disagree on." That's the job: not prettier URLs — portable positions. For what gets decided with them, see Buy vs. Build for the GTM Stack.