DOCUMENTATION Docs Powered by Fuzor Docs

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 fileRoute
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 /. /about and /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.

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.

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.

BuildNot-foundError
SPARendered by the routerRendered by the router when resolving or rendering a route fails
Static MPA404.html in each scope folderNot applicable
Server SPA/MPASent with status 404Root 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" })
  • viewTransitions wraps 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.

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.

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 }
OptionDefaultMeaning
history"auto""push", "replace", or "auto": replace when the destination is the current URL
statenoneStructured-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
documentfalseLoad 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:

statusMeaning
committedThe URL changed (only from committed)
completedThe 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.
blockedA blocker refused it
cancelledA newer navigation replaced it before it finished
failedRendering failed; the nearest error page is shown at the new URL and error holds the cause
documentHanded 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

ProblemHow it surfaces
Destination outside the app, or an unparsable URLnavigate() rejects with NavigationError (reason: "InvalidUrl"); pass document: true to leave the app
State that cannot be clonedNavigationError with reason: "InvalidState"
Navigating before start(), or document: true in an embedded (hash) appNavigationError with reason: "Unsupported"
Unknown routeNot an error: the nearest not-found page, route.kind === "not-found"
Rendering the page failsOutcome failed with a NavigationLifecycleError (phase "resolve" or "commit"), error page shown
Server data request failsRouteDataError from routeData(); the page stays
An island failsIslandActivationError 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:

NavigationHandled as
Link to a compiled route or redirectRendered in place
Link with data-fuzor-reload, target, download, modifier keys, or to another origin or an unknown pathBrowser document navigation
Form submission (GET or POST)Browser document navigation
router.navigate()Rendered in place; unknown paths show the not-found page
Back and ForwardRendered in place, after asking blockers
ReloadBrowser 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.

navigationIncludesRouter size (gzip)Use it for
"auto" (default)Navigation API, History API fallback, fragment routing for file: documents39.9 KBThe web, when you do not control browsers
"navigation-api"Navigation API only35.9 KBBrowsers 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 protocol38.8 KBElectron, 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.

BuildBrowser over HTTP(S)file:// or custom protocol (Electron, WebViews)
Inline SPASupportedSupported; verified in Electron over file:// and app://
SPA with separate assetsSupportedNeeds the host to serve assets at their URLs; not verified
MPASupportedNot supported: root-relative links leave the app. The client logs an error explaining this.
Server buildsSupportedNot 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.

Built with Fuzor Back to top ↑
On this page Back to top ↑