Core concepts

Modules & multi-file apps.

A single program is one flat list of statements — great for a streamed reply, cramped for a real app. Modules let you split an app across many .aktion files that import and export components, variables, functions, and reactive $state, with JavaScript-like syntax and true per-file scope. The files are linked in the browser — no build step — or ahead of time by the Vite plugin, from the same linker. A module can also be written in TypeScript or JavaScript (.aktion.ts, .aktion.js).

A first split

Here is a two-file app. app.aktion is the entry — it holds the $app(…) root and pulls in a component declared in Greeting.aktion. Editing either file re-links the whole program; the preview shows the linked result.

Live · multi-file
app.aktion entry
import { Greeting } from "./Greeting.aktion"

$app(Greeting("Aktion"))
Greeting.aktion
export function Greeting(name) {
  return Card([
    CardHeader(`Hello, ${name}!`, { subtitle: "Rendered from an imported file" })
  ])
}

Exporting

Prefix any top-level declaration with export to make it importable from another file. Anything not exported is private to its file. Every binding kind can be exported — components, actions, hooks, reactive $state, and plain bindings:

// components.aktion
export function Card2({ title }) {        // a component
  return Card([CardHeader(title)])
}

export function track(name) {             // an action (lowercase)
  console.log(name)
}

export function $useToggle() {            // a hook ($-prefixed)
  $on = $state(false)
  return { on: $on, toggle: () => { $on = !$on } }
}

export $count = 0                         // a reactive atom
export accent = "var(--rui-primary)"      // a plain binding

export of a destructuring declaration is not supported — export each binding inline, or list the names it declares (below).

Export lists and re-exports

An export { … } list exports bindings the module already declares, with as to rename them, and a $ atom keeps its $ on both sides. It does exactly what writing export before the declaration does — the same atom, shared by every importer — and the declaration may come before or after the list. A name can be exported once.

let $count = 0
function increment() { $count += 1 }
export { $count, increment as bump }

export { … } from "…" and export * from "…" forward another module's exports without binding them in this file, so a barrel file can gather a library behind one import. The source is linked where the statement stands (see link order). Naming something the source does not export is a link error, as is a name two export * statements supply from different modules; an explicit export always wins over a star. A name that was imported cannot be listed — re-export it with from. Aktion has no namespace objects, so export * as ns from "…" is rejected.

// barrel.aktion
export { $count, bump } from "./counter.aktion"
export { Card2 } from "./components.aktion"
export * from "./format.aktion"

Importing

Import named bindings with import { … } from "…". Rename with as, and import reactive state by keeping its $ sigil on both sides:

import { Card2 } from "./components.aktion"
import { Card2 as PanelCard } from "./components.aktion"   // aliased
import { $count, increment } from "./store.aktion"          // a shared atom + an action
import { $count as $total } from "./store.aktion"           // $ stays across `as`

Names are matched by their bare name, so export $count lines up with import { $count }. A $state import must keep its $ across as ({ $a as $b }) — mixing { $a as b } is a syntax error. from and as are ordinary identifiers everywhere else, so existing code that uses them as variable names keeps working.

A specifier list may span several lines and may end with a trailing comma, so a long import formats the way you would format it in JavaScript:

import {
  Button,
  Card,
  PrimaryButton,
} from "./ui.aktion"

All three names bind exactly as they would on a single line — the layout carries no meaning.

If a multi-line import used to vanish, this is why

Multi-line specifier lists used to fail to parse. Because parse() records the error and recovers to the next statement, the entire import disappeared while the program still “parsed” — leaving every imported name undefined and every reference to it silently null. Both the line breaks and the trailing comma parse correctly now, so a program that was quietly missing bindings starts working without any edit.

True per-file scope

Each file is its own scope. A file's non-exported top-level names are private, so two files can reuse the same name without clashing — the linker renames each file's private symbols behind the scenes. Only the names you import cross the boundary.

