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.
import { Greeting } from "./Greeting.aktion"
$app(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:
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" }))
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:
| Specifier | Importer | Module key |
|---|---|---|
./Button.aktion | app.aktion | Button.aktion |
../Button.aktion | components/App.aktion | Button.aktion |
/layout/Nav.aktion | any project file | layout/Nav.aktion — from the project root, leading slash dropped |
https://x.test/H.aktion | any | the URL itself, normalised |
./n.aktion | https://x.test/ui/H.aktion | https://x.test/ui/n.aktion |
lodash | any | null — 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.
| Message | What happened | Fix |
|---|---|---|
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:
| Option | Type | Default | What 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.
| Function | Shape | Reach 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.
| Member | Type | Purpose |
|---|---|---|
__aktionCompiled | 1 | Version marker. Must equal COMPILED_PROGRAM_VERSION. |
program | Program | The merged, scope-renamed AST. |
source | string | The original (or re‑emitted) text. Powers applyDelta, serializeState round‑trips, and debugging. |
path | string | Module 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
- One component per file (named to match), so imports read like a manifest.
- Keep shared
$statein its own store file and export the atoms plus the actions that mutate them — importers get a consistent surface. - Keep the entry thin. Let
app.aktionwire pieces together and own the$app(…)root; push real UI into imported files. - Export only what other files need. Everything else stays private — that is what makes a name safe to reuse.
- Prefer relative imports within a project; reserve URL imports for genuinely shared, versioned components — and remember they only resolve in the browser linker, not under the Vite plugin.
- Check
diagnosticsbefore you mount. A link returns its problems instead of throwing, so an unchecked result mounts a partial program with no complaint.
Next
Open the playground
Create files, import between them, and download the project as a zip.
Launch playground → ReferenceLanguage
Program structure, $state, expressions, and control flow.
Global state
Patterns for sharing reactive state across an app.
Read the guide →