Core Concepts
Routing & Navigation
Routes come from the file system. Every build generates a manifest; a small runtime router matches the exact pathname, hydrates server-rendered markup on first load, and lazily imports only the page you are on.
File-system routes
src/App.wizz and every .wizz file below src/pages becomes a route. Paths are lowercased; index.wizz names its directory.
src/App.wizz -> /
src/pages/index.wizz -> / (only one of these two may exist)
src/pages/Home.wizz -> /home
src/pages/Admin/Users.wizz -> /admin/users
src/pages/Docs/index.wizz -> /docs
src/pages/docs/getting-started.wizz -> /docs/getting-started- Collisions fail the build, naming both files — including
App.wizzvs.pages/index.wizz. - Reserved prefixes:
/api(server handlers),/runtime(Wizz runtime files), and/server(handler output) cannot be pages. - Adding, renaming, or removing a page takes effect on the next build or
wizz devrebuild.
The route manifest
The build writes dist/runtime/routes.js. Pages that are server-renderable also advertise their server and hydratable modules; ineligible pages carry null there and are served client-only.
// dist/runtime/routes.js — generated on every build. Edits will be overwritten.
export const pageModules = [
{
"filePath": "pages/docs/routing.wizz",
"modulePath": "../pages/docs/routing.js",
"routePath": "/docs/routing",
"serverModulePath": "../pages/docs/routing.server.js",
"hydratableModulePath": "../pages/docs/routing.hydrate.js"
}
];Never edit main.js to add a route — the entry module builds lazy loaders from the manifest:
// dist/runtime/main.js (copied from the Wizz runtime)
import { createRouter } from "./router.js";
import { pageModules } from "./routes.js";
const target = document.getElementById("app");
const routes = Object.fromEntries(
pageModules.map(({ routePath, modulePath }) => [routePath, () => import(modulePath)])
);
const hydratableRoutes = Object.fromEntries(
pageModules
.filter(({ hydratableModulePath }) => hydratableModulePath)
.map(({ routePath, hydratableModulePath }) => [routePath, () => import(hydratableModulePath)])
);
const router = createRouter({ routes, hydratableRoutes, target, window, document });
void router.render();How the router works
- Match
window.location.pathnameexactly against the manifest. - Hydrate on first render: if the document carries an
application/wizz-statescript and the route has a hydratable build, adopt the server markup. Otherwise drop it and mount fresh. - Mount later renders fresh, destroying the previous page first.
- Guard against races: each render takes a sequence token, and a render whose import resolves after a newer one started is abandoned.
Navigation & links
Use ordinary anchors. With the standard entry, a link is a document navigation: the server renders the next page, the browser parses it, and the router hydrates — fast, accessible, and working before any JavaScript arrives.
<nav class="docs-nav">
<a href="/docs/getting-started">Getting started</a>
<a href="/docs/routing">Routing</a>
</nav>For in-place client navigation, the router object exposes navigate(pathname), which calls history.pushState() and re-renders; Back and Forward re-render through popstate. The default main.js keeps its router private, so this applies to applications that create their own router:
import { createRouter } from "/runtime/router.js";
const router = createRouter({ routes, hydratableRoutes, target, window, document });
await router.render();
// pushState + render: destroys the current page, mounts the next one.
await router.navigate("/docs/api");
// Browser Back/Forward re-render through the router's popstate listener.
router.destroy(); // removes the listener and the current pagePage transitions
Because links are document navigations, the browser's cross-document View Transitions give app-like page changes with no JavaScript at all. This site does exactly that: the top bar and sidebar keep their identity, the article slides and fades, and the sidebar's active pill glides to its new row.
/* App.css — opt every same-origin navigation into a view transition */
@view-transition {
navigation: auto;
}
/* Persistent chrome keeps its identity across documents */
.topnav { view-transition-name: topnav; }
.sidebar { view-transition-name: sidebar; }
.article { view-transition-name: doc-main; }
::view-transition-old(doc-main) { animation: page-out 200ms ease both; }
::view-transition-new(doc-main) { animation: page-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes page-out { to { opacity: 0; transform: translateY(-10px); } }
@keyframes page-in { from { opacity: 0; transform: translateY(18px); } }
@media (prefers-reduced-motion: reduce) {
@view-transition { navigation: none; }
}/* Only the active link's pill carries the name, so the browser
morphs it from the old page's row to the new page's row. */
.is-active .nav-pill {
view-transition-name: nav-pill;
}Parameters & query strings
Route matching is exact; there are no dynamic segments like /users/:id. Carry parameters in the query string and read them after mount:
<script>
let userId = "";
let tab = "overview";
// The URL is a browser global: read it after mount so the server render
// and the hydration walk agree on the first frame.
onMount(() => {
const params = new URLSearchParams(document.location.search);
userId = params.get("id") ?? "";
tab = params.get("tab") ?? "overview";
});
</script>
<main>
<h1>User {userId}</h1>
<p>Tab: {tab}</p>
</main>Guards & redirects
Wizz has no router-level guard hook. Protect data on the server — where it can actually be enforced — and redirect from the page after mount:
<script>
let status = "Checking your session…";
onMount(async () => {
const response = await fetch("/api/session");
if (response.status === 401) {
// replace() keeps the guarded page out of history.
window.location.replace("/login?next=" + encodeURIComponent(location.pathname));
return;
}
const session = await response.json();
status = "Signed in as " + session.name;
});
</script>
<main class="dashboard">
<p>{status}</p>
</main>// src/server/api/session.js → /api/session
export default async function handler(request) {
const cookie = request.headers.cookie ?? "";
const token = /(?:^|; )session=([^;]+)/.exec(cookie)?.[1];
if (!token) {
return { status: 401, body: { error: "Not signed in" } };
}
return { name: await lookupUser(token) };
}Not found
An unmatched path renders a bare <main>Not found</main> into #app, and any server markup delivered for it is removed first. Unmatched /api/* paths answer 404 JSON — never the document shell.