// Button.aktion
icon = "bolt"                              // private to this file
export function PrimaryButton({ label, onClick }) {
  return Button(label, { variant: "primary", icon: icon, onClick: onClick })
}

// Link.aktion
icon = "arrow-up-right-from-square"        // a DIFFERENT, private `icon`
export function LinkButton({ label, onClick }) {
  return Button(label, { variant: "ghost", icon: icon, onClick: onClick })
}

Built-in library components (Button, Card, Column, …) and runtime globals ($util, route, console) are resolved by the runtime, not by a module, so they are always available without importing — and never renamed.

Sharing reactive state

Export a $state atom and the files that import it read and write the same reactive cell. This is the simplest way to share state across an app — a tiny store in its own file:

Live · shared store
app.aktion entry
import { $count, increment } from "./store.aktion"

$app(Column([
  Card([
    CardHeader("Shared store"),
    Text(`Count: ${$count}`),
    Button("Increment", { variant: "primary", onClick: increment })
  ])
], { gap: "lg", align: "center", padding: "xl" }))
store.aktion
export $count = 0

export function increment() {
  $count = $count + 1
}

The entry file keeps its own names canonical: the $state atoms you declare or import into app.aktion are exactly the names that serializeState(), hydrateState(), and applyDelta() target.

Writing shared state from another file

Two shapes work for state that several files change, and both are shared by every importer (measured with a toggle written from the entry and from an imported component: either button flips the text in both):

// 1. An exported atom plus an exported action that writes it
// ui.aktion
export let $open = false
export function toggle() { $open = !$open }

// any importer
import { $open, toggle } from "./ui.aktion"
Button("Toggle", { onClick: toggle })

// 2. An exported store, written by property
// ui.aktion
export const ui = $store({ open: false })

// any importer
import { ui } from "./ui.aktion"
Button("Toggle", { onClick: () => { ui.open = !ui.open } })

Declare the atom with let (export let $open = false): a bare export $open = false becomes a const under the ESLint processor, and the setter’s write is then reported as no-const-assign.

Writing an imported atom directly — () => { $open = !$open } in a file that only imports $open — also works at runtime. It is the one shape JavaScript tooling flags, because in JavaScript an import binding is read-only (ESLint’s no-import-assign), so prefer the two above. A shared module may not export a $store(…) factory: a store is created once per source location, so every caller of a factory would get the same instance — export the store itself, as above.

Which shape to use

Prefer the exported setter action. Every write to the atom then lives in the module that declares it, the importers are plain JavaScript (they only call a function), and nothing about reactivity changes. Measured on the runtime, for an atom or store read in three modules:

Setter actionStore propertyDirect write
Readers in other modules updateYesYesYes
Valid JavaScript in the importerYesYesNo (no-import-assign)
$effect in the owner and each importer re-runsYes, in link order (owner first)No: [ui.open] is not a valid dependency, and an effect with no list runs onceYes, same order
Called at the top level of an importerWorks: setOpen(true) sets itWorksDoes nothing: a top-level $x = … is a set-once declaration and the imported atom wins
Called while renderingA function setter does nothing; an arrow setter applies without a re-render and logs the render-write warningApplies without a re-render and logs the same warningA component body gets a private copy that other modules never see; in a lowercase function it does nothing
Compound writes (+=, ++, array updates)Inside the setter, in the ownerBy propertyWorks
Survives formatProgram unchangedYesYesYes

Use a store when the shared value is naturally a record read and written by property and nothing needs to $effect on it; use an atom with a setter when it is a single value, a list that is replaced, or the target of an effect. Do not write shared state while rendering with either shape: do it from a handler or an effect.

// ui.aktion
export let $open = false
export const setOpen = (value) => { $open = value }

// any importer
import { $open, setOpen } from "./ui.aktion"
Button("Toggle", () => setOpen(!$open))

