A site with an API
Serve a @moku-labs/web site and a Worker API from one deploy, with one dev command.
On this page
The shape
A @moku-labs/web site is static files. A Worker can serve them as assets, next to its
API. Three parts:
assets. The wrangler block that servesdist/and exposes it on a binding.- A fall-through route. Your API routes match first. Anything else goes to the assets.
- Build hooks.
devanddeploybuild the site through hooks your script passes in.
The site app is untouched. The worker package does not import it. Your script passes the two together.
The Worker
The fall-through lives in your entry: try the API, and on a 404 ask the assets.
import { createApp, endpoint } from "@moku-labs/worker";
export const app = createApp({
config: { name: "my-site", compatibilityDate: "2026-06-17" },
pluginConfigs: {
server: {
endpoints: [endpoint("/api/hello").get(() => Response.json({ hello: "world" }))]
}
}
});import { app } from "./app";
type Assets = { fetch(request: Request): Promise<Response> };
export default {
fetch: async (request: Request, env: Record<string, unknown>, ctx: ExecutionContext) => {
const response = await app.server.handle(request, env, ctx);
if (response.status !== 404) return response;
return app.bindings.require<Assets>(env, "ASSETS").fetch(request);
}
} satisfies ExportedHandler;- Not an API path: the static site answers.
An API route that returns its own 404 falls through too. Mount the API under one prefix and check it before you hand over, if that matters.
The script
import {
cliPlugin,
createApp,
d1Plugin,
deployPlugin,
durableObjectsPlugin,
kvPlugin,
queuesPlugin,
storagePlugin
} from "@moku-labs/worker";
import { web } from "./web";
const tools = createApp({
plugins: [storagePlugin, kvPlugin, d1Plugin, queuesPlugin, durableObjectsPlugin, deployPlugin, cliPlugin],
config: { name: "my-site", compatibilityDate: "2026-06-17" },
pluginConfigs: {
deploy: {
entry: "src/worker.ts",
nodeCompat: true,
assets: { binding: "ASSETS", directory: "dist" }
}
}
});
const webBuild = () => web.cli.build();
if (process.argv[2] === "deploy") await tools.cli.deploy({ webBuild });
else await tools.cli.dev({ webBuild, onChange: changes => web.cli.update(changes) });- The Node app of your site, with the web
cliplugin.
| Command | What runs |
|---|---|
dev | One cold webBuild. Local migrations. wrangler dev --live-reload once. Then onChange per batch of changes. |
deploy | Auth, then webBuild, then the rest of the pipeline. So the assets exist before wrangler ships them. |
A failed rebuild emits dev:error and keeps serving the last good build. wrangler is not restarted
for a site change, and its asset server reloads the browser.
For an app with client routes only, assets: { binding: "ASSETS", directory: "dist", spa: true } serves
index.html for unknown paths. A static-first site with real pages does not want it, because a missing
page would then answer 200.
watchcoverssrcandpublic. A site with content elsewhere adds it:deploy: { watch: ["src/**/*", "public/**/*", "content/**/*"] }. Only the top directory of each glob is used.- No hook means a fallback. Without
webBuild,devrunsbuildCommand, thenscripts/build.tsif it exists, and otherwise serves the Worker alone. deployhas no such fallback. It builds only throughwebBuild, from the call or from config.- Two
cliplugins, two apps. The web app has its owncliand the worker app has its own. They do not share a list.
Related
- deploy plugin:
assets,watchand the hooks. - web cli plugin:
buildandupdate. - server plugin: the endpoint table.