cloudflare-bundler-apps
Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts).
Warnings for the author
- "name" should match the name of the directory that holds SKILL.md
- Not listed through the skills extension: the directory must be named after the skill.
This skill teaches how to build apps that deploy cleanly through the space deploy pipeline. Every project committed to a space is built by @cloudflare/worker-bundler (createApp when there are static assets, createWorker when there are none) and served on a Dynamic Worker via WorkerLoader. Get the conventions right and the preview "just works". Get them wrong and you get a blank page, a 500, or a Build failed error.
How the pipeline works
When deploy_space(branch) is called, the space DO:
- Reads every file from the branch's working tree (skipping
.git/). - Parses
wrangler.json/wrangler.jsonc/wrangler.tomlformain,compatibility_date,compatibility_flags, and[assets]. - If
[assets].directoryis set, files under that directory become static assets served host-side byhandleAssetRequest. Everything else is built into a Worker viacreateApp(withserver: <main>). - If no assets directory is configured, only
createWorker({ entryPoint: <main> })is run. The output is loaded as a Dynamic Worker; all requests go to the Worker. - Previews are served at
/space/:name/preview/:branch/*. Responses withcontent-type: text/htmlare run throughHTMLRewriter, which prefixes root-relativesrc/href/actionattributes with the preview base path. JS-side fetches and dynamic imports are NOT rewritten.
Practical consequences:
- You can rely on root-relative
src="/foo.js"andhref="/style.css"in HTML. They will be rewritten to the preview path automatically. - You cannot rely on root-relative paths inside JS strings:
fetch("/api/x"),new URL("/foo", location.origin), dynamicimport("/lib.js"). These hit the wrong path under the preview prefix. Use relative paths (./api/x,import.meta.url) or read the base path from<base href>/ a meta tag injected at build time. - The
<base href>tag is also rewritten if present — using it lets all relative URLs resolve against the preview path.
Project layout
A typical full-stack space project:
/
├── wrangler.json # required for non-trivial setups
├── package.json # npm deps (text-only packages, see limits below)
├── src/
│ └── index.ts # server entry: export default { fetch }
└── public/ # static assets directory (configurable)
├── index.html
├── app.js # compiled, no JSX
└── styles.css
A static-only SPA can skip src/ entirely — just wrangler.json + public/ is enough as long as main points to a minimal pass-through worker or you accept that all requests fall through assets.
Wrangler config
Minimum viable wrangler.json:
{
"main": "src/index.ts",
"compatibility_date": "2025-04-01",
"assets": {
"directory": "./public",
"html_handling": "auto-trailing-slash",
"not_found_handling": "single-page-application"
}
}
Notes:
mainis required when there's any server code. The bundler also auto-detectssrc/index.ts,src/index.js,index.ts,index.jsif missing.compatibility_datedefaults to2025-04-01if omitted. Set it explicitly for newer features.- Add
"compatibility_flags": ["nodejs_compat"]only if you actually need Node built-ins. assets.directoryis the only way to ship static files. Files outside this directory are bundled into the Worker or ignored — they will not be reachable via URL.html_handling: "auto-trailing-slash"is usually what you want for multi-page sites; SPAs should also setnot_found_handling: "single-page-application"so deep links returnindex.html.
TOML works too (wrangler.toml), but the parser only handles top-level scalar fields plus an [assets] table — no inline tables, no env overrides. Prefer JSON.
Server entry: one Durable Object class named App
Your entire backend is one Durable Object. Your main module exports
a class named App extending DurableObject. The platform loads it as
a Cloudflare Durable Object
Facet
of the host SpaceDO and forwards every non-asset request — including
WebSocket upgrades — to your App.fetch(request).
Do not export default { fetch }. A default fetch handler will be
ignored. There is no separate worker; the DO is the worker.
// src/index.ts
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Lazy schema init on the first request. Idempotent.
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL
)
`);
if (url.pathname === "/api/notes" && request.method === "POST") {
const { body } = await request.json<{ body: string }>();
this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);
return new Response(null, { status: 201 });
}
if (url.pathname === "/api/notes") {
const rows = this.ctx.storage.sql
.exec(`SELECT id, body FROM notes ORDER BY id DESC`)
.toArray();
return Response.json(rows);
}
return new Response("Not found", { status: 404 });
}
}
Or with Hono inside the DO:
import { DurableObject } from "cloudflare:workers";
import { Hono } from "hono";
export class App extends DurableObject {
private app = new Hono()
.get("/api/hello", (c) => c.json({ msg: "hi" }))
.post("/api/notes", async (c) => {
const { body } = await c.req.json<{ body: string }>();
this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);
return c.json({ ok: true });
});
async fetch(request: Request) {
return this.app.fetch(request);
}
}
When [assets] is configured, static files take priority — your fetch
only sees requests that didn't match an asset. For a SPA, put
index.html in public/ and App.fetch only handles /api/*.
State and storage
State lives on this.ctx.storage. No env.DB exists; you do not need
to declare any binding. The DO's storage is a per-(space, branch)
SQLite database that survives redeploys.
this.ctx.storage.sql.exec(sql, ...params)— full SQLite. Returns a cursor with.toArray(),.one(),.columnNames, etc.this.ctx.storage.kv.get(key)/.put(key, value)— simple KV backed by SQLite.this.ctx.storage.transactionSync(() => { ... })— atomic batch inside the same DO instance.
// SQL
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS counter (n INTEGER)`);
const cur = this.ctx.storage.sql.exec(`SELECT n FROM counter LIMIT 1`).toArray();
const n = (cur[0]?.n as number) ?? 0;
// KV
const last = this.ctx.storage.kv.get<number>("last_seen") ?? 0;
this.ctx.storage.kv.put("last_seen", Date.now());
Use the DO's storage for all persistent state — users, sessions,
content, settings, anything that should survive a reload. Do not use
localStorage or sessionStorage for primary data; they are
per-browser, per-origin, and lost on incognito or any other browser.
WebSockets
The platform forwards Upgrade: websocket requests transparently into
App.fetch. Use the standard Durable Object hibernation API:
export class App extends DurableObject {
async fetch(request: Request) {
const upgrade = request.headers.get("Upgrade");
if (upgrade === "websocket") {
const { 0: client, 1: server } = new WebSocketPair();
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("not found", { status: 404 });
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// Broadcast to every connected client (chat-style fan-out).
for (const peer of this.ctx.getWebSockets()) {
peer.send(typeof message === "string" ? message : new Uint8Array(message));
}
}
webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {
// Cleanup if needed.
}
}
acceptWebSocket + webSocketMessage is the right pattern for
multi-connection use cases (chat rooms, multiplayer, presence) because
the DO can hibernate between messages without losing connections.
Per-entity grouping (chat rooms, per-user records)
You get one App DO per (space, branch) — a single instance, not a
namespace. Do not call idFromName or try to declare a DO binding in
wrangler.json; both are rejected. For multi-room / multi-user state,
partition rows with a column in the App's SQLite:
CREATE TABLE messages (
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
body TEXT NOT NULL,
ts INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
);
CREATE INDEX messages_by_room ON messages (room_id, ts);
Then route by room_id inside App.fetch. SQLite is fast enough that
sharing the same table across rooms is fine for prototyping; if you
later need stronger isolation, attach room_id to a tag and use
acceptWebSocket(ws, [room_id]) so getWebSockets(room_id) returns
only that room's sockets.
What you cannot declare
durable_objects.bindingsinwrangler.json. The platform extracts yourAppclass automatically. Adding bindings fails the deploy with a clear error.new_sqlite_classes/ migrations. Same reason. Your App's storage is set up by the platform.d1_databases,r2_buckets,kv_namespaces. Not available. Everything goes inthis.ctx.storage.
Static asset rules (read this twice)
This is where most preview failures happen. The browser eventually runs your assets — the bundler does not transform them. So:
NEVER ship JSX in a <script type="module"> (or any other script tag). Browsers cannot parse JSX.
This page will be blank with a SyntaxError: Unexpected token '<':
<script type="module">
import React from "https://esm.sh/react@18";
const App = () => <div>hi</div>; // ← browser dies here
</script>
Fix one of these three ways:
-
Pre-compile — write JSX in a build step (Vite/esbuild/tsup) and emit plain JS into
public/. Best for production. -
React.createElementby hand — works without a build step but is verbose. -
@babel/standalone— only for prototypes. Load Babel before the script and usetype="text/babel". The modern@babel/preset-reactdefaults to the automatic JSX runtime, which emitsimport { jsx } from "react/jsx-runtime". Browsers will reject that bare specifier unless your importmap maps it. Always ship a complete importmap alongside the Babel script:<script type="importmap"> { "imports": { "react": "https://esm.sh/react@18.3.1", "react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime", "react-dom": "https://esm.sh/react-dom@18.3.1", "react-dom/client": "https://esm.sh/react-dom@18.3.1/client" } } </script> <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> <script type="text/babel" data-type="module" data-presets="react"> import React from "react"; import { createRoot } from "react-dom/client"; const App = () => <div>hi</div>; createRoot(document.getElementById("root")).render(<App />); </script>data-type="module"is required forimportto work inside the Babel script. Import React from the importmap key ("react") — not from a hard-codedesm.shURL — so your code and Babel's emittedreact/jsx-runtimeimport resolve to the same React instance.If you really cannot ship
react/jsx-runtimein the importmap, force Babel to the legacy classic runtime instead so it emitsReact.createElementcalls and never touchesreact/jsx-runtime:<script type="text/babel" data-type="module" data-presets="react" data-plugins='[["transform-react-jsx", { "runtime": "classic" }]]' > import React from "https://esm.sh/react@18.3.1"; /* must be in scope */ const App = () => <div>hi</div>; </script>The classic runtime requires
Reactto be in lexical scope (because<div>becomesReact.createElement("div")). The automatic runtime (default) does not, but needsreact/jsx-runtimeresolvable.
Symptom-to-fix index
| Console error | Cause | Fix |
|---|---|---|
Uncaught SyntaxError: Unexpected token '<' (inside <script type="module">) | JSX shipped raw to the browser | Pre-compile, or use @babel/standalone with type="text/babel" |
Uncaught TypeError: Failed to resolve module specifier "react/jsx-runtime". Relative references must start with either "/", "./", or "../". | Automatic JSX runtime emits import "react/jsx-runtime" but importmap doesn't map it | Add "react/jsx-runtime": "https://esm.sh/react@<same-version>/jsx-runtime" to the importmap before the Babel/script tag — or force the classic runtime (see above) |
Uncaught TypeError: Failed to resolve module specifier "react" (or any bare name) | No importmap entry for that package | Add it to the importmap; importmap script tag must appear before any module script that uses the specifier |
TypeError: Cannot read properties of null (reading 'useContext') | Dual-React (two copies loaded) | Append ?external=react,react-dom to every esm.sh URL with React as a peer dep — see the dual-React trap below |
Other asset gotchas
- Binary assets are not extracted from npm packages. Fonts, images, and
.wasmshipped via npm tarballs will be missing. Put binaries directly inpublic/instead. - Paths in JS need care under preview: prefer
import.meta.url, relative paths, or a runtime base detected fromdocument.baseURI. Don't hard-code/api/...in client code; use./api/...or read a base from a<meta>tag. - Trailing slashes matter for
auto-trailing-slashmode:/aboutservesabout.html,/about/servesabout/index.html. Pick one shape and link consistently. - No CSS preprocessors at runtime — ship
.css, not.scss. Compile first if you need Sass. - Importmaps work since they're inline JSON. Use them to avoid bundling React/etc. for prototypes — but watch the dual-React trap below.
The dual-React trap (the #1 cause of "blank page + useContext is null`)
esm.sh bundles a package's peer dependencies into the package itself unless you tell it otherwise. So this importmap looks correct but ships two copies of React — one for your app, another nested inside framer-motion:
<!-- BROKEN: framer-motion has its own React inside, hooks crash with `Cannot read properties of null (reading 'useContext')` -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom": "https://esm.sh/react-dom@18.2.0/client",
"framer-motion": "https://esm.sh/framer-motion@11"
}
}
</script>
The symptom is exactly: blank background + console errors like
TypeError: Cannot read properties of null (reading 'useContext')
at u (SwitchLayoutGroupContext.mjs:1:1)
because the nested React instance has no provider in the tree.
Fix: append ?external=react,react-dom (and any other shared peers) to every esm.sh URL that has React as a peer dep. Pin the exact same React version everywhere too.
<!-- CORRECT: one React shared across the whole importmap -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
"react-dom": "https://esm.sh/react-dom@18.3.1",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
"framer-motion": "https://esm.sh/framer-motion@11?external=react,react-dom",
"lucide-react": "https://esm.sh/lucide-react@0.400.0?external=react,react-dom"
}
}
</script>
Apply ?external=... to every React-consuming dep (UI libraries, icon sets, animation libs, headless components). Same rule for vue, solid-js, etc. if you swap the framework.
If a project needs more than two or three of these packages, stop using the importmap path — switch to option 1 (pre-compile with Vite/esbuild) so the bundl
Files of this skill
This skill has no supporting files.