Skip to content
Moku LabsDocs
@moku-labs/web pages

Routing

route().load().render().head(): one typed builder is the contract for the build and the browser.

routing.mdllms.txt

How routing works

A route is a URL pattern plus optional handlers. You write routes with route(), a plain function that runs before createApp. You register them in one place, pluginConfigs.router.routes. The router plugin compiles the table once, while createApp runs.

At build, the build plugin walks the table: it expands each route into concrete pages with generate, loads their data, composes the head and renders the HTML. In the browser, spa matches the URL against the same table and runs render again with the saved data.

  • pattern
  • generate
  • load
  • head
  • render
  • index.html

Types flow along the chain. ctx.params comes from the pattern, ctx.data from what load returns. There is no codegen and no cast.

Patterns

SyntaxMeaningExample
{name}Required param/{slug}/ → ctx.params.slug: string
{name:?}Optional param. toUrl drops the segment when it is missing./archive/{page:?}/ → ctx.params.page?: string
{lang:?}Locale slot. The default locale is served at bare paths. It does not count toward specificity./{lang:?}/{slug}/

When two patterns match a URL, the one with fewer dynamic segments wins. On a tie the route declared first wins. Matching uses a plain RegExp, so it works in every browser, including those without URLPattern.

Define routes

Three files: the route table, a component that builds links, and the app that registers the table.

src/routes.tsx
import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser";
import type { Content } from "@moku-labs/web/browser";

function PostList(props: { cards: Content.ArticleCard[] }) {
  return (
    <ul>
      {props.cards.map(card => (
        <li key={card.contentId}>
          <a href={card.url}>{card.title}</a>
        </li>
      ))}
    </ul>
  );
}

export const routes = defineRoutes({
  home: route("/{lang:?}/")
    .generate(ctx => [{ lang: ctx.locale }])
    .load(async ctx => {
      const content = ctx.require(contentPlugin);
      const byLocale = await content.loadAll();
      return (byLocale.get(ctx.locale) ?? []).map(article => content.articleToCard(article));
    })
    .render(ctx => <PostList cards={ctx.data} />)
    .head(() => ({ title: "Home" })),
  post: route("/{lang:?}/{slug}/")
    .generate(async ctx => {
      const byLocale = await ctx.require(contentPlugin).loadAll();
      return (byLocale.get(ctx.locale) ?? []).map(article => ({
        lang: ctx.locale,
        slug: article.computed.slug
      }));
    })
    .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
    .render(ctx => <article dangerouslySetInnerHTML={{ __html: ctx.data.html }} />)
    .head(ctx => ({ title: ctx.data.frontmatter.title }))
    .meta({ section: "blog" })
});
  1. defineRoutes keeps the literal type of each route for IntelliSense.
  2. generate returns the param sets to pre-render, once per locale.
  3. load gets { params, locale, require, has }. Its return type is ctx.data below.
  4. meta is a JSON bag, shipped to the browser in clientManifest().
src/components/post-link.tsx
import type { Content } from "@moku-labs/web/browser";

type Url = (name: string, params?: Record<string, string>) => string;

export function PostLink(props: { card: Content.ArticleCard; slug: string; url: Url }) {
  return (
    <a href={props.url("post", { slug: props.slug })}>
      <h2>{props.card.title}</h2>
      <time>{props.card.date}</time>
    </a>
  );
}
  1. Inside render and head, ctx.url(name, params) builds links. Pass it down.
