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.