Guide

Production & deployment.

Aktion is a self-contained web component, so shipping it is mostly “serve a static bundle and mount the element.” The wrinkles are SSR/hydration through the shadow DOM, Content-Security-Policy, and wiring an LLM stream from your edge. This guide covers each.

The whole picture

A production Aktion app has three moving parts: a static bundle (the runtime) served from your host or a CDN, the <aktion-app> element mounted in your page, and — for AI surfaces — an edge function that proxies the LLM and streams tokens back. Everything below is about wiring those three together safely.

Serving the bundle

Run npm run build and serve dist/ from any static host — every artifact in dist/ is self-contained, including the CSS (it is injected into each instance’s shadow root, so the host page needs no extra stylesheet). Or load it from the CDN so it is cached across pages and apps:

<script type="module" src="https://cdn.example.com/aktion@0.6.5/dist/aktion.js"></script>
<aktion-app theme="dark"></aktion-app>

Pin a version (@0.6.5) for reproducible builds and set a long Cache-Control max-age on the immutable bundle.

Because the runtime is a plain static ES module, any static host works — Netlify, Vercel, Cloudflare Pages, GitHub Pages, S3 + CloudFront, or your own nginx. There is no server runtime to provision for the UI itself; the only server-side piece you may need is the edge function that proxies your model (see below). For mounting the element inside React, Vue, Svelte, or Angular, see Frameworks.

Building with the Vite plugin

If your app imports .aktion files rather than streaming program text, aktion-runtime/vite links the module graph at build time. Its resolver confines every .aktion import to the Vite project root: a specifier that resolves outside config.root is refused instead of read, because under vite dev the file’s contents would be handed straight to the browser.

// vite.config.ts
import { defineConfig } from "vite";
import aktion from "aktion-runtime/vite";

export default defineConfig({
  plugins: [
    // allowOutsideRoot defaults to false — imports stay inside config.root.
    // strict defaults to false — turn it on to fail the build on linker warnings.
    aktion({ allowOutsideRoot: true, strict: true }),
  ],
});

This is a breaking change for monorepos. A build that imported a .aktion file from a sibling package now fails with [aktion] refusing to read "…" — outside the project root until you widen root or opt out with allowOutsideRoot: true — and only do the latter for a layout whose sources you trust. See Modules for the import graph itself.

SSR & hydration

For server-side rendering and static generation, import renderToString(program, { path, initialState }). It returns four fields, not two: html, state, head, and headAttrs — so one call gives you crawlable markup, a hydration snapshot, and everything the page shell’s <head> needs.

import { renderToString } from "aktion-runtime";

const { html, state, head, headAttrs } = renderToString(program, {
  path: "/dashboard",            // seeds the router for the requested route
  initialState: { user: me },    // pre-populate reactive atoms
  container: true,               // true is the default — wraps output in one element
});
// html      → embed in your shell
// state     → inline for the client to hydrate
// head      → inject into the shell's <head>
// headAttrs → spread onto <html>, e.g. { lang: "en", dir: "ltr" }

head and headAttrs carry whatever $head({...}) the program ran during this render — title, meta, Open Graph and Twitter cards, links, JSON‑LD. Both come back empty when no $head ran. Document head is the reference for those two fields and for the allow-lists that decide what survives into them.

renderToStaticMarkup(program, opts) returns the html field on its own, for fully static pages with no hydration.

Both functions need a DOM, so in a Node entry register happy-dom or jsdom on globalThis first — without one, renderToString throws rather than quietly returning empty markup. For a DOM-free check that a program renders at all, see Verify a deploy.

SSR executes the program on your server

In the browser an untrusted program is same‑origin XSS; on your server the same program is remote code execution. renderToString evaluates it, and by default an Aktion program resolves unshadowed identifiers against globalThis — which under Node means the filesystem, your internal network, and process.env.

So if the program text is anything less than fully trusted — LLM output, a tenant record, a user-editable template — call setGlobalAccessPolicy("safe") in the server entry before rendering, and run that entry as a separate low‑privilege process. See Node-side tooling.

// Node SSR entry — set the policy once, before anything renders.
import { setGlobalAccessPolicy, renderToString } from "aktion-runtime";

