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:
| Method | Use |
|---|---|
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;
| Directive | Why 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.
- Deploy the candidate policy as report-only and leave the enforcing header alone (or absent).
- 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, andblob:previews are the three that surface late. - Read the reports. Group by
effective-directive; each distinctblocked-uriis either a host you forgot or something you did not intend to load. - 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
- Add a Subresource Integrity hash to the CDN
<script>(integrity="sha384-…"withcrossorigin="anonymous") when loading from a third party. - Pin the exact version in the URL so a CDN update can never change your runtime behaviour without a deploy.
- External links rendered by the library already get
rel="noopener noreferrer", and the href/image/CSS sanitizers run regardless of CSP.
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.
| Part | Pin it by | Roll 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
- Listen for the host
errorevent and forwarde.detail.errorsto your error tracker (Sentry, etc.). See Error handling. - Listen for
assistant-messageto capture round-trips back to the model, androute-changefor analytics page views. - Install
registerHttpInterceptors({ onRequest, onResponse, onError })to add auth headers, retry on 401, and log network timing centrally. - Keep the
strictattribute on in staging so silent fallbacks surface as console warnings before release.
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:
| Program | errors[0] |
|---|---|
$app(Card([Text("hi")], { gap: "md" })) | schema 1:6: Unknown prop "gap" on <Card>. Known props: children, variant, padding, onClick, href. |
$x = 1 | render: 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.
- The bundle responds 200 with a JavaScript content type
and the immutable
Cache-Controlyou intended — a misconfigured host that servestext/htmlfor a missing file fails the module load with no network error. - The console carries no CSP violations. Icons and web
fonts load lazily, so click through at least one
Iconand one themed route before you call it clean (see Report-Only). - The
errorevent fired zero times on first paint. Forward it to your tracker permanently — see telemetry. - Session resumption round-trips:
serializeState()→ persist →loadSnapshot()restores the same UI (see Rollback). strictis off in production and on in staging. If your staging console is quiet withstricton, the silent fallbacks troubleshooting describes are not firing.
Pre-launch checklist
- Runtime bundle is served with a pinned version and a long-lived
Cache-Control(and anintegrityhash if loaded cross-origin). - Program-text trust is decided, not assumed. Leaving the default
"all"policy means the program is as privileged as a<script>in your page; anything less than fully trusted needssetGlobalAccessPolicy("safe")plus a cross-origin sandboxed iframe (see Security). - Any SSR entry that renders program text you did not author calls
setGlobalAccessPolicy("safe")before the firstrenderToString, and runs in a low-privilege process (see SSR). style-srcallows'unsafe-inline'(or carries a nonce/hash), andfont-srccovers any web fonts you use —script-srcneeds no'unsafe-eval'(see CSP).- The policy shipped as
Report-Onlyfirst and its report endpoint was quiet before you promoted it to enforcing (see rollout). connect-srclists every API the generated programs call.- The LLM API key lives server-side in an edge function; the browser never sees it.
- The host
errorevent is wired to your error tracker. strictstays on in staging and off in production (where partial output is expected).- Every program you ship passes a
renderToTextTreegate in CI, and the deployed page passes the liveness check (see Verify a deploy). - Session resumption is tested:
serializeState()→ persist →loadSnapshot()restores state on reload, and the stored payload carries the program text it belongs to (see Rollback). - Rollback is rehearsed. The previous bundle URL is still reachable, the previous program text is still addressable, and you have reverted both together at least once.
Next
LLM integration
Wire OpenAI / Anthropic / OpenRouter streams into appendChunk.
Error handling
The error event and the runtime guards.
Security & the trust model
Who is trusted, what setGlobalAccessPolicy buys you, and every sanitised sink.