URL State
State is stored in the URL hash. You can get and set values using the @metapages/hash-query module:
Always use @metapages/hash-query — never parse the hash yourself
No regex or split on location.hash, no new URLSearchParams(location.hash.slice(1)), no hand-built #?key=value strings. Values are base64-encoded over a URI-encoded payload and the param order is canonical; the runtime, editor, shortener and frame API all agree on that encoding, and a hand-rolled version corrupts values or breaks saving.
And whenever you save URL state, the param name must also be added to definition.hashParams — otherwise it is stripped when the app is saved, shortened, or copied.
import {
getHashParamsFromWindow,
getHashParamFromWindow,
getHashParamValueJsonFromWindow,
setHashParamValueJsonInWindow,
setHashParamValueBase64EncodedInWindow,
getHashParamValueBase64DecodedFromWindow,
deleteHashParamFromWindow,
} from "https://cdn.jsdelivr.net/npm/@metapages/hash-query@0.10.0/+esm";
// Get JSON stored in URL
const myJsonBlob = getHashParamValueJsonFromWindow("someKey") || {};
// Update the JSON blob
myJsonBlob["someKey"] = "foobar";
// Set it back in the URL
setHashParamValueJsonInWindow("someKey", myJsonBlob);
// Delete it if needed
deleteHashParamFromWindow("someKey");TIP
This is designed for relatively small values. Large multi-megabyte JSON blobs are not yet supported.
Declare your hash params, or they get stripped
Writing the param is only half of it. framejs keeps a whitelist of hash params, and anything outside it is removed whenever the app is saved, shortened, or copied as a link — so the state silently disappears from the URL you share.
These built-ins are always allowed: js, inputs, modules, og, options, bgColor, edit, editorWidth, hm, definition.
Every other param name — someKey in the example above — must be declared in the frame's metaframe definition, under definition.hashParams.
In the editor
Open Settings (the ⚙ icon) → the Runtime tab → Allowed Hash Parameters → Add Hash Parameter, and add the param name. That writes it into the definition hash param for you.

In the definition JSON
The definition hash param holds the metaframe definition. The relevant part:
{
"version": "1",
"hashParams": {
"someKey": {
"type": "json",
"label": "Some Key",
"description": "State the app persists in the URL"
}
}
}type must match how the app encodes the value:
type | Written with |
|---|---|
json | setHashParamValueJsonInWindow |
stringBase64 | setHashParamValueBase64EncodedInWindow |
string | setHashParamInWindow |
number | setHashParamValueFloatInWindow / setHashParamValueIntInWindow |
boolean | setHashParamValueBooleanInWindow |
label and description are optional; they are only used by the editor's settings UI.
From the API or an AI agent
definition is a normal hash param, so it goes in the frame body alongside js (see Short URLs and the frame API):
{
"js": "…",
"definition": {
"version": "1",
"hashParams": { "someKey": { "type": "json" } }
}
}The framejs Agent Skill helper does this with a flag:
cat app.js | node scripts/framejs.mjs create --state "$SCRATCH/frame.json" \
--hash-param someKey:jsonWhen you update an existing frame, keep its definition — dropping it un-whitelists params the app still relies on. (The helper carries the stored definition forward automatically.)
Writing state does not re-run your app
A frame's own source lives in the URL hash, so the runtime re-executes the app whenever the hash changes. It makes one exception: changes the app itself writes. The app already holds the value it just wrote, and re-running would throw away the DOM and every bit of in-memory state — so a slider bound to setHashParamValueJsonInWindow can write on every input event without reloading itself.
Those writes are still announced, so an app opened from framejs.app saves them as a new version. This holds however the URL is written — setHashParamValueJsonInWindow, or a bare history.replaceState — though you should always use the helpers, because they are what get the encoding right.
A change from outside the frame — editing the address bar, an embedder rewriting the url — still re-runs the app, so it picks up the new state on the next run. To handle those in place instead, listen for hashchange:
window.addEventListener("hashchange", () => {
const next = getHashParamValueJsonFromWindow("someKey") || {};
// reconcile against what is already applied, then update the UI
});TIP
That listener also fires for your own writes. Compare against the state you last applied and ignore anything already reflected in the UI, so a write can't cause a render loop.
The css hash param (transient global stylesheet)
The css hash param loads a global stylesheet at runtime. Its value is base64-encoded and is either:
- raw CSS text, which is injected as a
<style>element, or - a URL to a CSS stylesheet (a single-line
http(s)URL), which is injected as a<link rel="stylesheet">.
Unlike the other hash params, css is not persisted: it is never written into the metaframe definition, copied into shareable links, or baked into short URLs. It is purely appended at runtime.
This makes it ideal for applying a consistent style across many different pieces of content without modifying that content. Because it is not part of a short URL's content, appending #?css=… to an existing short URL changes nothing about the underlying content — it just layers a stylesheet on top.
The value is encoded the same way as every other base64 hash param: btoa(encodeURIComponent(text)) (the plain btoa alone is not enough — non-ASCII characters would corrupt).
// Base64-encode raw CSS...
const css = btoa(encodeURIComponent("body { background: #111; color: #eee; }"));
location.hash = "?css=" + css;
// ...or base64-encode a stylesheet URL
const css = btoa(encodeURIComponent("https://example.com/theme.css"));
location.hash = "?css=" + css;Appended to a short URL: https://framejs.io/j/<id>#?css=<base64>
TIP
Clearing or changing the css param replaces the previously injected stylesheet, so you can swap themes live by updating the param.