Pattern: a design system in hono/jsx — one look, static and dynamic

When to reach for this. You're building more than one page — a blog, a portfolio, docs, a small app — and you want them to feel like one system: shared chrome, one type scale, one set of components, a coherent light/dark palette. And you want to keep it hand-authored — server-rendered HTML, enhanced in place, no SPA and no build step of your own. This is the layer that sits under both unfolder.static-site (plain files) and unfolder.routing (dynamic) — the same components render both ways.

The stance. Don't reach for Tailwind, CSS-in-JS, or a component framework. The whole design system is three small layers:

  1. Tokens — one public/theme.css of CSS custom properties (a type scale, a spacing scale, colors with light + dark). Served static off R2, zero cold-start.
  2. Components — small hono/jsx modules (WebsiteShell, Nav, CodeBlock, Prose, PostCard…). Server-rendered HTML. Import the same module everywhere — public pages and a living gallery — so nothing can drift.
  3. Progressive enhancementUnpoly for SPA-like navigation and in-place fragment swaps with no framework; client libraries are npm deps bundled from src/client.ts. The HTML works with JavaScript off; Unpoly upgrades it.

The files

public/theme.css        # tokens + component classes (static, served off R2)
src/client.ts           # client entry, BUNDLED for the browser → served at /client.js
src/components/*.tsx     # WebsiteShell, Nav, CodeBlock, Prose, PostCard, Footer…
src/index.ts / app.tsx  # the App DO (unfolder.routing) that renders the components

Binary (fonts, images) is media, never public/ — reference it by URL (media). theme.css is text, so it belongs in public/. src/client.ts is source — the bundler compiles it for the browser and serves the result at /client.js.

The client layer — npm deps, bundled from src/client.ts

Client JavaScript is real npm: declare unpoly and highlight.js in the same package.json as your server deps, import them in src/client.ts, and the build emits /client.js as a static asset (see bundling for the convention):

{ "dependencies": { "hono": "^4.12.18", "unpoly": "^3.11.0", "highlight.js": "^11.11.0" } }
// src/client.ts — bundled for the browser, served at /client.js
import unpolyCss from "unpoly/unpoly.min.css";   // .css imports bundle as STRINGS
import up from "unpoly";
import hljs from "highlight.js";

// Inject package CSS (a named import + inject — a bare `import "x.css"` does nothing):
const style = document.createElement("style");
style.textContent = unpolyCss;
document.head.prepend(style);
window.up = up;   // expose for inline up:* handlers + console use

// Re-highlight after every Unpoly fragment swap, not just first paint:
const highlight = (root) =>
  root.querySelectorAll("pre code[class*='language-']").forEach((el) => {
    if (!el.dataset.hl) { hljs.highlightElement(el); el.dataset.hl = "1"; }
  });
highlight(document);
up.on("up:fragment:inserted", (_e, f) => highlight(f));

Package CSS imports too.css resolves as a string from the same node_modules (the path is package-specific; a wrong one now fails the build instead of silently 404ing). The shell stays two lines:

<!-- in your WebsiteShell <head> -->
<link rel="stylesheet" href="/theme.css" />
<script type="module" src="/client.js"></script>

Two traps that cost real time — avoid them:

  • Unpoly's CSS lives at the package rootunpoly/unpoly.min.css, not unpoly/dist/unpoly.min.css. Without it, Unpoly's overlays/progress bar are unstyled (the wrong path is now a build error, so you'll know).
  • A syntax highlighter needs a theme stylesheethljs.highlightElement only adds .hljs-* classes; with nothing styling them the code stays colorless. Either import a theme (highlight.js/styles/github.css + a dark one) or, better, map the tokens to your own palette in theme.css so highlighting stays on-brand and follows light/dark automatically:
/* theme.css — restraint: keywords=accent, strings=muted-green, comments=muted */
.hljs-comment, .hljs-quote { color: var(--fg-muted); font-style: italic; }
.hljs-keyword, .hljs-built_in, .hljs-literal, .hljs-type { color: var(--accent); }
.hljs-string, .hljs-attr, .hljs-regexp { color: var(--hl-string); }
.hljs-number, .hljs-title { color: var(--fg); font-weight: 600; }