The imported-state-write lint finds the direct writes in a program. It is on by default in getLintWarnings, getDiagnostics, tools/validate-aktion.mjs and tools/validate-aktion-app.mjs; turn it off with { importedStateWrites: false } or --no-imported-state-writes. Each warning names the imported atom, the module it comes from and the setter to add. To migrate a site, add one setter per imported atom to the owning module (the warning spells it out: setMockClusters for $mockClusters) and replace $x = value with setX(value); a compound write becomes setX($x + 1). It is a warning, never an error, so the direct write still runs; a job that treats warnings as failures will report these writes.

The lint reads one file at a time, so it cannot tell the module that declares an atom from a barrel that re-exports it, and it does not check that the owner exports the setter. The setter belongs in the module that declares the atom (export let $open), and its body assigns the atom under that module's own name. When the import comes through a re-export (export { $open as $isOpen } from "./store.aktion"), add the setter to the declaring module (export const setIsOpen = (value) => { $open = value }) and re-export it from the barrel (export { $open as $isOpen, setIsOpen } from "./store.aktion"); a setter written in the barrel with $isOpen = value links and writes nothing. The name the warning suggests follows the name the imported module exports (setIsOpen).

The lint does not track scope, because $ names have none: a $-prefixed parameter or loop binding is a parse error, and a let $x = … or const $x = … inside a function or a handler does not shadow the import (measured on the runtime: it writes the shared atom). A declaration below the top level is therefore flagged like an assignment. delete $o.key on an imported atom changes nothing at run time and is not flagged.

Modules in TypeScript and JavaScript

The file name says which language a module is written in. All three compile to the same program, import each other, and share $ state:

FileLanguageNotes
counter.aktionThe Aktion DSLLinked everywhere, including in the browser.
counter.aktion.jsJavaScript in the Aktion subsetChecked for code that would behave differently from JavaScript; linked everywhere.
counter.aktion.tsThe same, with TypeScript typesThe types are erased by ts-blank-space, so it is compiled by the Vite plugin and the Node compile helpers — not by the in-browser linker.
counter.ts, counter.js, .json, .cssNative codeNot importable from an Aktion module: the import is refused with a message naming the file to rename.
// store.aktion.ts
export let $count = 0;
export function increment(step: number = 1): void { $count = $count + step; }
// app.aktion — imports the TypeScript module like any other
import { $count, increment } from "./store.aktion.ts"
$app(Button("n=" + $count, { onClick: () => increment() }))

aktion-runtime/dsl declares every built-in for TypeScript; an Aktion module may import from it (in any language), only by the built-in’s own name. Writing modules in TypeScript, and what Aktion does differently from JavaScript, is covered in the TypeScript guide.

Where modules resolve from

A specifier can be relative, absolute (from the project root), or a full URL. URL modules are fetched over the network when the project is linked, so you can pull a shared component straight from a CDN or gist:

import { Button } from "./Button.aktion"                     // same folder
import { Card2 } from "../components/Card2.aktion"           // parent folder
import { Nav } from "/layout/Nav.aktion"                     // from the project root
import { Hero } from "https://example.com/ui/Hero.aktion"    // remote, fetched on link

A relative import inside a URL module resolves against that URL, so a remote file can pull in its own neighbours. Bare specifiers (import { x } from "lodash") have no meaning and are reported as unresolved. These are the exact rules resolveSpecifier(spec, importerPath) applies:

SpecifierImporterModule key
./Button.aktionapp.aktionButton.aktion
../Button.aktioncomponents/App.aktionButton.aktion
/layout/Nav.aktionany project filelayout/Nav.aktion — from the project root, leading slash dropped
https://x.test/H.aktionanythe URL itself, normalised
./n.aktionhttps://x.test/ui/H.aktionhttps://x.test/ui/n.aktion
lodashanynull — reported as Cannot resolve import

