Skip to content
BetaDocs

Frontend & recipient links

A bundled component is an ES module with one exported function:

export async function mount(element, props, context) {
// Render into element. Return after initial setup is complete.
}

Typeroll calls mount once per block instance. The runtime context is scoped to that mount and contains:

  • protocol, runtime, Extension, installation, and component identifiers;
  • preview, which is true only inside an isolated editor preview;
  • the installation’s public configuration;
  • url.get(), url.has(), and one-time url.consume() accessors;
  • an in-memory navigation object;
  • site.url() and site.navigate() for root-relative Typeroll page paths;
  • installation-scoped storage.session and storage.local JSON storage;
  • api.fetch() for direct calls to declared provider routes;
  • forms.has(), forms.list(), and forms.submit() for approved form bindings;
  • handoff.read() for the values a visitor typed into a native navigation form on the previous page (runtime 0.43.0+).

Build a native lead tool on Typeroll Forms

Section titled “Build a native lead tool on Typeroll Forms”

Create the receiving form in the site’s ordinary Forms module, then declare its ID in the component manifest and request forms:submit. The bespoke frontend can calculate, branch, animate, or collect data however it wants and submit only the final fields:

const result = await context.forms.submit("lead", {
name: "Ada Lovelace",
email: "ada@example.com",
team_size: 12,
estimated_monthly_price: 4800,
});
if (result.ok === false) {
// Map result.errors to the custom interface.
} else if (result.done) {
// Show the custom confirmation state.
}

Typeroll adds its signed token, honeypot protocol fields, and proof of work. The request goes directly to the absolute Forms endpoint embedded by the CMS. Typeroll Cloud sites use the hosted Forms service and self-hosted sites use their own Forms endpoint. No Function or proxy is installed in the customer’s static hosting project. Submissions, notifications, and configured webhooks behave exactly like submissions from a standard core/form block.

In previews (context.preview is true) forms.submit() sends nothing and resolves { v: 1, ok: true, done: true, preview: true }, so the whole flow can be reviewed without creating submissions; unknown bindings and non-object data are still rejected. Test real writes on a deployed test page, where they appear in the normal Forms submission inbox.

An Extension can implement several screens inside one block:

context.navigation.subscribe((view) => render(view));
context.navigation.navigate("confirmation");

Navigation belongs to the mounted component. It does not create a new Typeroll page or URL path, and the component’s captured runtime context remains available until that mount is destroyed. A full reload starts a new mount and follows the provider’s declared session strategy.

Use the site-navigation API instead of assigning a root-relative path directly:

context.storage.session.set("quote-draft", {
from: "Storgatan 1",
to: "Kungsgatan 2",
});
context.site.navigate("/flyttfirmeoffert/");

On a published site, site.url(path) resolves against the site’s current origin. In a navigable preview it resolves to the matching preview route and preserves any signed preview ticket. Only root-relative paths are accepted.

Both storage areas accept JSON-compatible values through get, set, and remove. They are namespaced by installation. On a published site they use the browser’s corresponding Web Storage area. Because an isolated preview has an opaque origin, preview storage is tab-scoped and survives navigation inside that preview; preview local intentionally has the same lifetime as preview session. Values are not added to the URL, referrer, generated HTML, or Typeroll request body.

Preview changes the page that initiates browser requests: bundled code runs from the portal preview rather than the published site’s domain. A third-party browser key restricted by HTTP referrer or page origin must allow the portal’s preview origin to work there. Prefer a separate, narrowly restricted preview credential; do not loosen the production key just to make preview requests pass.

A native Navigation inputs block (core/navigation_form) in navigate mode lets a visitor start on one page, for example by typing two addresses and a floor area, and continue to the page that holds your component. The values stay in the browser tab. Your component receives them with context.handoff:

const handoff = context.handoff ? await context.handoff.read() : null;
render({
from: handoff?.values.address_from ?? "",
fromPostalCode: handoff?.parts.address_from?.postal_code ?? "",
to: handoff?.values.address_to ?? "",
area: handoff?.values.m2 ?? "",
});

read() resolves null or an object like this:

{
"key": "moving",
"source": "navigation_form",
"values": {
"address_from": "Storgatan 1, 111 22 Stockholm",
"address_from_street": "Storgatan",
"address_from_street_number": "1",
"address_from_postal_code": "111 22",
"address_from_locality": "Stockholm",
"address_to": "Kungsgatan 2, Uppsala",
"m2": "75"
},
"parts": {
"address_from": {
"street": "Storgatan",
"street_number": "1",
"postal_code": "111 22",
"locality": "Stockholm"
}
}
}

values holds every field as the visitor typed it, as strings of at most 512 characters, keyed by the navigation form’s field names. When the visitor picked an address suggestion and did not edit it afterwards, the structured parts street, street_number, postal_code, locality, country and formatted follow as <field>_<part>. parts groups the same parts by field. Map the names your form needs; do not assume a field or part is present.

Access needs all three of:

  1. the component declares "page_handoff": true and the manifest requests page_handoff:read;
  2. the site administrator grants page_handoff:read when installing or approving the release;
  3. the page author binds the block instance to the navigation form. Such components get a Prefill from navigation inputs field (block data page_handoff_key) that takes the navigation form’s Defaults key. In an HTML-mode page use <x-extension block="…" props='{"page_handoff_key":"moving"}' />.

The handoff must also be addressed to the current page, which is the navigation form’s destination, and be less than 15 minutes old. Otherwise, and whenever nothing was handed off, read() resolves null without an error; render the normal empty form. context.handoff.key is the bound key, or null for an unbound instance.