Tokens — one theme.css, light + dark

Custom properties are the whole system. Define them once; every component reads them. Support both the OS preference and a manual toggle:

:root {
  --font-serif: ui-serif, Georgia, serif;
  --font-sans:  ui-sans-serif, system-ui, sans-serif;
  --font-mono:  ui-monospace, Menlo, monospace;
  --text-sm: .875rem; --text-base: 1rem; --text-lg: 1.25rem; --text-xl: 1.563rem;
  --space-2: .5rem; --space-4: 1rem; --space-6: 2.5rem;   /* a real scale, not ad-hoc px */
  --measure: 68ch; --radius: .375rem;
  --bg:#fbfaf7; --surface:#fff; --fg:#1c1a17; --fg-muted:#6b6459; --border:#e4ded3; --accent:#9a3324;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
  --bg:#171512; --surface:#1f1c19; --fg:#ece7de; --fg-muted:#a49d90; --border:#35302a; --accent:#e2a26a;
}}
[data-theme="dark"]  { /* same dark values — lets a JS toggle override the OS */ }
[data-theme="light"] { /* same light values */ }

The toggle is ~6 lines in client.js: read localStorage, set document.documentElement.dataset.theme, persist on click. No dependency.

Components — plain functions

JSX needs no pragma — the bundler compiles .tsx with the automatic runtime imported from hono/jsx (just make sure hono is in your package.json):

import type { FC } from "hono/jsx";

export const WebsiteShell: FC<{ title: string; children?: any }> = ({ title, children }) => (
  <html lang="en">
    <head>
      <meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
      <title>{title}</title>
      <link rel="stylesheet" href="/theme.css" />
      <script type="module" src="/client.js"></script>
    </head>
    <body>
      <Nav />
      <main id="main" up-source>{children}</main>   {/* the swap target — see below */}
      <Footer />
    </body>
  </html>
);

Render to a string with the doctype prepended: "<!doctype html>" + String(<WebsiteShell …/>).

Embedding pre-rendered HTML (markdown, MDX, anything not written as JSX)

A hono/jsx expression escapes every string it interpolates — the right default (it's why JSX is XSS-safe by default), but wrong for content you've already turned into an HTML string yourself, most commonly a markdown body run through a parser (e.g. marked.parse(body)). Interpolating that string with {html} renders the literal <p>…</p> tags as visible text, not markup.

Wrap it in a Prose component using dangerouslySetInnerHTML — the same escape-hatch prop name as React, and only this one prop bypasses escaping:

export const Prose: FC<{ html: string }> = ({ html }) => (
  <div class="prose" dangerouslySetInnerHTML={{ __html: html }} />
);

// usage: <Prose html={marked.parse(post.body) as string} />

Trust boundary — the name is not decoration. Only pass HTML your own server code produced from content you control (your markdown, your own template output). Never pass a visitor-submitted string through Prose unsanitized — dangerouslySetInnerHTML inserts it verbatim, with no escaping, so unsanitized audience input becomes a stored-XSS hole. (A blog rendered from your own posts table is fine: you authored the markdown yourself, or authenticated only, gated authors write it.)

Apply the shell once — jsxRenderer (optional, idiomatic)

Wrapping every route in <WebsiteShell>…</WebsiteShell> by hand is explicit and always works. For an app with many routes, Hono's jsxRenderer middleware sets the layout once and each route calls c.render(content, props):

import { jsxRenderer } from "hono/jsx-renderer";

// Type the props your layout accepts:
declare module "hono" {
  interface ContextRenderer {
    (content: string | Promise<string>, props: { title: string }): Response;
  }
}

app.get("*", jsxRenderer(({ children, title }) => (
  <html lang="en">
    <head>
      <title>{title}</title>
      <link rel="stylesheet" href="/theme.css" />
      <script type="module" src="/client.js"></script>
    </head>
    <body><main id="main" up-source>{children}</main></body>
  </html>
)));

app.get("/", (c) => c.render(<Home posts={posts()} />, { title: "Home" }));

Nest a section's own chrome with the Layout param — jsxRenderer(({ children, Layout }) => <Layout><nav/>{children}</Layout>) + app.route("/__manage", manage) — an admin shell inside the site shell. (/__* paths are owner-gated by the platform's edge gate — the public gets a plain 404 — so an owner-only admin shell needs no hand-rolled auth; register its pages with place.add so the owner can reach them from the dashboard.)