src/app.ts
import { buildPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web";
import { routes } from "./routes";

export const app = createApp({
  plugins: [contentPlugin, buildPlugin],
  config: { mode: "hybrid" },
  pluginConfigs: {
    site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
    router: { routes },
    i18n: { locales: ["en", "uk"], defaultLocale: "en" },
    content: { providers: [fileSystemContent({ contentDir: "content" })] }
  }
});
  1. The render mode is global config, not router config.
  2. The only way to register routes. An import * as routes namespace works too.

Load data

A route without load is a static page: ctx.data is unknown and the build writes an empty {} sidecar. Add load and ctx.data takes its return type, in render and in head.

src/routes.tsx
import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser";

export const routes = defineRoutes({
  post: route("/{slug}/")
    .render(ctx => <h1>{ctx.params.slug}</h1>)
    .head(ctx => ({ title: ctx.params.slug }))
    .load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
    .render(ctx => <h1>{ctx.data.frontmatter.title}</h1>)
    .head(ctx => ({ title: ctx.data.frontmatter.title, description: ctx.data.frontmatter.description }))
});

load may be async. It runs inside the build plugin, so ctx.require reaches any plugin in the Node app, and ctx.has(name) tells you whether an optional one is there.

Set the head

.head(ctx) returns the page's title, description and extra elements. The head plugin adds the title template, canonical, Open Graph, Twitter and hreflang tags around it.

src/routes.tsx
import { defineRoutes, jsonLd, route } from "@moku-labs/web/browser";

export const routes = defineRoutes({
  about: route("/about/")
    .render(() => <h1>About</h1>)
    .head(ctx => ({
      title: "About",
      description: "Who writes this blog.",
      image: "/og/about.png",
      elements: [jsonLd({ "@context": "https://schema.org", "@type": "AboutPage", url: ctx.url("about") })]
    }))
});
  1. Becomes <title> through head.titleTemplate.
  2. Relative images are made absolute against site.url.

Dynamic paths

A route with a dynamic segment renders one page per param set that generate returns. The build calls generate once per locale, default locale first. Two param sets that resolve to the same file are written once.

RoutegeneratePages written
/{slug}/[{ slug: "a" }, { slug: "b" }]a/index.html, b/index.html
/{lang:?}/ with en, uk[{ lang: ctx.locale }]index.html, en/index.html, uk/index.html
/{lang:?}/ with en, uknoneindex.html and en/index.html only
/b/{id}/ in mode: "spa"nonenone: a client-only route, rendered in the browser from the URL

.toFile(params => path) overrides where a page is written. The URL stays the pattern's URL.

At build

build
bun run build
▟▙ moku web  build                                       v2.4.11
✓ bundle · 5ms
✓ content · 1239ms
✓ pages · 5ms
✓ sitemap · 1ms
✓ BUILD · 9 pages                                1252ms · ./dist/
find dist -name index.html | sort
dist/en/hello-world/index.html
dist/en/index.html
dist/en/second-post/index.html
dist/hello-world/index.html
dist/index.html
dist/second-post/index.html
dist/uk/hello-world/index.html
dist/uk/index.html
dist/uk/second-post/index.html

The English pages live at bare paths. The /en/… copies carry a canonical link to the bare URL, so an explicit /en/ link works without a redirect.

Builder methods

MethodRunsWhat it does
.generate(ctx => params[])buildParam sets to pre-render. ctx = { locale, require, has }. May be async.
.load(ctx => D)buildPage data. ctx = { params, locale, require, has }. Widens ctx.data to D. May be async.
.render(ctx => VNode)build and browserThe page. ctx = { params, data, locale, meta, url }.
.head(ctx => HeadConfig)build and browser{ title?, description?, canonical?, image?, elements? }. Same ctx as render.
.layout((ctx, children) => VNode)buildWraps the page in chrome. Not re-applied on client navigation.
.meta(record)A JSON-serializable bag, readable as ctx.meta in render, head, layout and islands.
.transition(mode)browser"none", "crossfade", "slide" or "morph" for navigations to this route.
.scroll(mode)browser"top" or "preserve" for navigations to this route.
.toFile(params => path)buildOutput file override.
.toJson(ctx => unknown)Stores a JSON serializer on the route. No built-in plugin reads it in 2.4.11.

Inside render and head, ctx.url(name, params) builds a link. It gives the same result as app.router.toUrl. Outside a route, in components and islands, use createUrls:

src/urls.ts
import { createUrls, defineRoutes, route } from "@moku-labs/web/browser";

const routes = defineRoutes({
  post: route("/{lang:?}/{slug}/")
});

export const urls = createUrls(routes, "en");

urls.toUrl("post", { lang: "en", slug: "hello" }); // "/hello/"
urls.toUrl("post", { lang: "uk", slug: "hello" }); // "/uk/hello/"
  1. Pass the default locale. The helper is app-free and cannot read i18n.

Without the second argument every locale keeps its prefix: "/en/hello/". toUrl only accepts names from the map, and throws [web] router: unknown route name "…". on a name it does not know.

RouterApi

Mounted at app.router and reachable with ctx.require(routerPlugin).

MethodReturns
match(pathname){ params, route } for the most specific match, or null.
toUrl(routeName, params)The URL. Throws on an unknown name.
entries()Every route as { pattern, name, meta, toUrl, toFile, match }, most specific first.
manifest()The full definitions with handlers, in declaration order. The build reads this.
clientManifest(){ pattern, name, meta }[] with no functions, JSON-safe.
mode()"ssg", "spa" or "hybrid", from the global config.
  • load and generate never run in the browser. The route file still ships to the client. Keep heavy Node-only code behind ctx.require in the loader, not in top-level imports of the route file.
  • There is no runtime registration. pluginConfigs.router.routes is the only path. Without it every router call throws [web] router: routes not registered.
  • The mode is global. Set createApp({ config: { mode } }). Router config has only routes.
  • A {lang:?} route without generate gets one page. The build expands it for the default locale only. Return [{ lang: ctx.locale }] to get a page per locale.
  • Only {lang:?} matches without its segment. /archive/{page:?}/ matches /archive/2/, not /archive/. Declare a second route for the bare path.
  • Patterns start with /. route("about/") fails createApp with [web] router: route "about" pattern must start with "/" (got "about/").