Like a native receiving form, the runtime takes the handoff out of the tab the first time a bound component reads it, so a reload starts empty. Every bound component on that page receives the same values, and repeated read() calls in a mount return them again. Keep them in context.storage.session if your flow needs them after a reload. A native receiving form with the same key on the same page takes the values first.

It works the same in preview links, where the preview shell keeps the handoff for the tab instead of the browser’s session storage. In the editor canvas read() resolves null.

The stored format is private to Typeroll and may change in any Core release. Read handoffs only through context.handoff; never read Web Storage or call page globals for them. The values are never added to a URL, referrer, generated HTML, analytics event or Typeroll request. Treat them as personal data: do not log them or send them anywhere before the visitor submits.

context.handoff exists from runtime 0.43.0. Declare "runtime_compatibility": ">=0.43.0 <1.0.0" in a release that uses page_handoff, because earlier runtimes reject the scope. Embedded applications post typeroll.extension.handoff.read with a unique request_id and receive typeroll.extension.handoff.result with the same handoff value.

Declare only the URL inputs that the component needs:

{
"url_context": {
"query": [
{
"name": "quote",
"expose_as": "quote_token",
"sensitive": true,
"consume": true,
"max_length": 256,
"pattern": "^[A-Za-z0-9_-]+$"
}
]
}
}

The provider can email several recipients links to the same static Typeroll page, for example /offer/?quote=opaque-value. At mount time, Typeroll:

  1. reads only declared query, fragment, path, or raw-query inputs;
  2. rejects values that exceed the declared length or pattern;
  3. captures values in the component’s private runtime closure;
  4. removes inputs marked consume with history.replaceState; and
  5. exposes the captured value through context.url.

From Core 0.2.18, cleanup preserves ordinary URL fragments exactly when no fragment parameter is consumed. Removing ?quote=opaque-value therefore leaves #details unchanged. Consuming a declared fragment parameter removes that parameter and retains the remaining fragment parameters. The exported cleanup helper and browser runtime behave the same way. Republish existing sites after upgrading Core to receive the corrected runtime.

The token is not initial block state and is never inserted into generated HTML, saved page data, Typeroll’s datastore, or diagnostics.

const quoteToken = context.url.consume("quote_token");
await context.api.fetch("/quotes/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: quoteToken }),
});

The provider remains responsible for generating a cryptographically random token and enforcing expiry, revocation, intended recipient, allowed actions, and replay behavior. A difficult-to-guess URL is a bearer credential, not an authorization model by itself.

Manifest v3 declares api.base_url, routes and an authentication mode. context.api.fetch() resolves only declared relative paths, sends the browser request directly to that provider URL with credentials: omit, and does not pass through Typeroll or Cloudflare Pages Functions.

With authentication: "signed_installation", the runtime first obtains a five-minute JWT from the CMS issuer and adds it as X-Typeroll-Extension-Token. The token confirms the enabled installation and requesting site origin. It is not a visitor login and does not authorize a quote, booking or wedding plan; the provider must still validate its own recipient token and requested action. The provider must allow the installed site origin in CORS and accept X-Typeroll-Extension-Token in its preflight policy.

An embedded_app runs in a sandboxed provider-origin iframe. It receives a versioned typeroll.extension.init message and can request resize or internal navigation with the matching protocol messages. Validate message origin, source window, protocol version, installation ID, and component ID on every message.

For the first init message, require event.source === window.parent and an HTTPS event.origin, then lock that exact origin for the frame session. Do not derive it from document.referrer; the Typeroll host intentionally applies a no-referrer policy to Extension iframes.

An embedded app receives binding names in typeroll.extension.init but never receives the signed form tokens. It can request a submission with typeroll.extension.form.submit; the host responds with typeroll.extension.form.result after validating the frame, origin, installation, component, binding, and request IDs.

For declared provider routes, the frame sends typeroll.extension.api.request with a unique request_id, relative path, and optional method, headers, and body. The site host performs the same direct, route-constrained request as context.api.fetch() and returns a serializable typeroll.extension.api.result. This browser-side message bridge does not relay traffic through Typeroll infrastructure or a hosting Function; the network request still goes straight from the published site to the provider API. Responses above 1 MiB fail closed.

Next, implement the provider backend and admin SSO.

First-party Analytics context (runtime 0.40.0)

Section titled “First-party Analytics context (runtime 0.40.0)”

Bundled components receive context.analytics, either null or an object with endpoint, token, and requires_consent. This contains a public site event token, never a service credential. Respect the site’s optional-consent state before sending. The CMS accepts only the site’s configured event rules; see Events and conversions.

Schema properties declared public: true can be edited independently on a site version. The portal app’s configuration editor follows the selected version. For automation, pass the same version to list_extension_installations, read_extension_installation, and update_extension_installation_config. REST clients use ?version=redesign on the installation GET or PATCH endpoint:

{ "config": { "heading": "Try the new introduction" } }

Only schema-declared public fields can change on a branch. Credentials, private settings, permissions, installation status and app releases remain site-wide; manage those on main. version in an installation PATCH body still means an app release; the query parameter selects the site version. An unknown site version is rejected rather than falling back to main.

The public configuration follows the existing Settings copy-on-write, diff, reset and promotion rules. Preview and static publication resolve the selected version. Frozen publications retain their saved configuration. A branch edit never changes the main installation or publishes production: the MCP config tool’s automatic deploy targets staging for a branch, production for main. Use deploy: false to save several edits before an explicit publication.

Native portal apps access this via sdk.configuration.read() and .save(); sdk.context.site_version identifies the selected site version, while sdk.context.version is the app release. This does not branch provider-owned records, forms, credentials, external sessions or arbitrary app API calls.