SPA / MPA

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:

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.

Or per build: bunx vibe compile --spa. Watch mode works too: bunx vibe compile --spa --watch.

What gets generated

The pages tree becomes three things:

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.

Routing

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.

Persistent layout — automatic hoisting

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:

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.

Navigation, frame by frame

A navigation must never paint a broken in-between. The runtime commits it in phases, and only the last one is visible:

State across navigation

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:

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

Components inside pages

componentsAsIs decides how a fragment's child components ship:

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.

Deployment

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.

The MPA → SPA contract

SPA mode assumes; this contract defines. Pages that follow it compile to either target with no edits:

Current limits