Build a server-rendered site with Hono + hono/jsx

This is the recommended stack for an unfolder.space facet backend: a Hono app, server-rendered with hono/jsx, state in the facet's own Durable Object SQLite, and (optionally) gated private media. Everything below builds, deploys, and runs as-is.

Project shape

Four files. The entry must be .ts/.js (.tsx is supported as a module but is not an entry candidate), so src/index.ts is a one-line shim that re-exports the JSX App.

package.json        # declares hono — REQUIRED, or `import ... from "hono"` 500s at runtime
src/index.ts        # entry shim: re-exports App
src/app.tsx         # Hono app: views + routes + the DO class
src/views.tsx       # hono/jsx components (optional split)

package.json — declare the dependency

{
  "name": "my-site",
  "private": true,
  "main": "src/index.ts",
  "dependencies": { "hono": "^4.12.18" }
}

A bare import { Hono } from "hono" that is not declared here is silently left external and throws No such module "hono" at runtime. Declaring it lets the bundler install it from npm at build time.

src/index.ts — the entry shim

export { App } from "./app";

src/app.tsx — Hono + hono/jsx + DO storage

createApp uses esbuild's classic JSX runtime, so every .tsx file needs the /** @jsx jsx */ pragma and import { jsx } from "hono/jsx". The class must be a named export class App (the facet host resolves getDurableObjectClass("App"); export default fails at startup).

/** @jsx jsx */
import { jsx } from "hono/jsx";
import { Hono } from "hono";
import { DurableObject } from "cloudflare:workers";

function Layout(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: any, env: any) {
    super(ctx, env);
    const read = () => (this.ctx.storage.kv.get("items") as string[] | undefined) ?? [];

    this.app.get("/", (c) =>
      c.html("<!doctype html>" + (<Layout items={read()} />).toString()),
    );

    // Form handling: read formData, persist, re-render.
    this.app.post("/", async (c) => {
      const form = await c.req.formData();
      const msg = String(form.get("msg") ?? "").trim();
      const items = read();
      if (msg) {
        items.push(msg);
        this.ctx.storage.kv.put("items", items);
      }
      return c.html("<!doctype html>" + (<Layout items={items} />).toString());
    });
  }

  async fetch(request: Request) {
    return this.app.fetch(request);
  }
}

State lives in ctx.storage.kv / ctx.storage.sql — production preserves it across redeploys; previews start fresh. For SQL and parameterized queries see dynamic-worker.

Serving private ("_") media through the worker

Public media (/hero.jpg) is served straight from the bucket — your worker never sees the request. Media whose first path segment starts with _ (/_deck.pdf, /_private/x.pdf) is never served unsigned: the request falls through to your worker, so you can gate it. Decide, then hand back a signed URL via the env.MEDIA loopback and let the supervisor serve the bytes — you never proxy them:

this.app.get("/_deck.pdf", async (c) => {
  if (!(await isAuthorized(c.req.raw))) return c.text("Members only", 401);
  const signed = await c.env.MEDIA.sign("_deck.pdf", { expiresIn: 600 });
  return c.redirect(signed, 302);
});

env.MEDIA.sign is scoped to this space's media only. Upload the private file the normal way (media.write("_deck.pdf", …) in run_code, or the upload page). Full model: media.

Ship it

# in run_code: write the four files, then
git.add({ filepath: ".", dir: "/" })
git.commit({ message: "feat: hono site", author: { name, email }, dir: "/" })
# then, as tools:
build({ slug })    # check warnings
deploy({ slug })   # main → production

See workflow for the full edit→preview→ship loop, bundling for entry/asset rules, and run-code for the file/git API. To add login on top of this, start the unfolder.auth prompt.