Vibe has no opinion on routing. It's a view layer — URLs belong to you, your server, or whatever router you like. The one exception: turning on SPA mode in the compiler, which has to own navigation to work — and then it uses Vibe's built-in router.
That gives you exactly three setups, from zero machinery to fully generated:
@ape-egg/vibe/spa, declare a route table, get client-side
navigation. For when you want control over the routes and behavior."spa": true — the compiler derives the route
table from your pages tree and wires the built-in router for you. Zero routing
code.
Rule of thumb: start with none. Flip "spa": true the
day you want an SPA. Reach for the by-hand router only when you need routes or
navigation behavior the compiler wouldn't derive.
A multi-page Vibe app doesn't route on the client at all. The
pages/ tree is the URL space, and every link is a normal
browser navigation:
pages/index.html → / (a terminal index
serves its directory path)pages/about.html → /aboutpages/articles/$id.html → /articles/:id — one dynamic
segmentpages/docs/$$rest.html → /docs/:rest* — an optional
catch-all serving /docs and every depth below itThese conventions are the whole story in MPA mode — and they're the same conventions SPA mode reads later, which is why switching modes never touches your pages.
@ape-egg/vibe/spa is a small standalone module: a route table,
setupSpa() to claim link clicks and history, and a reactive
$.page your markup can bind — most importantly as a component outlet:
The router claims same-origin, unmodified, untargeted link clicks whose pathname
matches a real route — pushState, a fresh $.page
({ path, route, params, src, title }), a document.title
swap. Everything else navigates natively: other origins, modified
clicks, download links, hash anchors, and unrouted paths. Routed and unrouted pages
coexist in one app.
resolve() is pure — it maps anything with a pathname
against the table and returns the page object or null. A
'*' entry is the declared no-match fallback for deep links and
popstate; it never claims a click. With no match and no '*',
$.page.src stays unset and the outlet mounts nothing — there is no
built-in 404. And with a custom onNavigate instead of an outlet, it's
a pure router with no Vibe at all.
"spa": true — generatedThe compiler flag composes option 2 for you: it reads the same file-tree conventions from option 1, generates the route table (most-specific-first, page titles harvested), compiles each page into a lazy-loaded fragment, and emits a shell that boots the built-in router against them. Your pages don't change; your links don't change; there is no routing code to write or maintain.
Everything about that mode — fragments, the persistent layout, how navigation commits — lives on SPA / MPA.