Keys are normalised, so . and .. segments collapse before lookup (/a/../b.aktion → b.aktion) and two specifiers that name the same file share one module instance.

Root‑absolute and URL specifiers are browser‑only

The table above describes the in‑browser linker. The Vite plugin resolves against the Node filesystem instead, where a leading / means filesystem root, not project root — so "/layout/Nav.aktion" lands outside the project and is refused. URL specifiers are refused there too: that resolver only handles filesystem paths, and anything that is neither ./, ../, nor / is treated as a non‑project module. In a bundled project, use relative specifiers only.

On the filesystem, a specifier that names no file is completed in this order: .aktion, .aktion.ts, .aktion.js, then /index.aktion, /index.aktion.ts, /index.aktion.js — so ./store finds store.aktion.ts. A specifier that names a file exactly is not completed (./store.aktion when only store.aktion.ts exists reports Did you mean "./store.aktion.ts"?). Neither is the TypeScript habit of writing .js for a .ts file: ./store.aktion.js when only store.aktion.ts exists reports Did you mean "./store.aktion.ts"? too. The extensions option replaces this list outright, so a project that pins it must add the new suffixes itself.

Keep one language per module name

store.aktion next to store.aktion.ts is refused, whatever the specifier: "store.aktion" and "store.aktion.ts" both exist — keep one (TypeScript and Vite resolve "./store.aktion" to different files). TypeScript would type-check one file while the bundler built the other.

The entry file

Linking starts from one entry file — app.aktion in the playground. The entry is what defines the rendered root (a $app(…) statement, or the legacy aktion = … binding); imported files normally only declare and export. export default $app(…) is the same statement — it is what gives TypeScript a typed default export for import app from "./app.aktion.ts"; any other export default is an error. The linker walks every import from the entry, merges the whole graph into one program, and the runtime renders it — import cycles are fine (each file is merged once).

Host code imports only the default export. The compiled program is the one thing an Aktion module gives main.ts, a test or any other native module. Its named exports — components, helpers, atoms — exist inside Aktion programs, so import { summary } from "./api.aktion.ts" in host code fails the vite build (with this explanation on Vite 5–7; Vite 8 reports the missing export itself), and under vite dev or Vitest the binding throws the explanation as soon as it is used. (An export named then is left out there instead: with a callable then the module would be a thenable, and await import() would call it instead of returning the module.) To unit-test a helper, call it from a program (compileAktionSource, see Testing).

The linker walks the graph depth-first and merges each module after the modules it imports (a post-order walk), taking a file’s import statements in the order they are written. That is the order ES modules evaluate in, and the merged program’s top-level statements run in it: the entry comes last, and a dependency runs before its importer except within an import cycle (entry → a → b → a links b, a, entry, with no diagnostic, as with ES modules). Importing a then b links a, b, entry; importing b then a links b, a, entry; if a itself imports c, the order is c, a, b, entry. LinkResult.dependencies and program.sources (after the entry at index 0) report the same order.

So import order is meaningful. A tool that sorts, groups or otherwise reorders a file’s imports — an import-sorting lint rule with autofix, say — changes the order the modules’ top-level statements run in, exactly as it would for JavaScript modules with side effects.

When a link fails

Linking produces its own diagnostics, separate from parse errors. A problem found in an imported file is prefixed with that file’s path, so a broken import in a five‑file app points at the file that has the problem instead of at the entry; the entry’s own diagnostics carry no prefix.

