Skip to content

Embedding in another site

By default no site may frame a Homespun app in an <iframe>. A manifest can name the exact third-party origins allowed to, and the app auto-sizes the iframe to fit its own content once framed. Use this for a form or a small tool that lives on someone’s marketing site or blog, backed by a Homespun app doing the work.

Only a public app whose manifest needs no visitor identity. Visitor identity means a collection admits anonymous create (write: ["anyone"]) and scopes update or delete to a row subject such as creator or own, which needs a cookie to recognize the same visitor across visits. Every usercontent cookie is dropped inside a cross-site frame, so an app that needs one would not actually work embedded, even if it rendered. A plain public form, write: ["anyone"] with no row-scoped update/delete, is the shape that qualifies.

This is checked on every request, not once at deploy: flip the app to private, or grow a collection that needs visitor identity, and framing stops on the very next response. There is nothing to revoke.

Add the embedder’s exact origin to x-homespun-manifest.embedAncestors:

"x-homespun-manifest": {
"embedAncestors": ["https://your-partner-site.example"]
}

Exact origins only, no wildcard, ever, with one exception for local development (http://localhost:PORT and http://127.0.0.1:PORT). See the manifest reference’s embedAncestors entry for the full grammar. Declaring the field on an app that is not public, or that needs visitor identity, does nothing: it is left declared but inert rather than rejected, so check the app’s own collections before assuming an embed will render.

Once at least one origin is declared, the app’s owner shell shows a ready-to-copy snippet on the app’s Settings tab, filled in with the app’s real URL. Hand that to whoever is embedding the app rather than retyping it by hand.

The snippet is an <iframe> plus a small <script> that listens for a resize message and grows the iframe to match the app’s rendered height, so a fixed-height iframe never scrolls internally or leaves dead space below a short form:

<iframe
id="homespun-embed-your-app"
src="https://your-app.example-usercontent-domain.com/"
style="width:100%; border:0;"
height="600"
title="Your app"
></iframe>
<script>
(function () {
var frame = document.getElementById("homespun-embed-your-app");
var expectedOrigin = "https://your-app.example-usercontent-domain.com";
window.addEventListener("message", function (event) {
if (event.origin !== expectedOrigin) return;
if (!frame || event.source !== frame.contentWindow) return;
var data = event.data;
if (!data || data.__homespun !== 1 || data.v !== 2 || data.kind !== "resize") return;
if (typeof data.height !== "number" || data.height <= 0) return;
frame.style.height = data.height + "px";
});
})();
</script>

The two checks in that listener are load-bearing, not decoration. event.origin must equal the app’s own origin exactly, and event.source must be that specific iframe’s own contentWindow, not any other frame or script on the embedder’s page. A listener that resizes on any message, from any window, is a defect: any page on the internet can post a message shaped like a resize envelope, and without both checks it would drive the iframe to an arbitrary height on command. Do not simplify either check away when adapting the snippet.

There is nothing to configure on the app side beyond declaring the origin: every app already loads the SDK, and the SDK already posts its rendered height to its parent whenever it detects it is framed. There is no manifest key for auto-resize and nothing to opt into.

For a simpler paste at the cost of one more request to homespun on every page load, use the hosted embed.js instead of hand-maintaining the snippet:

<script src="https://<main-domain>/embed.js"
data-app="https://your-app.example-usercontent-domain.com/"
data-height="600"
data-title="Your app"></script>

It creates the iframe itself and applies the same two checks as the inline snippet. This is the convenience path; the inline snippet above is the documented default, and it keeps working even if homespun is briefly unreachable after the embedder’s page has loaded, since no homespun-hosted script is involved.

Abuse guidance for an anonymous-write embed

Section titled “Abuse guidance for an anonymous-write embed”

A public, anonymous-write form is a spam target, embedded or not, and embedding it on someone else’s site makes it easier to find. If the embedded app’s collection admits anonymous writes (write: ["anyone"]), declare both of these on that collection:

"antiAbuse": "turnstile",
"anonWriteBudget": { "perIpPerDay": 20, "perAppPerDay": 500 }
  • antiAbuse: "turnstile" requires a Cloudflare Turnstile token on every anonymous write. This only works when the relay operator has Turnstile configured; if a deploy is refused naming antiAbuse, drop the key or ask the operator to enable it. Turnstile fails open: if Cloudflare cannot be reached in time, the write proceeds rather than being blocked, so a Cloudflare outage never takes an embedded form down.
  • anonWriteBudget caps how many rows anonymous visitors may add per day, per IP and across the whole app. This has no configuration prerequisite and is what keeps holding even during a Turnstile outage, so declare it regardless of whether Turnstile is available on the relay you are deploying to.

Neither of these gates reading an embedded app’s data, only anonymous writes: they exist because framing a form makes it reachable from wherever the embedder’s own page gets traffic, which is usually the point, and also usually more traffic than a spammer scanning for open forms would otherwise find on their own.