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.

file → route
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.wizz vs. 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 dev rebuild.

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
// 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
// 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

  1. Match window.location.pathname exactly against the manifest.
  2. Hydrate on first render: if the document carries an application/wizz-state script and the route has a hydratable build, adopt the server markup. Otherwise drop it and mount fresh.
  3. Mount later renders fresh, destroying the previous page first.
  4. Guard against races: each render takes a sequence token, and a render whose import resolves after a newer one started is abandoned.

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.wizz
<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:

custom entry
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 page

Page 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
/* 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; }
}
Sidebar.wizz — wizz:style
/* 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:

src/pages/User.wizz → /user?id=42
<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:

src/pages/Dashboard.wizz
<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
// 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.