MessageWhat happenedFix
Cannot resolve import "…" The specifier maps to no module key — a bare specifier, or one the active resolver refuses (see the resolution rules). Use a relative ./ / ../ path, or check the spelling.
Failed to load imported module "…" The key resolved but there is no source behind it — the file does not exist in the project. Create the file, or fix the path.
Failed to fetch module "…": … A URL import could not be fetched (network, CORS, non‑200). The rest of the graph still links. Check the URL and its CORS headers.
"./store.aktion" does not export `increment` The name exists in that file but is not exported, or an export { x } from re-exports a name its source lacks (reported at the specifier). State imports report the sigil: does not export `$count`. Add export to the declaration.
Cannot export `x`: this module declares no top-level `x`. An export { x } list names something the file does not declare (check the $ too: a and $a are different names). An imported name gets its own message pointing at export { x } from "…". Declare it, or fix the spelling.
`x` is exported more than once. Two exports — declarations, lists or re-exports — give the same name. Export it once, or rename one with as.
"./barrel.aktion" exports `x` from more than one module through `export *` Two export * statements reach different declarations of x. Export it by name (export { x } from "…") to choose one.
"./a.aktion" re-exports `x` in a cycle of re-exports that never reaches a declaration Re-exports of x point at each other and no file declares it. Declare it in one of the files.
Greeting.aktion: Unexpected token … A dependency’s own parse error, prefixed with its path. Fix the syntax in that file.
"./utils.ts" is not an Aktion module. Aktion modules end in .aktion, .aktion.ts or .aktion.js — rename it to utils.aktion.ts … An import of native code. It is never read; host code passes such values in instead. Rename the file to an Aktion suffix, or keep it native and pass values from the host.
"…" is a typescript Aktion module, but no typescript frontend is configured A .aktion.ts module reached a linker without a TypeScript frontend — the in-browser linker, or a Node tool that did not pass one. Build with aktion-runtime/vite, or pass frontends: { typescript: await loadTypeScriptFrontend() }.
E101 … E127, W201, W202 A .aktion.ts / .aktion.js construct that Aktion would run differently from JavaScript. See the TypeScript guide; each message says what to write instead.
Entry module "app.aktion" was not found The entry key is not present in the file map handed to the linker. Check the entry name against the keys you passed.

Where they show up depends on the host: <aktion-app src="…"> surfaces them in the error banner and through the error event alongside parse errors, the playground lists them in its error inspector, and the Vite plugin turns them into build failures.

A dependency’s syntax error used to be invisible

Only the entry’s parse errors used to travel out. parse() drops a malformed statement and recovers, so an imported file with a syntax error linked “successfully” minus whatever the parser threw away — you saw a missing component, not an error. Every module’s parse errors are now reported as link diagnostics, which means an app that used to link clean may start reporting errors it always had.

Building with the Vite plugin

For a bundled app, run the linker at build time instead of in the browser. aktion-runtime/vite compiles every .aktion file it sees into a module that default‑exports a pre‑linked program, so the browser never parses that source. Projects created with create-aktion ship this wired up already:

// vite.config.ts
import { defineConfig } from "vite";
import aktion from "aktion-runtime/vite";   // default export = aktionPlugin

export default defineConfig({
  plugins: [aktion()],
});

With the plugin installed, a .aktion file is an importable module. Its default export is a CompiledProgram, which the element mounts with mountCompiled():

// src/main.ts
import "aktion-runtime";                       // registers <aktion-app>
import type { AktionElement } from "aktion-runtime";
import app from "./app.aktion";                // linked at build time

document.querySelector<AktionElement>("#app")?.mountCompiled(app);

The plugin follows every import from the entry, registers each dependency as a watched file, and inlines the whole graph into that one module — so editing an imported file re-transforms the entry.

In vite dev it also appends a self‑accepting HMR hook that re‑mounts each matching <aktion-app> with its serializeState() snapshot. That is why a hot edit keeps live $state.

Plugin options

aktionPlugin(options?) accepts these options, all optional:

