Skip to content

Saving and Short URLs ​

Since all code and state is stored in the URL hash, metaframe URLs can get very long. The built-in URL shortener compresses these into compact, shareable links.

There are two forms of short URL persistence:

  • Expiring immutable URLs
  • Durable editable URLs

Howto: Expiring immutable URLs ​

  1. Click the Create expiring snapshot button in the editor header
  2. The current hash parameters (code, inputs, options) are stored in R2
  3. You get a short URL framejs.io/j/<SHA256-based short ID>
  4. Title, description, and image are supported for nice bookmarks and previews

The short URL is automatically copied to your clipboard.

If any input points at a local address (http://localhost:…, 127.0.0.1), its content is uploaded to the file store first and the reference rewritten to a permanent framejs.io/f/<sha256> URL — a snapshot has to work for whoever you send it to, and nobody else can reach your machine. If one of those inputs can't be fetched, no snapshot is created and you get an error naming the URL. See Inputs served from your own machine.

The content is immutable — the same content always produces the same short URL. Short URLs are a free convenience snapshot, kept for a month and then garbage-collected. They are perfect for quick sharing, previews, and QR codes. If you need a link that lasts, save the content to a durable Frame on framejs.app — see below and Persistence & Data Retention for the full retention model across every kind of URL.

Howto: Durable editable URLs ​

  1. Click the Save to framejs.app button in the editor header
  2. The current hash parameters (code, inputs, options) are persisted
  3. The durable editable short URL framejs.app/j/<uuid> opens
  4. You can claim this URL for your account if authenticated

Howto: Standalone HTML export (an offline archive) ​

A link, however durable, still needs the internet to run: the runtime, your npm imports, your modules and your inputs are all fetched when the page loads. For long-term science you may want the frame as a file, with everything inside.

  1. Click the Export standalone HTML button in the editor header (the file icon; in a narrow editor it is in the menu)
  2. The server bundles the frame into one self-contained .html file:
    • your code, with every import it makes (https://esm.sh/…, jsdelivr, import-map entries) bundled in
    • every entry of modules — stylesheets (with their fonts and images), module and classic scripts — inlined
    • every input, including url datarefs and uploaded files, inlined as data: URLs, so your code receives exactly what it did before
    • the framejs runtime itself
  3. The file downloads. Open it from disk — it makes no network requests for any of the above, even with the network unplugged.

Requests your own code makes while it runs (a fetch() to an API, an import() of a URL computed at run time, a library loading its own .wasm) are outside what the export can see, and still need the network.

The file is stored on the server under the SHA-256 of its contents, at framejs.io/html/<sha256>: exporting the same frame again gives the same link and is not uploaded twice. The server copy is kept for ~30 days: the file you download is the archive. The un-bundled source, modules and inputs are kept, readable, inside the file (<script id="framejs-original">). There is no editor in the file, so its edit button is removed.

An export is limited to 50 MB (everything it pulls in, and the file itself; inlined inputs grow by roughly 1.8× on the way in). Entries in modules that cannot be made network-free — an inline <script> with no src, a tag that is not a <script> or <link> — fail the export with a message naming them, rather than being dropped or left to make a request.

Howto: Get a frame's code, CSS and params as files ​

Every short URL — framejs.io/j/<uuid>, framejs.io/j/<sha256> — also serves each of the frame's hash params as a plain file. Add an extension to the URL:

URLReturnsContent-Type
/j/<id>.jsthe frame's code (js), unalteredtext/javascript
/j/<id>.esm.jsthe code reduced to an importable ES module of its functions (see below)text/javascript
/j/<id>.cssthe frame's css, if it stored onetext/css
/j/<id>.<param>.jsonany hash param's value as JSON: inputs, modules, og, options, definition, bgColor, … and the frame's own setJson keys (/j/<id>.state.json)application/json
/j/<uuid>.jsonthe whole frame — {js, modules, inputs, og, …} — in one object (uuid only)application/json

Every variant takes the same forms as the page:

  • <id> is a <uuid> (with or without dashes) or a <sha256>;
  • a uuid takes a version pin, ?v=<sha256>, for one published version: /j/<uuid>.esm.js?v=<sha256>, /j/<uuid>.inputs.json?v=<sha256>, … (?sha256= is accepted as an alias). A ?v= on a sha256 id is ignored — it already is one exact version.

A param the frame does not have is a 404 ({"error": "This frame has no `css`"}): most frames have no css, which is a live-URL overlay and only stored when the frame declares it. When js or css is a URL — framejs.app stores a very large frame's code as a reference to /f/<id>, and css may link a stylesheet — the file is the content behind that URL, not the URL.

bash
curl https://framejs.io/j/<uuid>.js            # the code, to read or save
curl https://framejs.io/j/<uuid>.inputs.json   # the inputs
js
// from another frame:
import { total } from "https://framejs.io/j/<uuid>.esm.js";
const inputs = await (await fetch("https://framejs.io/j/<uuid>.inputs.json"))
  .json();

Caching follows the URL: a <sha256> and a pinned <uuid>?v=… can never change, so they are cached for a year (immutable). The live <uuid> form revalidates on every request (no-cache with an ETag, so an unchanged file is a cheap 304) and always serves the current version — as does a js/css whose URL points somewhere that can change.

Errors: 400 for an id that is neither a uuid nor a sha256, 404 for no such frame (or no such param), 422 when .esm.js is asked of code that does not parse as a JavaScript module (.js still serves it as it is), 413/502 when a referenced js/css URL is too large (10 MB) or cannot be fetched.

None of these can be registered as a service worker: a frame running on framejs.io could otherwise install its own code as one over every other frame's page, so a service-worker registration of any of them is refused (403).

Import a frame's functions: /j/<id>.esm.js ​

/j/<id>.js is the frame's program, and importing it would run the whole thing: render into root, add listeners, fetch data. /j/<id>.esm.js is the same code with all of that taken out, so another frame can import just the functions:

js
import { total } from "https://framejs.io/j/<uuid>.esm.js";
import { total } from "https://framejs.io/j/<uuid>.esm.js?v=<sha256>"; // pinned
import { total } from "https://framejs.io/j/<sha256>.esm.js"; // snapshot

The module keeps only what is safe to evaluate and has the same value it has in the running frame:

  • function declarations, exported or not (the unexported ones are the helpers the exported ones call), with the comments directly above them, and const f = () => … / function () {…}
  • const primitives (const SCALE = 10, const UNIT = "px")
  • imports of names the kept code uses, and re-exports (export … from). A kept import still runs the imported library's own top level, as any import does
  • export { … } / export default of any of the above

Everything else is removed: top-level statements, classes, let/var, const arrays and objects, const x = compute(), await, imports nothing kept uses (including side-effect-only import "x"), and functions that top-level code reassigns. let, var and const objects are removed rather than kept with their initial value because the top-level code that changes them is removed too: keeping them would hand your function a value the frame never had. An export that was removed is listed in a comment at the top of the module. A function that reads something removed — or a runtime global such as root or setOutput — throws when it is called, not when it is imported, so write the functions you want to share to take what they need as arguments.

Imports resolved by the frame's own import map (import { max } from "d3" with a modules entry) are not rewritten: the module loads as long as no kept function uses them, but a function that does needs the same import map in the importing frame. Prefer full URLs (https://esm.sh/d3@7) in code you share.

Longer discussion between the two short-URL forms: /j/<sha256> vs /j/<uuid> ​

Both forms look alike — framejs.io/j/… and framejs.app/j/… — but they make very different persistence promises. The Create expiring snapshot button above creates the ephemeral sha256 form; saving into an account creates the durable uuid form.

framejs.io/j/<sha256>framejs.app/j/<uuid>
IdentifierSHA-256 of the content (content-addressed)Random UUID (a stable document handle)
Backed byImmutable blob in object storage (R2)Versioned rows in a database, tied to an account
Mutable?No — different content always yields a different URLYes — the same URL, edited over time (append-only version history)
Account?None requiredOwned by an account (free tier included)
PersistenceFree, best-effort, kept ~30 days, then garbage-collectedDurable & guaranteed while the account is active
Best forQuick sharing, previews, QR codes, paste-into-chatAnything you need to keep, keep editing, or make private

In short: sha256 is a disposable snapshot; uuid is a durable document. You get the best of both by publishing an exact immutable version of a durable Frame (Frame menu → Publish version), which mints framejs.app/j/<uuid>?v=<sha256> — a stable handle and an unchanging snapshot. A published version is permanently public and citable, like a DOI: it keeps resolving even if you later make the Frame private or delete it, and both apps serve it (framejs.io/j/<uuid>?v=<sha256> renders the same snapshot on the runtime). See Persistence & Data Retention for the complete model.

Example ​

A full URL like this:

https://framejs.io/#?js=Y29uc3QgZGl2ID0gZG9jdW1lbnQuY3Jl...&options=eyJhdXRvcnVuIjp0cnVlfQ%3D%3D

Becomes:

https://framejs.io/j/8a3b1c9f4e2d7a6b5c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b

When someone opens the short URL, the page loads with all the original code and state intact — no redirect, the browser stays on the /j/... path.

API ​

You can also shorten URLs programmatically.

POST /api/shorten — Shorten from hash params ​

Store raw hash params and get a short URL ID back.

bash
curl -X POST https://framejs.io/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"hashParams": "?js=Y29uc29sZS5sb2coImhlbGxvIik%3D"}'

Response:

json
{
  "success": true,
  "id": "8a3b1c9f...",
  "path": "/j/8a3b1c9f..."
}

POST /api/shorten/json — Shorten from JSON ​

A convenience endpoint that encodes JavaScript and options into hash params for you.

bash
curl -X POST https://framejs.io/api/shorten/json \
  -H "Content-Type: application/json" \
  -d '{"js": "console.log(\"hello\")"}'

Accepted keys are the frame's hash params: js, inputs, modules, og, options, and definition. Any hash param the app writes itself must be declared in definition.hashParams or it is dropped — setJson does that for the app automatically, and definition carries the result. See URL State.

Response:

json
{
  "id": "8a3b1c9f...",
  "shortUrl": "https://framejs.io/j/8a3b1c9f...",
  "fullUrl": "https://framejs.io/#?js=Y29uc29sZS5sb2coImhlbGxvIik%3D",
  "hashParams": "?js=Y29uc29sZS5sb2coImhlbGxvIik%3D"
}

POST /api/frame — Create or update an editable frame ​

Creates a durable, editable framejs.app/j/<uuid> frame (anonymous until claimed) and returns its URL. CORS is open, so a page on any origin can call it and read the result — this is what an app's Open in framejs export uses.

bash
curl -X POST https://framejs.io/api/frame \
  -H "Content-Type: application/json" \
  -d '{"frame": {"js": "root.innerHTML = \"hello\"", "og": {"title": "Hello"}}}'

frame is the same {js, inputs, modules, og, definition} object /api/shorten/json takes. It is stored as sent, minus params left at their default value. Pass "id": "<uuid>" next to it to append a version to that frame instead of creating a new one (allowed while the frame is unclaimed); an id that is not a uuid is a 400, never a new frame.

Response:

json
{
  "id": "019f2916dcb17ace8876580ebd1fdcd5",
  "url": "https://framejs.app/j/019f2916dcb17ace8876580ebd1fdcd5"
}

GET /j/:id.js, .esm.js, .css, .:param.json — One hash param as a file ​

The frame's code (unaltered), its functions as an importable module, its CSS, or any one hash param as JSON. :id is a uuid or a sha256; a uuid takes ?v=<sha256>. See Get a frame's code, CSS and params as files.

bash
curl https://framejs.io/j/8a3b1c9f....js
curl https://framejs.io/j/8a3b1c9f....esm.js
curl https://framejs.io/j/8a3b1c9f....inputs.json

GET /api/j/:sha256 — Retrieve decoded hash params ​

Returns the short URL ID and decoded hash parameters as a JSON object.

bash
curl https://framejs.io/api/j/8a3b1c9f...

Response:

json
{
  "id": "8a3b1c9f...",
  "hashParams": {
    "js": "console.log(\"hello\")"
  }
}

GET /api/j/:sha256/url — Retrieve full URL ​

Returns the full URL (with hash params) as plain text.

bash
curl https://framejs.io/api/j/8a3b1c9f.../url

Response:

https://framejs.io/#?js=Y29uc29sZS5sb2coImhlbGxvIik%3D

GET /j/:sha256/qrcode.png — QR code image ​

Returns a PNG QR code that encodes the short URL https://framejs.io/j/<sha256>. The response has CORS open (Access-Control-Allow-Origin: *) and is cached immutably, so the image can be embedded directly anywhere — <img> tags, Markdown, slides, printed material:

html
<img src="https://framejs.io/j/8a3b1c9f.../qrcode.png" alt="QR code" />

Any extra query parameters on the request are appended to the encoded URL. This lets you generate QR codes that open the short URL with additional hash/query state, for example to point at a specific tab or view:

https://framejs.io/j/8a3b1c9f.../qrcode.png?foo=bar

encodes https://framejs.io/j/8a3b1c9f...?foo=bar.

The encoded URL always uses the canonical framejs.io origin, regardless of which host served the image — so QR codes stay valid even when generated from an alias or preview domain.

POST /api/upload/presign — Get presigned upload URL ​

Generate a presigned S3 URL for direct browser-to-storage file upload. Files are content-addressed by SHA256.

bash
curl -X POST https://framejs.io/api/upload/presign \
  -H "Content-Type: application/json" \
  -d '{"contentType": "image/png", "sha256": "abcd1234..."}'

Response:

json
{
  "presignedUrl": "https://s3.../f/abcd1234...?X-Amz-Signature=...",
  "id": "abcd1234...",
  "canonicalPath": "/f/abcd1234..."
}

GET /f/:id — Download a file ​

Redirects to the public storage URL for a previously uploaded file.

bash
curl -L https://framejs.io/f/abcd1234...

TIP

Short URLs are content-addressed — the same code always produces the same short URL. They are free and kept for about a month; for a link that lasts, save it to a durable Frame.