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

How it works

One route, two places: the build renders HTML, the browser renders the saved data.

how-it-works.mdllms.txt

The picture

app.build.run()dist/**/index.htmlspa: islandsdata.at(path)
Build to SPA
  1. Build. Node runs every route: generate lists the pages, load fetches the data, head and render turn it into HTML. The kernel checked the plugin list before anything ran.
  2. Static HTML. Each URL is a finished index.html. It works with JavaScript off, and it is what search engines see.
  3. Hydration. app.start() in the browser mounts the islands onto that HTML. Nothing else is re-rendered.
  4. Data navigation. A link click matches the route, fetches the page JSON with data.at(path) and runs the same render with it.

The same render runs in both places

A route is one object. The build and the browser read different parts of it.

HandlerBuild (Node)Browser
.generate(ctx)lists the param sets to pre-rendernever
.load(ctx)fetches the page datanever
.render(ctx)renders the HTMLrenders the swap region on navigation
.head(ctx)composes <head>the head is copied from the fetched page or re-titled

In spa and hybrid mode the build saves what load returned next to the HTML, as JSON. The browser fetches that file and passes it to render as ctx.data. There is no second projection of the data and no second render function, so the HTML and the client view cannot drift apart.

Where the data comes from

SSG → DATA → SPA
BUILD (mode !== "ssg")                 ON DISK                               CLIENT NAVIGATION
for each page of each route:           dist/hello-world/index.html           click /second-post/
  data = route.load(ctx)       ──►     (static HTML: SEO + first paint)      1. router.match(path)
  html = render(ctx) to string ──►                                           2. data.at(path) → fetch
  data.write([{ path, data }]) ──►     dist/_data/hello-world/index.json        /_data/second-post/index.json
                                       (the load() result, never HTML)       3. ctx.data = the JSON
                                                                             4. route.render(ctx) → swap

The sidecar of a list page holds what its load returned, for example slim cards. A detail page holds one article. A route without load still gets an empty {} sidecar, so client navigation to it never falls back.

what the build wrote
ls dist dist/_data
dist:
404.html  _data  _headers  assets  atom.xml  en  feed.json  feed.xml
hello-world  index.html  robots.txt  second-post  sitemap.xml  uk

dist/_data:
en  hello-world  index.json  second-post  uk
cat dist/_data/index.json
[{"contentId":"en:0000:second-post","status":"published","title":"Second post",...}]

Rendering modes

One global switch, createApp({ config: { mode } }), read by every plugin through router.mode().

ModeThe build writesThe browser doesUse it for
ssgHTML onlyfull page loads, or HTML-over-fetch when the spa plugin interceptsdocs, marketing, content sites
spaHTML and JSON sidecarsdata navigation: fetch JSON, render in placeapp-like sites
hybridHTML and JSON sidecarsdata navigation where a sidecar exists, else HTML-over-fetchthe default

Data navigation needs the data plugin on both sides: in the Node app so the build writes the sidecars, in the browser app so spa can read them. Without it, every navigation fetches the next page's HTML and swaps the main > section region.

Two apps, one route table

src/app.ts
import { buildPlugin, contentPlugin, createApp, dataPlugin, fileSystemContent } from "@moku-labs/web";
import { routes } from "./routes";

export const app = createApp({
  plugins: [contentPlugin, dataPlugin, buildPlugin],
  config: { mode: "hybrid" },
  pluginConfigs: {
    site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
    router: { routes },
    content: { providers: [fileSystemContent({ contentDir: "content" })] }
  }
});
  1. The Node app imports the routes with their loaders.
src/client.ts
import { createApp, dataPlugin } from "@moku-labs/web/browser";
import { routes } from "./routes";

const app = createApp({
  plugins: [dataPlugin],
  config: { mode: "hybrid" },
  pluginConfigs: {
    site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
    router: { routes }
  }
});

await app.start();
  1. The browser entry cannot pull in node:fs.
  2. The same table. Its loaders ship but never run here.

A complete app on the browser entry, in one file. It compiles as it is:

src/client.tsx
import { createApp, createIsland, defineRoutes, route } from "@moku-labs/web/browser";

const routes = defineRoutes({
  home: route("/").render(() => <h1>Home</h1>),
  about: route("/about/").render(() => <h1>About</h1>)
});

const clock = createIsland("clock", {
  onMount(ctx) {
    const timer = setInterval(() => {
      ctx.el.textContent = new Date().toLocaleTimeString();
    }, 1000);
    ctx.cleanup(() => clearInterval(timer));
  }
});

const app = createApp({
  config: { mode: "hybrid" },
  pluginConfigs: {
    site: { name: "Demo", url: "https://demo.dev", author: "", description: "" },
    router: { routes },
    spa: { islands: [clock] }
  }
});

await app.start();
  1. Runs when the island is destroyed, for example on navigation.
  • load runs at build only. Do not expect it on navigation. The browser reads the JSON the build wrote.
  • No HTML in the JSON. The route owns rendering, data owns transport. A missing or broken sidecar falls back to fetching the HTML, then to a full page load.
  • Use @moku-labs/web/browser for the client. Its import graph has no Node module. Tree-shaking the root entry is not the same guarantee: bundling entries together can merge Node code into a shared chunk.
  • The layout is not re-applied on the client. .layout() frames the page at build. On navigation the chrome stays and only the swap region changes.