Server-side rendering usually comes with baggage: a big framework, a heavy runtime, and a hosting bill to match. It doesn't have to. Pair three small, TypeScript-first tools and you get real SSR that ships tiny bundles and runs at the edge for pennies:
- Onefold for the UI
- Hono for the server
- Cloudflare Workers for hosting
This is a great fit for content-heavy apps where the first paint has to be fast and the budget has to stay small. This walkthrough builds a real (if small) server-rendered app end to end and ships it to Cloudflare. By the last section you'll have HTML rendering on a Worker and coming alive in the browser.
One promise up front: every snippet here uses the actual Onefold API and actually compiles. No invented method names, no hand-wavy pseudocode.
Why this stack
Each piece earns its place:
| Piece | What it brings |
|---|---|
| Onefold | Fine-grained reactivity, real DOM rendering, core under 6kb gzipped, SSR built in |
| Hono | A tiny, fast web framework with a clean API and first-class Workers support |
| Cloudflare Workers | Global edge deployment, generous free tier, one-command deploys with Wrangler |
The combination is a good fit for job boards, blogs, documentation sites, dashboards — anything that benefits from fast initial loads and doesn't need the weight of a larger framework. Onefold renders to a plain HTML string on the server, which is exactly what a Worker wants to return, and its client core is small enough that hydration is cheap.
How the pieces fit together
Before any code, here's the request flow:
Browser request
↓
Cloudflare Worker (Hono routes it)
↓
renderHTML(() => Home()) ← Onefold renders the page to an HTML string
↓
Hono returns a full HTML document (fast first paint, no JS needed)
↓
client.js loads → mounts interactive bits into placeholders → interactive
Three things are worth calling out up front, because they shape the whole design:
-
Onefold's SSR is a snapshot.
renderHTMLruns your component with a server version of thehtmltag that produces a string. Reactive expressions are evaluated once, and event handlers are stripped — they can't run in static HTML anyway. -
Routing lives on the server, in Hono — not in Onefold. Onefold ships a client-side
Router, but it builds DOM nodes directly (document.createElement), so it can't run insiderenderHTMLon a Worker where there's no DOM. That's fine: Hono is a web framework, routing is its job. Each Hono route renders the matching page. Navigation between pages uses plain<a href>links. -
Hydration is selective. Instead of re-rendering the whole page on the client, we mark the interactive parts with a container (
#interactive) and mount just those in the browser. Fully static pages ship no JavaScript logic at all. This is the same pattern Onefold's own SSR example uses.
Project setup
Start with a Hono project targeting Cloudflare Workers:
npm create hono@latest onefold-ssr-app
When prompted for a template, choose cloudflare-workers. Then move in and add Onefold:
cd onefold-ssr-app
npm install onefold
Onefold has zero runtime dependencies, so this adds one small package and nothing else.
Project structure
Here's the layout we'll build:
onefold-ssr-app/
├── src/
│ ├── index.ts # Hono server + routes (runs on the Worker)
│ ├── shell.ts # HTML document wrapper
│ ├── client.ts # Client entry — selective hydration
│ ├── pages/
│ │ ├── Home.ts # SSR page: dynamic data + interactive placeholder
│ │ ├── About.ts # Fully static SSR page
│ │ └── NotFound.ts # 404 page
│ ├── server/
│ │ └── data.ts # Server-only data (DB/KV/API stand-in)
│ └── shared/
│ ├── Nav.ts # Server-rendered nav (plain <a> links)
│ └── Counter.ts # Interactive component (client-only)
├── public/ # Static assets (client.js lands here)
├── wrangler.toml
└── package.json
The pages/ are Onefold components built from the html tag, which is SSR-safe. Counter.ts is the one interactive piece that runs in the browser.
1. The pages
src/shared/Nav.ts
Navigation is a plain server-rendered list of links. We use real <a href> elements rather than Onefold's client-only Link component, so the nav renders fine through renderHTML.
import { html } from 'onefold';
const LINKS: [string, string][] = [
['/', 'Home'],
['/about', 'About'],
];
export function Nav(activePath: string): unknown {
return html`
<nav>
${LINKS.map(
([href, label]) => html`
<a href=${href} class=${activePath === href ? 'active' : ''}>${label}</a>
`
)}
</nav>
`;
}
src/server/data.ts
Here's the part that makes SSR feel like SSR: data that only exists on the server, baked into the HTML before it ships. In a real app this is your database, KV, D1, or an upstream API. For the demo it's a static list plus a little request-time metadata — enough to prove the page is rendered fresh on every request, not cached at build time.
export interface Post {
id: number;
title: string;
author: string;
minutesAgo: number;
}
const POSTS: Post[] = [
{ id: 1, title: 'Shipping SSR to the edge with Onefold', author: 'Ada', minutesAgo: 8 },
{ id: 2, title: 'Why fine-grained reactivity beats diffing', author: 'Lin', minutesAgo: 42 },
{ id: 3, title: 'Zero-egress uploads with R2', author: 'Sam', minutesAgo: 121 },
{ id: 4, title: 'A tiny router that stays out of your way', author: 'Noor', minutesAgo: 300 },
];
/** Pretend this is an async DB/KV read — it's async on purpose. */
export async function getLatestPosts(limit = 3): Promise<Post[]> {
return POSTS.slice(0, limit);
}
export interface RenderInfo {
renderedAt: string; // ISO timestamp, computed per request
colo: string; // Cloudflare edge location (data center code)
country: string;
}
export function getRenderInfo(req: Request): RenderInfo {
// Cloudflare attaches request metadata on `request.cf` (plus some headers).
const cf = (req as Request & { cf?: IncomingRequestCfProperties }).cf;
return {
renderedAt: new Date().toISOString(),
colo: cf?.colo ?? req.headers.get('cf-ray')?.split('-')[1] ?? 'local',
country: (cf?.country as string) ?? req.headers.get('cf-ipcountry') ?? 'local',
};
}
The getRenderInfo bit is a nice touch for a demo — it pulls the actual Cloudflare data center that served the request off request.cf, so once deployed you can literally see which edge location rendered your page.
src/pages/Home.ts
Now the Home page takes that data as props and renders it into the markup: a posts list and a "rendered in {colo} at {timestamp}" line. All of it lands in the HTML on the server. The only client-side piece is still the counter, which mounts into the #interactive placeholder.
import { html } from 'onefold';
import { Nav } from '../shared/Nav';
import type { Post, RenderInfo } from '../server/data';
interface HomeProps {
posts: Post[];
info: RenderInfo;
}
function ago(minutes: number): string {
if (minutes < 60) return `${minutes}m ago`;
const h = Math.floor(minutes / 60);
return h < 24 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`;
}
export function Home({ posts, info }: HomeProps): unknown {
return html`
<div class="app">
${Nav('/')}
<h1>Onefold on the edge</h1>
<p>
This page was rendered to HTML on a Cloudflare Worker in
<strong>${info.colo}</strong> (${info.country}) at
<time datetime=${info.renderedAt}>${info.renderedAt}</time>.
Refresh and the timestamp changes — proof it's rendered per request.
</p>
<h2>Latest posts</h2>
<ul class="posts">
${posts.map(
(post) => html`
<li>
<span class="post-title">${post.title}</span>
<span class="post-meta">by ${post.author} · ${ago(post.minutesAgo)}</span>
</li>
`
)}
</ul>
<h2>Interactive bit</h2>
<div id="interactive">
<p class="loading">Loading counter…</p>
</div>
</div>
`;
}
Notice there's no () => wrapper around info.colo or the post fields. That's deliberate — this data is fixed for the life of the request, so a one-time interpolation is exactly right. The reactive () => wrapper is for values that change after render, which on the server never happens. Save it for the client.
src/shared/Counter.ts
The interactive bit. This runs in the browser during hydration, so it uses signals and event handlers normally.
import { html, createSignal } from 'onefold';
export function Counter(): Node {
const count = createSignal(0);
return html`
<button onclick=${() => count.set(n => n + 1)}>
Clicked ${() => count()} times
</button>
` as Node;
}
Two Onefold rules are in play here, and they matter:
- The click handler is a function, not a string, and the attribute starts with
on. - The count read is wrapped in
() =>. That closure is what makes it reactive — without it the value renders once and never updates. This is the single most common Onefold mistake, so it's worth burning into memory.
src/pages/About.ts
A fully static page. No #interactive container, so it ships zero JavaScript logic.
import { html } from 'onefold';
import { Nav } from '../shared/Nav';
export function About(): unknown {
return html`
<div class="app">
${Nav('/about')}
<h1>About</h1>
<p>
A small but complete SSR example using
<strong>Onefold</strong>, <strong>Hono</strong>, and
Cloudflare Workers.
</p>
</div>
`;
}
src/pages/NotFound.ts
import { html } from 'onefold';
import { Nav } from '../shared/Nav';
export function NotFound(): unknown {
return html`
<div class="app">
${Nav('')}
<h1>404 — Not found</h1>
<p>That page doesn't exist. Head back <a href="/">home</a>.</p>
</div>
`;
}
2. The HTML shell
A small helper wraps a page's rendered body in a full HTML document, including the global styles and the <script> that loads the client bundle.
src/shell.ts
export function shell(title: string, body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title} — Onefold + Hono SSR</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 720px; margin: 48px auto; padding: 0 20px; line-height: 1.6;
}
nav { margin-bottom: 2rem; padding-bottom: 1rem;
border-bottom: 1px solid #e5e5e5; display: flex; gap: 1rem; }
nav a { color: #4338ca; text-decoration: none; }
nav a.active { font-weight: 600; }
.loading { color: #94a3b8; }
time { color: #4338ca; font-variant-numeric: tabular-nums; }
.posts { list-style: none; padding: 0; }
.posts li { display: flex; flex-direction: column; gap: 2px;
padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
.post-title { font-weight: 600; }
.post-meta { color: #94a3b8; font-size: 0.85rem; }
button { padding: 0.6rem 1.2rem; font-size: 1rem; cursor: pointer;
border-radius: 6px; border: 1px solid #ccc; background: #f8f8f8; }
button:hover { background: #eee; }
</style>
</head>
<body>
<div id="app">${body}</div>
<script type="module" src="/client.js"></script>
</body>
</html>`;
}
3. The Hono server
Here's where routing happens, and where the dynamic data comes together. The / route gathers its data on the server — getLatestPosts() and getRenderInfo() — then hands it to the page inside renderHTML. renderHTML is available from onefold and from the dedicated onefold/ssr subpath; the explicit subpath keeps the intent obvious. It accepts a component function and can be sync or async, so await is the safe default — exactly what you want when a page fetches data first.
src/index.ts
import { Hono } from 'hono';
import { renderHTML } from 'onefold/ssr';
import { shell } from './shell';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { NotFound } from './pages/NotFound';
import { getLatestPosts, getRenderInfo } from './server/data';
const app = new Hono();
app.get('/', async (c) => {
// Gather request-time data on the server, then render it into the HTML.
const posts = await getLatestPosts(3);
const info = getRenderInfo(c.req.raw);
const body = await renderHTML(() => Home({ posts, info }));
return c.html(shell('Home', body));
});
app.get('/about', async (c) => {
const body = await renderHTML(() => About());
return c.html(shell('About', body));
});
app.notFound(async (c) => {
const body = await renderHTML(() => NotFound());
return c.html(shell('404', body), 404);
});
export default app;
If a page needs data, fetch it inside the render function and pass it to the page component:
app.get('/posts', async (c) => {
const body = await renderHTML(async () => {
const posts = await c.env.DB.getPosts();
return PostsPage(posts);
});
return c.html(shell('Posts', body));
});
4. The client entry — selective hydration
The client's only job is to bring the interactive parts to life. We look for the #interactive container; if it's there (Home has one, About doesn't), we mount the Counter into it. Static pages load this script, find no container, and do nothing.
src/client.ts
import { mount } from 'onefold';
import { Counter } from './shared/Counter';
const root = document.getElementById('interactive');
if (root) {
mount(Counter(), root);
}
mount replaces the container's contents (the "Loading…" placeholder) with the live counter. The server gave the browser a fully-formed page for instant paint; this swaps in the interactive piece once JS arrives.
5. Build configuration
The Worker code (src/index.ts) is bundled by Wrangler. The client code (src/client.ts) is a separate browser bundle we build ourselves and serve as a static asset. esbuild handles that in one line.
Add esbuild:
npm install -D esbuild
Then update the scripts in package.json:
{"scripts":{"build:client":"esbuild src/client.ts --bundle --format=esm --minify --outfile=public/client.js","build":"npm run build:client","dev":"npm run build && wrangler dev","deploy":"npm run build && wrangler deploy"}}
The client bundle lands in public/client.js, which Cloudflare serves as a static asset at /client.js — matching the <script src="/client.js"> in the document.
6. Cloudflare configuration
Point Wrangler at the Worker entry and the static assets directory.
wrangler.toml
name = "onefold-ssr-app"
main = "src/index.ts"
compatibility_date = "2025-07-18"
[assets]
directory = "./public"
binding = "ASSETS"
Use a compatibility_date your installed Wrangler runtime supports — if you set one in the future, Wrangler falls back to its latest supported date with a warning. The [assets] block tells Cloudflare to serve files from public/ directly from its edge cache. Static asset requests (like /client.js) are handled before your Worker runs, so serving the client bundle costs you nothing in Worker execution time.
7. Run it locally
npm run dev
Open http://localhost:8787 (Wrangler prints the exact port). View source: the posts list and the "rendered at" line are right there in the HTML, no JavaScript required to see them. Hit refresh a few times and watch the timestamp change on each load — that's the page being rendered fresh per request, not served from a cache. Once client.js loads, the counter starts working. Visit /about and notice it's fully static: it renders on the server and ships no interactive JavaScript at all.
Once you deploy, that rendered in ... line will show the real Cloudflare data center that served you — a fun, tangible reminder that your HTML is being built at the edge, close to the visitor.
8. Deploy
npm run deploy
Wrangler builds the client bundle, uploads your Worker and static assets, and puts the app on Cloudflare's global network. First deploy will prompt you to log in if you haven't already.
Optional: storing files with R2
If your app grows to handle uploads — resumes on a job board, images on a blog — Cloudflare R2 is a natural fit because it has no egress fees. Add a bucket binding:
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-uploads"
Then reach it from any Hono route through c.env.MY_BUCKET, using the standard R2 API (put, get, delete). No extra client library needed.
A few honest caveats
-
This is SSR plus selective hydration, not resumability. The server sends real HTML for a fast first paint; the client then mounts the interactive pieces into their placeholders. The
#interactivecontainer briefly shows its fallback before JS swaps in the live component. For content-driven pages that's exactly what you want. If you need above-the-fold widgets interactive with zero visible swap, render their initial state into the placeholder so the fallback already looks right. -
Route on the server, not with Onefold's
Router. Onefold'sRouterandLinkbuild DOM directly, so they can't run insiderenderHTMLon a Worker. Let Hono handle routing and use plain<a href>links between pages. Onefold's clientRouteris great for a pure SPA — just not in the SSR path. -
Keep server components DOM-free. Anything that only exists in the browser —
window,document,localStorage, or Onefold'smount/Router— will throw during SSR. Keep those inclient.tsand the components it imports. - Onefold is young. It's capable and the API is stable enough to build on, but it's a newer project than the big frameworks. Weigh that against the small bundles and zero-config toolkit.
Wrapping up
That's the whole stack: a server-rendered app that's small on the wire, quick to paint, and cheap to run anywhere in the world.
- Onefold renders pages to HTML on the Worker, then hydrates just the interactive pieces in the browser.
- Hono routes requests and returns the document with almost no overhead.
- Cloudflare serves it from the edge and hosts your client bundle for free.
It's a solid foundation to grow into a real product — a blog, a job board, a dashboard, a small SaaS. Add routes, pull in data, and the shape stays the same.
Links:
Happy building.