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:
- Tokens — one
public/theme.cssof CSS custom properties (a type scale, a spacing scale, colors with light + dark). Served static off R2, zero cold-start. - Components — small
hono/jsxmodules (WebsiteShell,Nav,CodeBlock,Prose,PostCard…). Server-rendered HTML. Import the same module everywhere — public pages and a living gallery — so nothing can drift. - Progressive enhancement — Unpoly 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 root —
unpoly/unpoly.min.css, notunpoly/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 stylesheet —
hljs.highlightElementonly 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 intheme.cssso 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) vsup-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, notunpoly/dist/unpoly.min.css. - A highlighter needs a theme stylesheet (or palette-mapped
.hljs-*rules), or code is colorless. - Unpoly
up-targetmust exist on every destination page, or the swap no-ops; a form behindup-targetmust return the fragment, notc.redirect(bodyless 303). - Any
.tsxrequireshonoinpackage.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 bareimport "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).