On this platform: useRequestContext() (hono/jsx-renderer) works fine — verified end-to-end (a nested component consuming the hook, rendered through jsxRenderer). Hono's own docs restrict it only under Deno's precompile mode, which this platform never uses. Passing request-derived data as explicit props is still often the clearer choice for a small, easily-testable component — a style preference, not a platform limitation. If in doubt, the explicit String(<Shell/>) form above always works.

Unpoly discipline — the one thing that silently breaks

The full progressive-enhancement pattern — up-follow (links) vs up-submit (forms), fragment-vs-redirect handlers, re-running enhancements after a swap — is its own prompt: unfolder.unpoly. The essentials for a design system are here; read that one when interactivity misbehaves.

Unpoly swaps a fragment, identified by a CSS selector, without a full reload:

<a href="/archive" up-follow up-target="#main">Archive</a>
<nav up-nav>…</nav>   {/* up-nav auto-adds aria-current to the active link */}

The swap target must exist, with the same id, on every destination page. If /archive doesn't render a #main, the swap silently no-ops (the click does nothing). Pick one target id for a surface (#main for the public site, #manage-main for an admin shell) and put it in that surface's shell so it's always present. With JS off, up-follow links just navigate normally — that's the progressive part; don't gate content on the swap.

A form with up-target must return the fragment — never c.redirect(...). A redirect is a bodyless 303: Unpoly gets no #main to swap and throws up.render() needs … content (the loud cousin of the silent no-op). So a POST handler behind an up-target form re-renders and returns the fragment (200). Need the address bar to move too (post created → its edit URL)? Still return the fragment, and set c.header("X-Up-Location", url) (+ X-Up-Method: GET) so history updates without a round-trip. Full reference: unfolder.unpoly.

Static and dynamic — the same components, two callers

The components don't know or care how they're rendered:

  • Dynamic — a route calls them per request: app.get("/", (c) => c.html("<!doctype html>" + String(<WebsiteShell …/>))).
  • Genuinely static — hand-authored HTML in public/ (unfolder.static-site) serves off R2 with zero cold-start; a good fit for a portfolio, a landing page, docs that don't come from live data.

For a page that's dynamic in origin but rarely changes (a blog index, a post, docs rendered from your own data), set a long author Cache-Control on the route instead of maintaining two rendering paths — repeat hits skip the worker entirely, and the page stays live-correct on every publish (publishLive purges the space's cache tags). One component set covers both stories.

A living component gallery (a guideline that can't drift)

Add a /__manage/design route (dynamic; the platform's edge gate already owner-gates every /__* path, so an owner-only gallery needs no auth pattern) that imports the real component modules and renders each in its states, plus the type scale, color swatches (a light panel + a dark panel), and the spacing scale. Because it imports the same modules the site does, editing a component updates the gallery for free — the guideline is the code. Register it with place.add("/__manage/design", "Design") — a gated page has no public URL, so the place frame is how the owner opens it.

Gotchas, in one place

  • Unpoly's CSS import is unpoly/unpoly.min.css, not unpoly/dist/unpoly.min.css.
  • A highlighter needs a theme stylesheet (or palette-mapped .hljs-* rules), or code is colorless.
  • Unpoly up-target must exist on every destination page, or the swap no-ops; a form behind up-target must return the fragment, not c.redirect (bodyless 303).
  • Any .tsx requires hono in package.json (the JSX runtime resolves from it at build).
  • Client JS is bundled from src/client.ts (npm deps); package CSS is imported as a string there and injected — a named import + inject, never a bare import "x.css".
  • Binary → media; text (CSS/JS/HTML) → public/.

Next: unfolder.routing (the App DO the components render inside), unfolder.static-site (a files-only site), and media (reference uploads by URL).