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

Quick start

From an empty folder to a deployed static blog with client navigation.

quick-start.mdllms.txt

A static blog in eight steps

  1. Create the project and install. preact, preact-render-to-string, @moku-labs/core and @moku-labs/common are 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
  2. 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
      }
    }
  3. Add content. One folder per article, one file per locale. The frontmatter fields title, date, description, tags and language are required.

    content/hello-world/en.md
    ---
    title: Hello world
    date: 2026-01-15
    description: The first post.
    tags: [intro]
    language: en
    ---
    
    Some text.
  4. Write the routes. A route is a pattern plus generate, load, render and head. generate and load run at build only. render and head run at build and in the browser.

  5. Compose the app. The defaults (site, i18n, router, head, spa) are always there. Add the Node-only plugins the build needs.

  6. Add one script per command. The cli plugin has no argument parser. Each script calls one method.

  7. Build. bun run build writes static HTML, feeds, a sitemap and the JSON sidecars into dist/.

  8. Deploy. bun run deploy runs a guided wizard: it checks the Cloudflare credentials, offers to scaffold wrangler.jsonc, test-builds and ships dist/ 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.

src/routes.tsx
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
    }))
});
  1. One home page per locale. Without generate only the default locale gets one.
  2. Sibling plugins come through ctx.require. The return type becomes ctx.data.
src/app.ts
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")] }
  }
});
  1. Node-only plugins are opt-in. dataPlugin makes the build write the JSON sidecars.
  2. notFound: true writes dist/404.html. cli.build() fails without it.
scripts/build.ts
import { app } from "../src/app";

await app.cli.build();
package.json
{
  "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:

scripts/deploy.ts
import { app } from "../src/app";

await app.cli.deploy({ guided: !process.argv.includes("--cli") });

Build

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/.

src/client.tsx
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();
  1. In your app, import the shared ./routes here instead.
  2. With data composed, a link click fetches the page JSON and runs the route's render.
  3. 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.

.env
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_ACCOUNT_ID=...
deploy
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.

  • load and generate run 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/web drags Node code into the bundle and the build fails on a Node-only dependency.
  • site.name and site.url are required. createApp throws [web] site.name is required. when they are missing, in the Node app and in the browser app.
  • cli.build() checks for dist/404.html. Set build.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.