Pages and routing
Fuzor maps folders to routes. A directory containing index.html or index.md becomes a page. The root pages/index.html or pages/index.md is required.
| Source file | Route |
|---|---|
pages/index.html | / |
pages/docs/index.md | /docs/ |
pages/docs/routing/index.md | /docs/routing/ |
Only index.html and index.md are treated as route sources. A folder cannot contain both.
URL matching rules
The compiler, generated servers and the client router use one route-matching policy:
- Routes end with
/./aboutand/about/select the same page; empty segments are ignored, so//about//does too. - Folder names are Unicode-normalized (NFC) and percent-encoded per segment. A request matches however the client encoded it:
pages/a+b/answers/a+b/,/a%2Bb/and/a%2bb/. - Paths that cannot name a folder, such as malformed escapes, encoded slashes (
%2F) or encoded dot segments, never match and render the not-found page. - Two folders that resolve to the same URL, for example composed and decomposed spellings of
café, fail compilation.
Shared layouts
Put pages/layout.html around every route. A layout must have a default <slot></slot> where child content goes.
<div class="site-shell">
<header>My site</header>
<main><slot></slot></main>
</div>
When Fuzor detects a pages/docs/ folder, it automatically provides its official opinionated docs layout and restricts pages/docs/ to Markdown files only. If you want to customize the docs layout, you can place an external layout at layouts/docs.html to override the built-in layout.
Static components
Files in components/ are compile-time HTML components. A filename such as components/site-banner.html becomes <site-banner></site-banner>. Components can contain a default slot for child content. Their names must be kebab-case; fuzor-* is reserved.
Links
Use an ordinary <a> or <fuzor-link>. Fuzor turns the latter into an anchor with routing metadata, so the link remains useful when JavaScript is unavailable.
The SPA router handles clicks on HTML anchors, SVG anchors (including xlink:href), <area> and links inside open shadow roots. It leaves links alone when a modifier key or non-primary button is used, when they have target other than _self, download or data-fuzor-reload, when the href is malformed or external, or when no compiled route matches. Navigating to the URL that is already current replaces the history entry instead of adding a duplicate.
Typed links
Every build writes .fuzor/routes.d.ts with your page and endpoint patterns. Include it in tsconfig.json ("include": [".fuzor/*.d.ts", …]) and build links with href:
import { href } from "fuzor/runtime";
href("/blog/[slug]/", { slug: "hello world" }); // "/blog/hello%20world/"
href("/api/users/[id]", { id: 7 }, { search: { full: true } }); // "/api/users/7?full=true"
href("/nope/"); // type error: unknown route
Parameters are required and encoded; catch-all values keep their /. Pass { base: import.meta.env.BASE_URL } when the app has a deployment base. Without the generated file (before the first build), href accepts any string. Turn generation off with fuzorPlugin({ routeTypes: false }).
Deployment base paths
Set Vite's base (for example base: "/docs-site/") to deploy under a sub-path. Keep writing root-relative links as app routes (/about/); at build time Fuzor prefixes <a href>, <area href> and <form action> in compiled pages. Generated SPA clients pass import.meta.env.BASE_URL to the router, generated server manifests include base, and createNodeServer({ base }) serves public files below it. URLs outside the base are not found. Relative (./) and absolute-URL bases only change asset URLs; routes stay at the origin root. router.path() reports the route without the base, while router.navigate(href) takes document URLs such as /docs-site/about/.
Dynamic routes
A folder named [name] is a route parameter and [...name] matches one or more segments. Every value is listed at build time by a paths.ts (or paths.js) module in that folder, so each one becomes a finished document in every build profile:
pages/blog/[slug]/index.html
pages/blog/[slug]/paths.ts
pages/docs/[...path]/index.md
pages/docs/[...path]/paths.ts
// pages/blog/[slug]/paths.ts
export default async function paths() {
const posts = await loadPosts();
return posts.map(post => ({ params: { slug: post.slug }, metadata: { title: post.title } }));
}
The default export is an array or a function returning one. Each entry has params and optional metadata (the same fields as Markdown frontmatter). Catch-all values are an array of segments or an "a/b" string. Folders below a dynamic folder reuse its paths module, unless they add another dynamic segment.
Typed parameters
Export a params Effect Schema from the paths module to describe the values. Entries then hold decoded values, the build encodes each to its URL form and checks that it decodes again, and the generated route types use the schema for typed links:
// pages/users/[id]/params.ts — shared with the server loader and islands
import * as Schema from "effect/Schema";
export const UserParams = Schema.Struct({ id: Schema.FiniteFromString });
// pages/users/[id]/paths.ts
import { UserParams } from "./params.js";
export const params = UserParams;
export default async () => (await loadUsers()).map(user => ({ params: { id: user.id } })); // id is a number
A value the schema rejects fails the build with the entry number and the schema's message. URLs, router.params(), routeParams(host) and server loaders still see the encoded strings ({ id: "7" }); decode them with the same schema where you need typed values:
import * as Schema from "effect/Schema";
import { routeParams } from "fuzor/runtime/islands";
import { UserParams } from "../pages/users/[id]/params.js";
const { id } = Schema.decodeUnknownSync(UserParams)(routeParams(host)); // number
href("/users/[id]/", { id: "7" }) accepts the encoded form, so literal unions such as Schema.Literals(["ada", "grace"]) are checked at compile time. Keep the schema in its own module when islands import it, so the browser bundle does not include the code that lists the paths.
Insert a parameter into HTML with <fuzor-param name="slug"></fuzor-param>; the value is escaped. The router exposes router.params(), and islands can call routeParams(host) from fuzor/runtime/islands.
URLs that are not listed are not found. A listed value that produces the same URL as a static folder fails compilation rather than shadowing it. Catch-all folders cannot contain subfolders, and optional segments are not supported. Values that only exist at request time (unbounded ids) are not supported yet; see server data for request-time values on listed pages.
Redirects
List permanent redirects in the plugin. Destinations are app routes (with optional query or hash) or absolute URLs:
fuzorPlugin({ redirects: { "/start/": "/docs/getting-started/", "/chat/": "https://discord.example/" } })
Chains are collapsed to their final destination, and loops, missing destinations or a redirect from a URL that is also a page fail compilation. Servers answer 308 with a Location header, keeping the request's query string when the destination has none. The SPA router replaces the history entry with the destination. Static builds write _redirects for hosts that read it, and static MPA builds also write a refresh page at the old URL.
Blocking navigation
Protect unsaved work with router.block. Blockers run synchronously and return true to block:
import { getRouter } from "fuzor/runtime";
const router = getRouter();
const unblock = router?.block(attempt => form.dirty && !confirm("Discard your changes?"));
// later: unblock?.()
getRouter() returns the router that the generated SPA client started. Blocked links do nothing and router.navigate() resolves { status: "blocked" }. Back and Forward are cancelled where the browser allows it; otherwise Fuzor returns to the previous entry. While any blocker exists, reloading or leaving the app asks the browser to show its own confirmation (kind: "document"); the message is not customizable. Remove blockers when they are no longer needed: an active beforeunload listener can keep pages out of the back/forward cache. MPA builds use ordinary document navigation, so only beforeunload applies there.
After a navigation, router.subscribeState reports completed, cancelled or failed.
Not-found and error pages
Add not-found.html (or .md) to show your own page for unknown URLs, and error.html (or .md) for pages that fail to render. Both use the layouts of the folder they are in, and the nearest one applies: pages/docs/not-found.md handles unknown URLs below /docs/, pages/not-found.html everything else. Without them Fuzor uses an accessible default.
| Build | Not-found | Error |
|---|---|---|
| SPA | Rendered by the router | Rendered by the router when resolving or rendering a route fails |
| Static MPA | 404.html in each scope folder | Not applicable |
| Server SPA/MPA | Sent with status 404 | Root error page sent with status 500 |
Error details are never shown on these pages; they go to the onError option of createRouter, createServerHandler and createNodeServer. Failing islands keep their own error UI and do not replace the page.
Transitions and prefetching
Two opt-in SPA features:
fuzorPlugin({ viewTransitions: true, prefetch: "intent" })
viewTransitionswraps page changes in the View Transitions API where the browser supports it and the user has not asked for reduced motion. Navigation works the same without support. Customize the animation with::view-transition-old(root)and::view-transition-new(root)in CSS.prefetch: "intent"starts downloading the island code of a page when a link to it is hovered or focused, so its islands activate right after the click. Each module is requested once, and nothing is prefetched when the browser's Save-Data setting is on.
API routes
URLs below /api belong to the api/ folder when it exists, and a page cannot be placed under pages/api/ at the same time. Endpoint files have their own matching rules, including request-time [param] segments; see API endpoints.
Navigation lifecycle
SPA routing prefers the browser Navigation API where supported and uses the History API otherwise. Effect coordinates route resolution, DOM commitment and scoped island activation. MPA links keep normal document navigation.
Navigating from code
import { getRouter } from "fuzor/runtime";
const router = getRouter()!;
const result = router.navigate("/checkout/", { history: "push", scroll: "preserve" });
await result.committed; // the URL has changed
const outcome = await result; // the page is rendered: { status: "completed", url, route }
| Option | Default | Meaning |
|---|---|---|
history | "auto" | "push", "replace", or "auto": replace when the destination is the current URL |
state | none | Structured-cloneable data stored with the new entry |
scroll | "auto" | "preserve" keeps the scroll position instead of scrolling to the top or the anchor |
focus | "auto" | "preserve" keeps focus where it is instead of moving it to the new page |
document | false | Load the destination as a new document, even outside the app |
The returned promise resolves once the navigation has finished; its committed property resolves as soon as the URL has changed. Both resolve to an outcome instead of rejecting for normal results:
status | Meaning |
|---|---|
committed | The URL changed (only from committed) |
completed | The page is rendered and islands without a client:* directive or with client:load are mounted. client:idle, client:visible and client:interaction islands never delay completion. |
blocked | A blocker refused it |
cancelled | A newer navigation replaced it before it finished |
failed | Rendering failed; the nearest error page is shown at the new URL and error holds the cause |
document | Handed to the browser as a document navigation |
outcome.route and router.route() describe the matched page: { kind: "page" | "not-found", path, pattern?, params }. A compiled redirect reports the URL it ended at.
Errors
| Problem | How it surfaces |
|---|---|
| Destination outside the app, or an unparsable URL | navigate() rejects with NavigationError (reason: "InvalidUrl"); pass document: true to leave the app |
| State that cannot be cloned | NavigationError with reason: "InvalidState" |
Navigating before start(), or document: true in an embedded (hash) app | NavigationError with reason: "Unsupported" |
| Unknown route | Not an error: the nearest not-found page, route.kind === "not-found" |
| Rendering the page fails | Outcome failed with a NavigationLifecycleError (phase "resolve" or "commit"), error page shown |
| Server data request fails | RouteDataError from routeData(); the page stays |
| An island fails | IslandActivationError in onError and the island's own error UI; the navigation still completes |
The URL always changes before the page is rendered, so a failed render never leaves the old page under a new URL: the error page is shown at the new URL, and the next navigation recovers.
What the router handles
The same rules apply with the Navigation API and with the History fallback:
| Navigation | Handled as |
|---|---|
| Link to a compiled route or redirect | Rendered in place |
Link with data-fuzor-reload, target, download, modifier keys, or to another origin or an unknown path | Browser document navigation |
| Form submission (GET or POST) | Browser document navigation |
router.navigate() | Rendered in place; unknown paths show the not-found page |
| Back and Forward | Rendered in place, after asking blockers |
| Reload | Browser loads a fresh document |
Fuzor only calls preventDefault() on clicks it renders itself and on blocked navigations the browser allows cancelling.
Scrolling and focus
With the Navigation API the browser scrolls and restores positions itself; Fuzor only moves focus to the new page. With the History fallback Fuzor does both: new pages scroll to the top or to the URL's anchor, Back and Forward restore the entry's position, and positions are kept in sessionStorage under the entry's key so they also survive a reload. scroll: "preserve" and focus: "preserve" opt out per navigation in both cases. After each route change a polite live region announces the new title (or first heading) to screen readers.
History entries
Entries are the browser's own. The History fallback keeps its key, index and your state under one __fuzorNavigation property of history.state: a new entry does not inherit other libraries' state from the previous entry, and a replacement keeps their properties. Entries created outside the app have an unknown index, so their distance is reported as null rather than guessed. router.navigation().canTraverse(-1) answers true, false, or null when the adapter cannot know (the History API cannot see forward entries).
Query- or hash-only navigation keeps the page's DOM and islands. url() and subscribeUrl follow the full URL; path(), params() and route() follow the page. state() and subscribeState report the phases requested, resolving, ready, committing, activating, completed, cancelled and failed, with route from ready on.
For application URL state, makeNavigationUrlAtom(navigation, key, schema, initialValue) initializes and subscribes in an Effect scope. Pass router.navigation() after start() to follow the router's own pushes, replacements and traversals. Its set and remove operations preserve other query parameters and entry state. Invalid initialization fails unless invalid: "default" is configured; later invalid URLs retain the last valid value and can be reported through onError.
Router builds
The same pages can be built with a smaller router when you know where the app runs. fuzorPlugin({ navigation }) chooses it for the generated SPA client; MPA builds ignore it.
navigation | Includes | Router size (gzip) | Use it for |
|---|---|---|---|
"auto" (default) | Navigation API, History API fallback, fragment routing for file: documents | 39.9 KB | The web, when you do not control browsers |
"navigation-api" | Navigation API only | 35.9 KB | Browsers with the Navigation API. Others still work, but every link and navigate() loads the page as a new document, which needs a host that serves the SPA document for all page URLs (server builds and fuzor preview do). |
"embedded" | Fragment routing only, over any protocol | 38.8 KB | Electron, WebViews and single-file apps: URLs look like index.html#/about/ |
Custom clients import the same routers from fuzor/runtime, fuzor/runtime/router/navigation-api and fuzor/runtime/router/embedded. Page sources do not change between them.
Embedded SPA apps
For a SPA document loaded through file: or a custom WebView protocol, the router automatically uses fragment transport. The document remains at its physical path, such as file:///app/index.html, while /about/ is represented as #/about/. Write the same ordinary app links used in browser builds. Query parameters and page anchors remain part of the logical route.
Use fuzor build --target spa --inline when the document must contain its JavaScript and CSS.
| Build | Browser over HTTP(S) | file:// or custom protocol (Electron, WebViews) |
|---|---|---|
| Inline SPA | Supported | Supported; verified in Electron over file:// and app:// |
| SPA with separate assets | Supported | Needs the host to serve assets at their URLs; not verified |
| MPA | Supported | Not supported: root-relative links leave the app. The client logs an error explaining this. |
| Server builds | Supported | Not supported (--inline with server features fails the build) |
Capacitor navigation is supported through fuzor/runtime/capacitor (connectCapacitor), wiring cold/warm deep links, the Android hardware Back button through the router (with navigation blockers and exit/minimize on the first page), session reload preservation, and foreground resume hooks. Verified on Android 16 / WebView 134 (tests/capacitor/android.mjs). See the runtime API.
An explicit navigation adapter can be passed to createRouter(outlet, { navigation }); memory, History and hash adapters can use the same DOM/island lifecycle. The caller owns an injected adapter’s scope. Native browser interception uses the default adapter selection so there is one interception owner.
start() is idempotent. Await dispose() or the cleanup returned by start() before restarting. Pending activation is cancelled and asynchronous cleanup is awaited. A document can have one active SPA router; a second start() on the same document fails. The router listens for link clicks on the whole document, so links outside the outlet (for example in a header rendered by index.html) navigate the same router.