setGlobalAccessPolicy("safe");   // process-global, not per-render
const { html, state } = renderToString(programFromTheModel);

The policy is process-global, so one call at bootstrap covers every subsequent render. Under "safe" a program can no longer reach eval, Function, the DOM, bare fetch, or localStorage; $http and storage still work, so restrict outbound origins at the host too.

Escaping the hydration snapshot is your job. state comes back as a plain object and the runtime does no escaping for the <script> context. Emit it inside <script type="application/json"> and JSON.parse it on the client, or escape < yourself — a string atom containing </script> otherwise closes the tag early and turns your own markup into an injection point.

On the client you can also persist and resume reactive state so a reload (or the server-rendered shell) restores the app without re-running the LLM:

MethodUse
serializeState()Returns every reactive atom as a flat, JSON-friendly { name: value } object — persist it (cookie, KV, localStorage) for SSR / resumption.
hydrateState(snapshot)Applies a snapshot to the live store and schedules a re-render. Atoms not in the snapshot are untouched; names the program never declares are written but never read.
loadSnapshot({ programText, state })Sets the program and the state in one shot, so the next render plans the program with the hydrated values already in place. Prefer this over two calls — see Rollback.
// On unload / checkpoint — store the program text with the state.
await fetch("/session", {
  method: "PUT",
  body: JSON.stringify({ programText: app.response, state: app.serializeState() }),
});

// On next load — restore both atomically.
app.loadSnapshot(await loadSession());

loadSnapshot clears the previous program’s defaults before seeding, so an atom the old program declared cannot survive into the new one with a stale default. Splitting the restore into setResponse then hydrateState also works, but renders once in between.

Content-Security-Policy

You do not need 'unsafe-eval'

The runtime uses neither eval nor new Function — there are zero occurrences of either in the shipped bundle. Action and effect bodies are interpreted from the AST by a tree-walking evaluator, not compiled to a function. So Aktion itself runs happily under a script-src with no 'unsafe-eval'.

The one case that still needs it is a program that calls eval or Function itself, which the default "all" global-access policy makes reachable. Closing that is what setGlobalAccessPolicy("safe") is for — and it is precisely what makes dropping 'unsafe-eval' meaningful when program text is not fully trusted.

script-src is not the interesting directive here, though. The runtime renders inline styles and can pull two external stylesheets, so a policy that tightens only script-src yields an app with no icons, no spacing, and a shadcn / mui / heroui / signal theme stuck on system-ui. Here is a policy that actually works:

Content-Security-Policy:
  default-src 'self';
  script-src  'self' https://cdn.example.com;
  style-src   'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com;
  font-src    'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com;
  img-src     'self' data: blob: https:;
  connect-src 'self' https://api.your-llm.example.com;
