DEV Community

Cover image for Onefold + Hono + Cloudflare: Building a Fast SSR App on the Edge
Md. Zahirul Haque
Md. Zahirul Haque

Posted on

Onefold + Hono + Cloudflare: Building a Fast SSR App on the Edge

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
Enter fullscreen mode Exit fullscreen mode

Three things are worth calling out up front, because they shape the whole design:

  1. Onefold's SSR is a snapshot. renderHTML runs your component with a server version of the html tag that produces a string. Reactive expressions are evaluated once, and event handlers are stripped — they can't run in static HTML anyway.
  2. 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 inside renderHTML on 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

When prompted for a template, choose cloudflare-workers. Then move in and add Onefold:

cd onefold-ssr-app
npm install onefold
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  `;
}
Enter fullscreen mode Exit fullscreen mode

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',
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
  `;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  `;
}
Enter fullscreen mode Exit fullscreen mode

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>
  `;
}
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 #interactive container 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's Router and Link build DOM directly, so they can't run inside renderHTML on a Worker. Let Hono handle routing and use plain <a href> links between pages. Onefold's client Router is 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's mount/Router — will throw during SSR. Keep those in client.ts and 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.

Code
Demo

Links:

Happy building.

Top comments (0)