Quick start
From an empty folder to a deployed static blog with client navigation.
A static blog in eight steps
-
Create the project and install.
preact,preact-render-to-string,@moku-labs/coreand@moku-labs/commonare peer dependencies. Declare them yourself: a second copy of Preact breaks hooks and island hydration.install mkdir my-blog && cd my-blog bun init -y bun add @moku-labs/web @moku-labs/core @moku-labs/common preact preact-render-to-string
-
Point JSX at Preact. Routes and islands are
.tsx. Without these two lines Bun looks for React and the build fails.tsconfig.json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact", "module": "Preserve", "moduleResolution": "bundler", "strict": true } } -
Add content. One folder per article, one file per locale. The frontmatter fields
title,date,description,tagsandlanguageare required.content/hello-world/en.md --- title: Hello world date: 2026-01-15 description: The first post. tags: [intro] language: en --- Some text. -
Write the routes. A route is a pattern plus
generate,load,renderandhead.generateandloadrun at build only.renderandheadrun at build and in the browser. -
Compose the app. The defaults (
site,i18n,router,head,spa) are always there. Add the Node-only plugins the build needs. -
Add one script per command. The
cliplugin has no argument parser. Each script calls one method. -
Build.
bun run buildwrites static HTML, feeds, a sitemap and the JSON sidecars intodist/. -
Deploy.
bun run deployruns a guided wizard: it checks the Cloudflare credentials, offers to scaffoldwrangler.jsonc, test-builds and shipsdist/to Cloudflare Pages.
The code
The routes import from @moku-labs/web/browser, because the client bundle imports this file
too. The app imports from @moku-labs/web, the Node entry.
import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser";
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 => (
<ul>
{ctx.data.map(card => (
<li key={card.contentId}>
<a href={card.url}>{card.title}</a>
</li>
))}
</ul>
))
.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,
description: ctx.data.frontmatter.description
}))
});- One home page per locale. Without
generateonly the default locale gets one. - Sibling plugins come through
ctx.require. The return type becomesctx.data.
import {
buildPlugin,
cliPlugin,
contentPlugin,
createApp,
dataPlugin,
deployPlugin,
dotenv,
fileSystemContent,
processEnv
} from "@moku-labs/web";
import { routes } from "./routes";
export const app = createApp({
plugins: [contentPlugin, dataPlugin, buildPlugin, deployPlugin, cliPlugin],
config: { mode: "hybrid" },
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
i18n: { locales: ["en", "uk"], defaultLocale: "en" },
router: { routes },
head: { titleTemplate: "%s · My Blog" },
content: { providers: [fileSystemContent({ contentDir: "content" })] },
build: { clientEntry: "src/client.tsx", notFound: true },
env: { providers: [processEnv(), dotenv(".env")] }
}
});- Node-only plugins are opt-in.
dataPluginmakes the build write the JSON sidecars. notFound: truewritesdist/404.html.cli.build()fails without it.
import { app } from "../src/app";
await app.cli.build();{
"scripts": {
"build": "bun scripts/build.ts",
"dev": "bun scripts/serve.ts",
"preview": "bun scripts/preview.ts",
"deploy": "bun scripts/deploy.ts"
}
}scripts/serve.ts and scripts/preview.ts are the same two lines with app.cli.serve() and
app.cli.preview(). scripts/deploy.ts turns on the guided wizard unless --cli is passed:
import { app } from "../src/app";
await app.cli.deploy({ guided: !process.argv.includes("--cli") });Build
bun run build ▟▙ moku web build v2.4.11 ✓ bundle · 5ms ✓ images · 0ms ✓ content · 1239ms ✓ pages · 5ms ✓ content-images · 0ms ✓ not-found · 1ms ✓ feeds · 2ms ✓ sitemap · 1ms ✓ cache-headers · 0ms ✓ root-index · 0ms ✓ BUILD · 9 pages 1252ms · ./dist/
Nine pages from two articles, hello-world in English and Ukrainian and second-post in English only: the English pages at bare paths, a copy of each under /en/
with a canonical link to the bare URL, and the Ukrainian pages under /uk/. The second article
has no uk.md, so /uk/second-post/ shows the English text.
Add client navigation
The browser entry has the same createApp over the same defaults, with every Node-only module
left out. Name it in build.clientEntry and the build bundles it into dist/assets/.
import { createApp, dataPlugin, defineRoutes, route } from "@moku-labs/web/browser";
const routes = defineRoutes({ home: route("/").render(() => <h1>My Blog</h1>) });
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();- In your app, import the shared
./routeshere instead. - With
datacomposed, a link click fetches the page JSON and runs the route'srender. - Mounts islands on the static HTML and starts intercepting links.
The browser app needs the same site, i18n and router config as the Node app. Put the
shared values in one module and import it in both.
Deploy
Put the Cloudflare credentials in .env (keep it out of git) and in the repository secrets for
CI. Then deploy.
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_ACCOUNT_ID=...bun run deploy ▟▙ moku web deploy v2.4.11 ✓ DEPLOYED · main 9.4s → https://my-blog.pages.dev · 3f2a9c1e
The project name is the site name as a slug: My Blog deploys to the Pages project my-blog.
See Build & deploy for the guided wizard and CI.
loadandgeneraterun at build only. The browser never calls them. On client navigation it reads the JSON the build saved.- Import routes from
@moku-labs/web/browser. The client bundle imports the route file. A route file that imports@moku-labs/webdrags Node code into the bundle and the build fails on a Node-only dependency. site.nameandsite.urlare required.createAppthrows[web] site.name is required.when they are missing, in the Node app and in the browser app.cli.build()checks fordist/404.html. Setbuild.notFound, or Cloudflare Pages treats the project as an SPA.
Next
- How it works: what the build writes and what the browser does with it.
- Routing: patterns, builder methods, links.
- Islands: make parts of a page interactive.
- Build & deploy: scripts, the dev server, Cloudflare Pages.