Data
SSG to DATA to SPA: the build saves each page's load() result, the browser fetches it.
How data works
In spa and hybrid mode, with the data plugin composed, the build saves what each page's
load returned as a JSON file next to the HTML. On a link click, spa matches the route,
fetches that file with data.at(path) and runs the route's own render with it as ctx.data.
The payload follows load. A list page stores the slim cards its load returned. A detail page
stores one record. There is no second data shape to maintain, and no HTML in the JSON.
- load(ctx)
- data.write
- _data/…/index.json
- data.at(path)
- render(ctx)
data is not a default plugin. Compose it on both sides: in the Node app so the build writes the
files, and in the browser app so spa reads them. It has no dependencies and its Node writer is
loaded lazily, so the browser bundle stays free of node:*.
Example
import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser";
export const routes = defineRoutes({
home: route("/")
.load(async ctx => {
const content = ctx.require(contentPlugin);
const articles = (await content.loadAll()).get(ctx.locale) ?? [];
return articles.map(article => content.articleToCard(article));
})
.render(ctx => (
<ul>
{ctx.data.map(card => (
<li key={card.contentId}>
<a href={card.url}>{card.title}</a> · {card.readingTime} min
</li>
))}
</ul>
))
});- Return only what
renderneeds. This array is the whole sidecar.
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" })] }
}
});- Node side: the build writes one sidecar per rendered page.
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();- Browser side:
spafinds it withctx.has("data")and fetches sidecars on navigation.
At build
bun run build ▟▙ moku web build v2.4.11 ✓ pages · 5ms ✓ BUILD · 9 pages 1252ms · ./dist/ find dist/_data -name index.json | sort dist/_data/en/hello-world/index.json dist/_data/en/index.json dist/_data/en/second-post/index.json dist/_data/hello-world/index.json dist/_data/index.json dist/_data/second-post/index.json dist/_data/uk/hello-world/index.json dist/_data/uk/index.json dist/_data/uk/second-post/index.json cat dist/_data/index.json [{"contentId":"en:0000:second-post","status":"published","title":"Second post","date":"2026-02-01","description":"Another one.","tags":[],"readingTime":1,"url":"/second-post/"},…]
One sidecar per rendered page, including the /en/… copies. A page whose route has no load
gets {}.
The URL convention
urlFor and fileFor come from one function, so the file the build writes is exactly the URL
the browser fetches.
| Page path | fileFor(path) | urlFor(path) |
|---|---|---|
/ | _data/index.json | /_data/index.json |
/en/hello/ | _data/en/hello/index.json | /_data/en/hello/index.json |
/en/hello | _data/en/hello/index.json | /_data/en/hello/index.json |
outputDir (default "_data") is the folder under the build outDir. baseUrl (default
"/_data/") is the URL the browser fetches from. Keep them in step.
Read data yourself
app.data.at(path) fetches and caches a sidecar. It returns unknown, or null when the fetch
or the JSON parse fails.
import { createApp, dataPlugin, defineRoutes, route } from "@moku-labs/web/browser";
const routes = defineRoutes({ home: route("/").render(() => <h1>Home</h1>) });
const app = createApp({
plugins: [dataPlugin],
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
router: { routes }
}
});
const url = app.data.urlFor("/second-post/"); // "/_data/second-post/index.json"
const page = await app.data.at("/second-post/");
console.log(url, page === null ? "no sidecar" : "ready");- Cached after the first fetch. A later navigation to the page reuses it.
ssgmode writes no sidecars. Setconfig.modeto"spa"or"hybrid"on both apps.- Compose
dataon both sides. Node only: the files exist but are never read. Browser only: every fetch fails and navigation falls back to HTML. rendermust be a function ofctx.data,ctx.paramsandctx.locale. On the client nothing else is there. A render that reads module state shows different output after navigation.- There is no validation step. The fetched JSON is
ctx.dataas it is. A missing or broken file falls back to HTML-over-fetch, then to a full page load. baseUrlmust start with/. OtherwisecreateAppthrows[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").
Related
- data plugin: config and the
app.dataAPI. - How it works: where the data comes from.
- SPA navigation: what happens on a link click.