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.5/dist/aktion.js"></script>
<aktion-app theme="dark"></aktion-app>
Pin a version (@0.5.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).
SSR & hydration
For server-side rendering and static generation, import
renderToString(program, { path, initialState }) — it
returns { html, state } so you can embed crawlable markup in
your page shell and serialise state for client hydration.
renderToStaticMarkup(program, opts) emits markup only, for
fully static pages. The renderer needs a DOM, so in Node register
happy-dom / jsdom on globalThis first.
import { renderToString } from "aktion-runtime";
const { html, state } = renderToString(program, {
path: "/dashboard", // seeds the router for the requested route
initialState: { user: me }, // pre-populate reactive atoms
});
// Embed `html` in your shell and inline `state` for the client to hydrate.
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 plain JSON-friendly 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. |
// On unload / checkpoint:
const snapshot = app.serializeState();
await fetch("/session", { method: "PUT", body: JSON.stringify(snapshot) });
// On next load, after setting the program text:
app.setResponse(savedProgram);
app.hydrateState(await loadSnapshot());
Content-Security-Policy
The bundle does not use eval. However, effect
and action bodies are evaluated with new Function(...) so that
the DSL can run arbitrary JS expressions — that requires
'unsafe-eval' in your script-src.
If you cannot relax CSP, you can still run Aktion: avoid emitting complex
JS expressions from the LLM. Declarative constructs — component
trees, $http(), and the $util helper
namespace — keep working without 'unsafe-eval'.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-eval' https://cdn.example.com;
connect-src 'self' https://api.your-llm.example.com;
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.
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.
Pre-launch checklist
- Runtime bundle is served with a pinned version and a long-lived
Cache-Control(and anintegrityhash if loaded cross-origin). script-srcallows'unsafe-eval'— or the LLM is constrained to declarative output (see CSP).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).- Session resumption is tested:
serializeState()→ persist →hydrateState()restores state on reload.