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 resolves getDurableObjectClass("App"); an export default fails 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.