DirectiveWhy Aktion needs it
style-src 'unsafe-inline' Two paths need it. Components emit inline style attributes for token-derived and measured values (Container.maxWidth, Skeleton.height, chart geometry, the floating layer's positioning), and the runtime injects <style> elements for theme tokens, the Styles component, and as the component-CSS fallback on engines without constructable stylesheets. A nonce or hash strategy for those elements is the stricter alternative. Where style-src-attr is supported you can scope the exemption to attributes only.
cdnjs.cloudflare.com in style-src + font-src Font Awesome. The runtime injects the stylesheet on first Icon use, with SRI, crossorigin="anonymous", and referrerpolicy="no-referrer". Self-host the stylesheet instead if you cannot accept a third-party CDN.
fonts.googleapis.com / fonts.gstatic.com Web fonts requested by $theme({ fonts: { import: […] } }), and by the shadcn / mui / heroui / signal built-in themes, which declare Geist, Roboto, Inter and IBM Plex respectively (see built-in themes). Omit both hosts if you use no web fonts.
img-src data: blob: Image, Avatar, MediaCard and friends accept data:image/* and blob: sources — the latter is how a local FileUpload preview renders. Narrow https: to the hosts you actually serve images from.
connect-src Every $http({...}) destination, plus your LLM streaming endpoint.

Aktion never injects a <script> element and never assigns innerHTML on the live document, so script-src needs neither 'unsafe-inline' nor 'unsafe-eval' — keep both out. For what CSP does and does not buy you here, see Security & the trust model: a restrictive CSP hardens the host page, but it is not what keeps an untrusted program contained. That is setGlobalAccessPolicy("safe")’s job.

Rolling a policy out with Report-Only

Never enforce a new policy blind. Ship it first as Content-Security-Policy-Report-Only: the browser evaluates every directive and reports each violation, but blocks nothing — so a directive you got wrong lands in your report endpoint instead of shipping a blank page.

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src  'self' https://cdn.example.com;
  style-src   'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com;
  font-src    'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com;
  img-src     'self' data: blob: https:;
  connect-src 'self' https://api.your-llm.example.com;
  report-uri  /csp-report;

The two headers are independent, so you can keep a known-good enforcing policy live while a tighter candidate runs in report-only beside it. report-uri is the widely-implemented directive; report-to plus a Reporting-Endpoints response header is its modern replacement, and sending both is the usual hedge.

  1. Deploy the candidate policy as report-only and leave the enforcing header alone (or absent).
  2. Exercise the app: mount it, switch themes, render an Icon, upload a file, open every route, and let a generated program run end to end. Icons, web fonts, and blob: previews are the three that surface late.
  3. Read the reports. Group by effective-directive; each distinct blocked-uri is either a host you forgot or something you did not intend to load.
  4. Widen the policy for the legitimate ones only, then promote the header to enforcing and keep report-only on the next candidate.

Expect style-src and font-src reports on the first pass even from a correct app — both external stylesheets load lazily, Font Awesome on the first Icon and Google Fonts on the first theme that declares fonts, so neither appears until something actually renders.

Constraining the model to declarative output

Dropping 'unsafe-eval' only breaks something if a generated program calls eval or Function. Two levers close that gap and they are not interchangeable: the prompt asks, the policy enforces.

// 1. Ask — append hard rules to the generated system prompt.
const prompt = app.getSystemPrompt({
  additionalRules: [
    "Never call eval, Function, or WebAssembly.",
    "Never touch window, document, globalThis, localStorage, or fetch directly.",
    "Use $http for network calls, storage for persistence, $effect for side effects.",
  ],
});

// 2. Enforce — narrow what any program can reach, once at host bootstrap.
import { setGlobalAccessPolicy } from "aktion-runtime";
setGlobalAccessPolicy("safe");

additionalRules is appended to the prompt as an “Additional rules” section, so the model sees it next to the component catalogue. getSystemPrompt(options) is the host-element method; generatePrompt(library, options) is the standalone form.

Step 1 reduces how often the model reaches for a host global. Only step 2 makes it impossible — which is why a script-src without 'unsafe-eval' is meaningful with both in place and merely optimistic with step 1 alone.

Check your own programs before you switch

$script is the one feature the policy switches off. $script({ src }) is disabled outright under any policy other than "all": the resource comes back with an error set and ready stays false, so a program that gates on ready degrades rather than crashing.

Everything else that stops working is listed in what breaks under safe. The shortlist that bites a production app: bare fetch, localStorage, crypto, and document.

Integrity & pinning

Rollback & version pinning

An Aktion deploy has two independently versioned parts: the runtime bundle and the program text. They fail differently and they roll back differently, so pin and revert them separately.

PartPin it byRoll back by
Runtime bundle An exact version in the URL (aktion@0.6.5), never a range or a floating tag, plus an integrity hash when it is cross-origin. Pointing the <script> back at the previous exact URL. Keep the old immutable path reachable — a long Cache-Control is what makes the rollback instant, and deleting the artefact is what makes it impossible.
Program text Whatever store you keep it in — a git commit, a KV entry, a row id. Record the id you shipped. Re-mounting the previous text with setResponse / loadSnapshot, or re-deploying the previous .aktion build.

Because the two are independent, always test the pair you intend to ship. Rolling the bundle back alone is the trap.

A program that uses a component the older bundle does not have renders a Skeleton placeholder instead of failing loudly, and a prop the older bundle does not know is dropped with only a schema diagnostic. The UI comes up — just with holes in it.

Do serializeState() snapshots survive a version bump?

A snapshot is a flat { atomName: value } map of the reactive atoms as they stood. There is no schema, no embedded version marker, and no migration hook. So compatibility is not really a function of the runtime version at all — it is a function of the program that declared those atoms.

Rename $user to $currentUser and every stored snapshot quietly stops restoring that value: hydrateState writes the unrecognised name into the store, the new program never reads it, and nothing warns. Change an atom’s shape — a string that becomes an object — and the old value is restored as-is into code that no longer expects it.

The fix is to treat the snapshot and the program as one artefact: persist them together and restore them together with loadSnapshot({ programText, state }).

Stamp your own version or content hash on the stored payload, and discard any snapshot whose stamp does not match the program you are about to mount. A fresh render from defaults is a far better failure than half-restored state.

COMPILED_PROGRAM_VERSION guards a different artefact

It is not a state-snapshot version. COMPILED_PROGRAM_VERSION (currently 1) is the schema version of the compiled program that linkProject() and compileLite() produce and mountCompiled() consumes.

Every artefact carries the stamp and mountCompiled checks it: a payload built by an incompatible compiler is refused with a console error rather than mounted as garbage. The constant is bumped only on a breaking change to the CompiledProgram shape, and it says nothing at all about serializeState() output.

Practical consequence: if you cache linked artefacts (a build output, a KV blob, a service-worker entry), key the cache on both the runtime version and COMPILED_PROGRAM_VERSION. A stale artefact from an incompatible compiler does not render a broken UI — it renders nothing, with one line in the console.

Edge-function LLM streaming

The common production shape is: the browser holds the <aktion-app>, an edge function proxies the LLM (keeping your API key server-side), and tokens are streamed back and fed to appendChunk:

const res = await fetch("/api/generate", { method: "POST", body: prompt });
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  app.appendChunk(decoder.decode(value, { stream: true }));
}

See the LLM integration guide for provider-specific stream parsing and prompt selection.

Error reporting & telemetry

Verify a deploy

Two checks catch nearly every bad Aktion deploy. The first runs in CI before you publish and needs no browser at all; the second runs against the deployed origin and answers “did the bundle actually load?”

Does the program render? (CI, no DOM)

renderToTextTree(program) parses, schema-validates, and evaluates a program into an indented text outline — without a DOM, so plain node runs it with no happy-dom or jsdom setup. ok is the machine signal, errors explains a failure, and text is what you want in the CI log.

import { renderToTextTree } from "aktion-runtime";
import { readFileSync } from "node:fs";

const { ok, text, errors } = renderToTextTree(readFileSync("src/app.aktion", "utf8"));
if (!ok) {
  console.error(errors.join("\n"));
  process.exit(1);
}
console.log(text);   // an indented outline: <Column> → <Text> → "Invoices"

It reports parse errors, schema violations, a program with no $app(...) root, a root that renders nothing, a bare-string root, and any throw out of the entry point or a user component. That last set is exactly what a parse-only gate misses — a program can parse perfectly and still render an empty tree:

Programerrors[0]
$app(Card([Text("hi")], { gap: "md" }))schema 1:6: Unknown prop "gap" on <Card>. Known props: children, variant, padding, onClick, href.
$x = 1render: program has no UI root — add `$app(...)` (or `aktion = ...`).
$app(null)render: the UI root is empty (renders nothing).
$app("hello")render: the UI root is a bare string, not a component tree (root-not-renderable).

Wire it as a build step over every program you ship. If your app is multi-file, link it first — the Vite plugin does that for you, and Modules covers the entry-binding rule the linker checks.

Smoke-test the deployed page

A rendered deploy and a loaded deploy are different questions. The single most common production failure is a bundle that 404s or is blocked by CSP, which leaves <aktion-app> in the DOM as an inert unknown element — no error, no banner, just nothing.

// Run in the deployed page's console, or from Playwright / Puppeteer.
const app = document.querySelector("aktion-app");
console.log("upgraded:", !!app.shadowRoot);                        // false → bundle never loaded
console.log("route:", app.route);                                  // router's current path
console.log("atoms:", Object.keys(app.serializeState()).length);   // 0 → nothing planned
app.addEventListener("error", (e) => console.error(e.detail.errors));

app.shadowRoot is the reliable liveness signal — the element attaches an open shadow root in its constructor, so a truthy shadowRoot proves the custom element was defined and upgraded. Everything else can only be non-empty once a program has planned.

Pre-launch checklist

Next