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

@moku-labs/web

Content static-site generator and SPA framework. Same render at build and in the browser.

web.mdllms.txt

What it is

@moku-labs/web renders Preact pages to static HTML for search engines and first paint. Then it hydrates islands and, if you want it, navigates on the client from JSON. The same render function runs at build and in the browser, so the two can never disagree.

It is a Moku framework: the @moku-labs/core kernel plus a list of plugins. You write one createApp call. The browser-safe plugins are always there. You add the Node-only plugins for the build.

Five things it does

  • SSG and SPA parity. One route, two runtimes. The build writes HTML, the browser re-runs the same render with the saved data.
  • A typed route. route("/{slug}/").load().render().head(). Params come from the pattern, ctx.data from load.
  • Full SEO. Title template, canonical, Open Graph, Twitter, JSON-LD, hreflang, feeds, sitemap and OG images.
  • A node-free browser entry. @moku-labs/web/browser has zero Node imports in its graph. About 50 kB gzip.
  • Islands. Only the interactive parts run in the browser, each with its own state, render, events and API.

Quick start

  1. Install the framework and its peers: bun add @moku-labs/web @moku-labs/core @moku-labs/common preact preact-render-to-string.
  2. Write the routes with route(): a pattern, a load, a render, a head.
  3. Compose the app with createApp, adding contentPlugin and buildPlugin for the build.
  4. Build with await app.build.run(). The site is in dist/.

A whole static blog fits in two files:

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

export const routes = defineRoutes({
  home: route("/")
    .render(() => <h1>My Blog</h1>)
    .head(() => ({ title: "My Blog" })),
  about: route("/about/")
    .render(() => <p>Notes on the web.</p>)
    .head(() => ({ title: "About" }))
});
  1. .head() feeds the title template, canonical and OG tags.

The quick start goes from an empty folder to a deployed site.

Two entry points

EntryFormatUse it for
@moku-labs/webESM and CJSThe Node build. Everything, including buildPlugin, deployPlugin, cliPlugin, fileSystemContent and the Node env providers.
@moku-labs/web/browserESM onlyThe client bundle. The same createApp over the same defaults, plus dataPlugin, the route helpers, createIsland and the head helpers. env reads browserEnv() with no config.

The eleven plugins

The defaults are loaded by every createApp. You can only configure them. The others you add in plugins: [...].

PluginKindWhat it does
sitedefaultSite name, URL, author, description, canonical URLs.
i18ndefaultLocales, default locale, translations, og:locale map.
routerdefaultThe route table, the matcher, URL and file paths, the render mode.
headdefaultThe <head> of every page.
spadefaultIslands, intercepted navigation, progress bar. Inert on Node.
contentNode buildMarkdown articles to HTML, with locale fallback.
collectionoptional(collection, shard) → JSON file, read on demand.
dataoptionalPage path → JSON file, for client navigation.
buildNode-onlyThe static build into dist/.
deployNode-onlyCloudflare Pages through wrangler.
cliNode-onlybuild, serve, preview, deploy with the branded panel.

Two core plugins come with every app: log and env, from @moku-labs/common.

Start with the Claude plugin

Install the moku plugin in Claude Code and describe the site in plain words. It knows this framework, writes the routes, the islands and the tests, and runs the build.

Claude Code
/plugin marketplace add moku-labs/claude
/plugin install moku@moku-labs

Build a blog with tags and an RSS feed on @moku-labs/web

Add a search box that reads a JSON index built at deploy time

Where to next

  • Quick start: a static blog in two files, client navigation in a third.
  • How it works: one route, two places.
  • Routing: route().load().render().head(), the single contract.
  • Islands: the parts that run in the browser.
  • API reference: every export of both entries.