OptionTypeDefaultWhat it does
allowOutsideRoot boolean false Let .aktion imports resolve outside the Vite project root. See the warning below.
runtimeModuleId string "aktion-runtime" Specifier the generated module imports defineCompiledProgram from. Change it only when the runtime is aliased.
strict boolean false Promote linker warnings to build errors. Errors already fail the build either way.
alias, roots, extensions see AktionResolveOptions — Map package-style prefixes to directories, admit extra directories, or replace the extension list. Also read from an aktion.config.json above the project (config: false turns that off).
typescript { eraser? } | false on when ts-blank-space is installed Compile .aktion.ts modules. { eraser } plugs in another position-preserving type eraser; false refuses .aktion.ts.
devtools boolean true Ship each module’s original text (sourcesContent) so DevTools shows the files you wrote. Turn off to trim production bundles.
dts boolean | { include?, exclude?, outDir? } false Write name.d.aktion.ts declarations for .aktion modules, so TypeScript can import from them (see the TypeScript guide). The modules of each alias target are declared too, mirrored under .aktion-types/<prefix>/; give the alias a tsconfig paths entry that falls back to that mirror, e.g. "@acme/ui/*": ["../../libs/ui/src/*", "./.aktion-types/@acme/ui/*"] (TypeScript needs the paths entry for an alias either way). A directory reached through roots is not mirrored: run aktion-dts --out-dir . in that package, so each declaration sits next to its module.

Breaking: .aktion imports are confined to the project root

An import that resolves outside the Vite root is now refused — resolution returns nothing and the build fails with Cannot resolve import "…"; a second check in the loader refuses the read outright ([aktion] refusing to read "…" — outside the project root). The comparison is separator‑aware, so /srv/app does not admit a sibling named /srv/app-secrets.

A .aktion file is source, but it is also data that may have arrived with an untrusted repository. Without the check, "../../../../etc/passwd" is read by the build and — under vite dev — served to the browser inside the compiled module.

This breaks a monorepo that imports .aktion files from a sibling package. The opt‑out is aktion({ allowOutsideRoot: true }), and it is only appropriate for a layout you control and trust. Prefer raising the Vite root, or re-exporting the shared files from inside the project. See Security for the reasoning.

Linking from JavaScript

Three public entry points turn source text into something mountCompiled() accepts. Pick by how the sources reach you: a resolver you own, an in-memory map that may contain URLs, or a single string.

FunctionShapeReach for it when
linkProgram Synchronous. (entrySource, entryPath, resolver) → { program, diagnostics, dependencies }. You can load every module synchronously — a filesystem, a bundler, an in-memory map. This is what the Vite plugin uses.
linkProject Async. ({ entry, files, fetch? }) → Promise<{ program, source, diagnostics, dependencies }>. The graph may reach the network. It walks the imports, fetches every URL specifier, then links. This is what the playground and <aktion-app src> use.
compileLite Synchronous. (source, { path?, library? }) → CompiledProgram. One file, no imports. It parses and wraps — no linking and no schema validation — so it is the cheapest way to mount a string through mountCompiled() instead of setResponse().

linkProject is async only because of URL fetches: a project with no remote imports settles on one microtask. It also returns source — the merged program re‑emitted as text — which is what keeps applyDelta() and serializeState() round‑trips working after a link.

import { linkProject, defineCompiledProgram, COMPILED_PROGRAM_VERSION } from "aktion-runtime";

const files = {
  "app.aktion": `
    import { $count, increment } from "./store.aktion"
    $app(Button("+1", { onClick: increment }))
  `,
  "store.aktion": `
    export $count = 0
    export function increment() { $count = $count + 1 }
  `,
};

const res = await linkProject({ entry: "app.aktion", files });
if (res.diagnostics.length) console.error(res.diagnostics);   // [] when the link is clean

document.querySelector("aktion-app").mountCompiled(
  defineCompiledProgram({
    __aktionCompiled: COMPILED_PROGRAM_VERSION,
    program: res.program,
    source: res.source,
    path: "app.aktion",
  }),
);

That is exactly what the multi‑file demos on this page do. Diagnostics are returned, never thrown — a URL that fails to fetch becomes one diagnostic and the rest of the graph still links, so you decide whether to mount a partial program or stop.

