In Vibe, single-page versus multi-page is not an architecture you choose up front —
it's a build target. You always author plain multi-page documents:
a pages/ tree where every file is a complete HTML page that works on its
own. One config flag decides what the compiler makes of them:
"spa": true) — the same tree compiles to one
shell plus per-route fragments: client-side navigation, per-route lazy loading, a
persistent layout, and app-lifetime state.Nothing about how you write pages changes between the two. That's the working model this page describes: develop and reason in MPA terms, and treat SPA as something the compiler derives — the same source compiles to an MPA today and an SPA tomorrow, and back.
{ "vibe-compiler": { "spa": true } }
Or per build: bunx vibe compile --spa. Watch mode works too:
bunx vibe compile --spa --watch.
The pages tree becomes three things:
/components/vibe-spa/<path> —
each page's body content, with its head <style> tags prepended and
its <script type="module"> carried along. Fragments are fetched and
mounted on navigation, so per-route lazy loading falls out for free.<title> harvested for
document.title swaps./index.html — the deduped union of every
page's head resources, the union of body attributes, a generated boot script, and the
route outlet — plus the hoisted layout, compiled and pre-rendered, when every page
shares one (see below). The shell is plain runtime-Vibe code:
The outlet is the reactive component src: navigation writes a fresh
$.page, the binding changes, the old fragment unmounts and the new one
mounts. The key makes route identity explicit — navigating
/articles/3 → /articles/7 keeps the same src but changes the
key, so the outlet mounts fresh, exactly like an MPA reload on the new URL. No pages
directory, no per-page manifests in SPA output — the shell gets a manifest and
everything else hydrates at runtime.
Routes come straight from the file tree — the same conventions your MPA pages
already follow (pages/articles/$id.html → /articles/:id,
with the segment as @[page.params.id]). The generated
shell wires Vibe's built-in router against them: it claims only same-origin,
unmodified link clicks on real routes, and everything else navigates
natively — pages outside the SPA are just ordinary navigations. The full
story — the URL conventions, what the router claims, and using it by hand — lives on
Routing.
Most apps wrap every page in the same layout component — sidebar, top bar, overlays:
When every page's body root is that identical
<component src> (top-level scripts, styles and
comments may sit beside it), the compiler recognizes the shape and
hoists the layout into the shell. The structural fact is the
declaration — there is nothing to configure:
<slot> —
navigation swaps page content inside the persistent chrome.Two consequences follow. First, app-lifetime bootstrap belongs in the layout: the layout's script runs once with the shell, before any page fragment, so global state seeding, socket setup and clocks import there (pages keep their own bootstrap import for MPA builds — modules run once either way). Second, the shape is all-or-nothing: one page that diverges from it disables hoisting for the whole app and every route falls back to whole-page fragments — the build output notes whether the layout was hoisted.
A navigation must never paint a broken in-between. The runtime commits it in phases, and only the last one is visible:
pushState immediately, and
the target fragment prefetches through the component cache. Route state does not
flip yet; on rapid navigation the latest target wins.$.page flips. Shell bindings like active-tab highlights update
instantly, but the outgoing page is frozen: no reactive update touches it again, so
it never re-renders against the incoming route.display: none) while the outgoing page keeps the screen. Its scripts
run in document order, its bindings hydrate, all invisibly. Binding updates on the
outlet's ancestors — the styling context around the page, like a
<page @[page.name]> wrapper whose attribute scopes
per-page CSS — are parked rather than applied, so the old page keeps matching its
own rules.
Under SPA, a page's script re-runs on every visit — so the compiler redirects each
fragment's vibe import unchanged — vibe() itself applies defaults semantics once booted, with
defaults semantics: once booted, only keys that don't exist yet on
$ are set. First mount seeds; re-mounts never clobber. Notifications pushed
on page A survive navigating A → B → A.
The rule this encodes:
vibe() state is app-lifetime — it lives for the whole
sessioncomponent() state is mount-lifetime — it resets every
visitAnything that must reset per visit — a timer, a wizard step — belongs in a component. In an MPA every load is fresh, so defaults ≡ assign: byte-identical author code, byte-identical MPA behavior.
componentsAsIs decides how a fragment's child components ship:
false (default) — children are inlined into each
fragment: every route is one self-contained fetchtrue — children stay as runtime
<component src> fetches, shared across routes
through the component cache
Side effects in page and component scripts tear down with
$.on('unmount', ...) when their fragment unmounts on
navigation — the compiler warns about page scripts that start intervals, listeners, or
sockets without ever referencing it.
SPA output needs exactly one rewrite: every route serves /index.html, while
/components/**, /vibe-hyperspeed/**, and assets serve as
files. On Vercel:
For local dev, any static server with a history-API fallback works — or a few lines of
Bun.serve: serve the file if it exists, fall back to
/index.html for extension-less paths.
SPA mode assumes; this contract defines. Pages that follow it compile to either target with no edits:
$.on('unmount', ...)vibe() for app-lifetime
state, component() for per-visit state<style> tags
(they travel with the fragment), and page-specific <meta> is
droppedvibe-fouc covers the
flash. A hybrid mode (stamped MPA documents for entry + SPA takeover) is planned.
With a hoisted layout the shell's chrome IS pre-rendered — only the route outlet
hydrates at runtime.spa: true converts the entire
pages tree; per-page selection is planned as a config-object form.<meta> beyond the title is future
work.