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 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 { a, b } lists, re-exports, and export of a destructuring declaration are not supported — export each binding inline, where it is declared.

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.

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.

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. 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).

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 prefixed with export. State imports report the sigil: does not export `$count`. Add export to the declaration.
Greeting.aktion: Unexpected token … A dependency’s own parse error, prefixed with its path. Fix the syntax in that file.
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 three 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.

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.

strict: true currently rejects $app(…) programs

The plugin warns when a linked program has no top-level aktion = … binding, and that check does not recognise the modern $app(…) root. So every $app‑based entry emits No top-level `aktion = …` entry binding found.

It is harmless noise with the default options, but strict: true turns it into a fatal build error. Leave strict off until the check understands $app.

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 original (or re‑emitted) text. Powers applyDelta, serializeState round‑trips, and debugging.
pathstringModule id. Used for diagnostics and to target HMR — it is what el.sourceId reports.

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