Supplying your own resolver

linkProgram does no I/O itself. You hand it a ModuleResolver with two methods, and it calls them as it walks the graph:

import { linkProgram, createMemoryResolver, resolveSpecifier } from "aktion-runtime";

// The ready-made one: a complete { path: source } map.
const res = linkProgram(files["app.aktion"], "app.aktion", createMemoryResolver(files));

// Or your own — `resolve` returns a module key or null, `load` returns text or throws.
const resolver = {
  resolve: (spec, importerPath) => resolveSpecifier(spec, importerPath),
  load: (path) => readMySource(path),          // throws → "Failed to load imported module"
};

A custom resolver gets no path confinement for free

Confinement lives in the resolver, not in the linker. The Vite plugin’s resolver is the one that refuses paths outside the project root; linkProgram itself will load whatever your resolve returns. If your specifiers can come from a source you do not control, check the resolved path against your own root before returning it — and read Security first.

The compiled‑program contract

A CompiledProgram is the artefact mountCompiled() accepts. It carries the parsed AST so the runtime skips parse(), plus the original source so the text‑based features keep working. The reactive runtime — state, $http, effects, routing — still runs in the browser exactly as it does for a streamed string; only the parse is gone.

MemberTypePurpose
__aktionCompiled1Version marker. Must equal COMPILED_PROGRAM_VERSION.
programProgramThe merged, scope-renamed AST.
sourcestringThe whole linked program as runnable Aktion text (the Vite plugin and compileAktion* print the merged AST). Powers applyDelta, serializeState round‑trips, and debugging. To server-render the program, pass the whole artefact — renderToString(app) renders program, not this text.
pathstringModule id. Used for diagnostics and to target HMR — it is what el.sourceId reports.
sourcesContentstring[] (optional)Each module’s original text, indexed like program.sources — for a .aktion.ts module, the TypeScript you wrote. DevTools shows these; older artefacts without the field still mount.

Because source is the merged program, an imported module’s private names appear in it under their linker symbols (__a1_Counter, $__a2_total): an applyDelta that targets one must use that spelling. moduleLocalBaseName("__a2_total") recovers the name you wrote, and the entry module’s own names are never renamed.

Two helpers guard that shape. defineCompiledProgram(obj) is an identity function whose only job is to give you one typed construction point, so a malformed payload is a compile error rather than a runtime surprise. isCompiledProgram(value) is the runtime narrowing guard mountCompiled() itself applies — fail it and the call is ignored with [aktion] mountCompiled() expected a CompiledProgram … on the console.

COMPILED_PROGRAM_VERSION is 1 today and is bumped only on a breaking change to the shape, so a stale build artefact is rejected instead of mounted as garbage.

In the playground

The playground is multi-file. Use the file explorer on the left to create, rename, switch, and delete files; app.aktion is always the entry. Press to download the whole project as a .zip, or the share / download buttons to get a single linked program (every file inlined). Examples and shared links load into app.aktion. Try the “Multi-file modules” preset to start from a ready-made split.

Lazy loading & code-splitting

Modules organise your source; the Lazy(…) helper defers rendering. Reach for Lazy(…) when a part of the UI should mount only when it is needed (showing a fallback until a promise resolves), and reach for modules to keep that part — and the rest of the app — in readable, reusable files.

// app.aktion
import { Header } from "./Header.aktion"
import { Dashboard } from "./Dashboard.aktion"

$app(Column([
  Header(),
  // Render the heavy view behind a fallback until it's ready.
  Lazy(() => Dashboard(), { fallback: Skeleton({ lines: 6 }) })
]))

The two are orthogonal: the import still links Dashboard.aktion into the program, so Lazy defers when the subtree renders, not when its source arrives. For code that should not be in the bundle at all, split at the bundler level — a dynamic import() of a module that itself imports the .aktion entry.

Best practices

Next