Pattern: a dynamic space is one App Durable Object running Hono
When to reach for this. Your space needs to do something per request — handle a form, persist state, render a page from data, gate a route. Anything beyond serving static files. This is the foundation every other dynamic pattern (auth, a blog, an API) builds on.
The stance. Don't reach for a framework or a build tool. The whole dynamic
stack is Hono (routing) + hono/jsx (server-rendered HTML) + the facet's
own Durable Object storage (state). One named App class, one Hono app
inside it. That's it — and it's enough for almost everything.
The shape — three files
The entry must be .ts/.js (a .tsx can be imported but is never the
entry), so src/index.ts is a one-line shim:
package.json # declares hono — REQUIRED, or `import … from "hono"` 500s at runtime
src/index.ts # entry shim: re-exports App
src/app.tsx # the Hono app + the App DO class
package.json — declare every npm import
{ "name": "my-site", "private": true, "main": "src/index.ts",
"dependencies": { "hono": "^4.12.18" } }
A bare import { Hono } from "hono" that isn't declared here is silently left
external and throws No such module "hono" at runtime. Declaring it lets the
bundler fetch it at build time. This is the #1 first-deploy failure — declare
your deps.
src/index.ts
export { App } from "./app";
src/app.tsx — Hono + hono/jsx + DO storage
One hard rule the facet host enforces:
- Named
export class App— the host resolvesgetDurableObjectClass("App"); anexport defaultfails at startup.
JSX needs no pragma — the bundler uses the automatic runtime with hono/jsx
(that's why hono in package.json is required for any .tsx).
import { Hono } from "hono";
import { DurableObject } from "cloudflare:workers";
function Page(props: { items: string[] }) {
return (
<html>
<body>
<h1>Guestbook</h1>
<ul>{props.items.map((m) => <li>{m}</li>)}</ul>
<form method="post" action="/">
<input name="msg" />
<button type="submit">Sign</button>
</form>
</body>
</html>
);
}
export class App extends DurableObject {
app = new Hono();
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
const read = () => (this.ctx.storage.kv.get("items") as string[] | undefined) ?? [];
this.app.get("/", (c) => c.html("<!doctype html>" + (<Page items={read()} />).toString()));
// Read formData → persist → re-render. The classic POST-renders-the-result loop.
this.app.post("/", async (c) => {
const msg = String((await c.req.formData()).get("msg") ?? "").trim();
const items = read();
if (msg) { items.push(msg); this.ctx.storage.kv.put("items", items); }
return c.html("<!doctype html>" + (<Page items={items} />).toString());
});
}
async fetch(request: Request) { return this.app.fetch(request); }
}
State survives publishes — that's the point
ctx.storage.kv / ctx.storage.sql is the facet's OWN Durable Object storage. A
republish does an abort-then-get on the same facet, so your data survives a code
change — guestbook entries, sessions, a counter all persist across deploys. For
SQL + parameterized queries, see dynamic-worker.
Precedence — the worker only sees what static didn't answer
A request resolves bundled assets → media → your worker. So /index.html (a
public/ asset) and /hero.jpg (media) never reach App.fetch — you only handle
what nothing static answered (here, GET / SSR + POST /). Don't author an
index.html in public/ if you want your worker to own /.
Keep the admin hooks — your App replaces the scaffold
Writing your own App replaces the scaffold's src/index.ts, which carried the
__query / __queryTransaction RPC hooks behind the platform's Database
console and introspect.query. Without them, both answer { ok: false, code: "UNSUPPORTED" } (every other admin section keeps working). Paste the two
methods from admin-prep onto your App class to
keep the SQL console alive.
Ship it
// in run_code: write the files, then go live in the same call
async () => {
// …state.writeFile(...) the three files…
return host.publish(); // waits for the deploy → { ok, status, live, snapshotHash, instanceId, url?, visibility? } — url once live
}
host.build() first if you want to check warnings without going live. The full
edit→build→publish loop is workflow; the file API is
run-code; entry/asset rules are
bundling.
Next: gate it by identity — pick an auth pattern (unfolder.auth-basic for a
shared or user+password admin, unfolder.auth-sessions for "sign in with Google",
unfolder.auth-accounts for public sign-up). Auth runs in your facet — edge
Cloudflare Access can't gate a space (your own-zone Access is never in the request
path, so no Cf-Access-* header reaches the facet). Make the links and forms feel
instant with unfolder.unpoly (progressive enhancement — no SPA). Or, if some
of your routes rarely change per request, set a long Cache-Control on them so
repeat hits skip the worker entirely — a genuinely static slice (portfolio,
landing page, docs) is better shipped as plain files, see
unfolder.static-site.