Server-Side Rendering with Hono: SSR, SSG, ISR
Hono is a fast, lightweight web framework that runs on Cloudflare Workers, Deno, Bun, and Node.js. While it is often associated with building APIs, Hono ships with a powerful set of helpers for rendering HTML on the server. This tutorial walks through three rendering strategies — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — and shows you how to implement each one in a real Hono project.
Why Rendering Strategy Matters
The way you generate HTML affects performance, SEO, infrastructure cost, and freshness of content. Choosing the right strategy for each route is one of the most impactful architectural decisions you can make.
- SSR renders HTML on every request. Best for highly dynamic, personalized, or authenticated pages.
- SSG renders HTML once at build time. Best for content that rarely changes, like marketing pages and docs.
- ISR renders HTML at build time and revalidates it on a schedule or on demand. Best for large content sites that need freshness without per-request rendering cost.
Setting Up a Hono Project
Start by creating a new project and installing Hono along with the JSX renderer middleware. We will use Node.js for this tutorial, but the same code works on Workers, Deno, and Bun with minimal changes.
mkdir hono-rendering && cd hono-rendering
npm init -y
npm install hono @hono/node-server
npm install -D typescript @types/node
Create a tsconfig.json that enables JSX so we can write components in TypeScript files.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"strict": true,
"outDir": "dist"
}
}
Create the entry point src/index.tsx:
import { serve } from "@hono/node-server";
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("Hello Hono"));
serve({ fetch: app.fetch, port: 3000 });
console.log("Server running on http://localhost:3000");
Server-Side Rendering (SSR) with Hono JSX
Hono provides a built-in JSX renderer middleware that lets you return JSX components directly from route handlers. The HTML is generated on every request, making it true SSR.
Enabling the JSX Renderer
Apply jsxRenderer to your app. The layout option wraps every rendered page in a shared shell.
import { Hono } from "hono";
import { jsxRenderer } from "hono/jsx-renderer";
const app = new Hono();
app.use(
"*",
jsxRenderer(({ children }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hono SSR Demo</title>
</head>
<body>{children}</body>
</html>
),
{ docType: true }
);
Defining Components
Components are plain functions that return JSX. Because Hono's JSX is typed, you get full TypeScript support.
function ProductCard({ name, price }: { name: string; price: number }) {
return (
<article class="card">
<h2>{name}</h2>
<p>${price.toFixed(2)}</p>
</article>
);
}
Rendering Data on Every Request
Fetch data inside the handler and pass it to the component. The HTML is regenerated for each visitor, so it always reflects the latest state.
async function fetchProducts() {
const res = await fetch("https://api.example.com/products");
return res.json<Array<{ id: number; name: string; price: number }>>();
}
app.get("/products", async (c) => {
const products = await fetchProducts();
return c.render(
<main>
<h1>Products</h1>
<div class="grid">
{products.map((p) => (
<ProductCard key={p.id} name={p.name} price={p.price} />
))}
</div>
</main>
);
});
Streaming SSR with Suspense
Hono supports streaming responses with use and Suspense, allowing you to send the shell immediately and stream slow data as it resolves.
import { Suspense } from "hono/jsx";
import { use } from "hono/jsx";
async function SlowStats() {
const data = await fetch("https://api.example.com/stats").then((r) => r.json());
return <p>Visitors today: {data.visitors}</p>;
}
app.get("/dashboard", (c) => {
return c.render(
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<SlowStats />
</Suspense>
</main>
);
});
Static Site Generation (SSG) with Hono
For pages that do not need fresh data on every request, generate static HTML at build time. Hono provides the toSSG helper, which crawls your routes and writes HTML files to disk.
Installing the SSG Helper
npm install hono ssg
The SSG utilities are exported from the hono/ssg module in recent Hono versions. Make sure you are on Hono 4.5 or newer.
Preparing Routes for SSG
SSG works best when routes do not depend on request-specific data such as cookies or query strings. Define your static routes normally.
import { Hono } from "hono";
import { jsxRenderer } from "hono/jsx-renderer";
export const app = new Hono();
app.use("*", jsxRenderer(({ children }) => (
<html>
<head><title>SSG Site</title></head>
<body>{children}</body>
</html>
), { docType: true }));
app.get("/", (c) => c.render(<h1>Home</h1>));
app.get("/about", (c) => c.render(<h1>About Us</h1>));
app.get("/contact", (c) => c.render(<h1>Contact</h1>));
Generating Static Files
Create a build script src/build.ts that imports your app and calls toSSG. The helper writes one HTML file per route into the output directory.
import { toSSG } from "hono/ssg";
import { app } from "./app";
import { mkdirSync } from "node:fs";
mkdirSync("public", { recursive: true });
await toSSG(app, {
dir: "./public",
before: async () => {
console.log("Generating static site...");
},
});
console.log("SSG build complete");
Run the build with a TypeScript loader such as tsx:
npx tsx src/build.ts
You will get public/index.html, public/about.html, and public/contact.html. Serve them from any static host — Cloudflare Pages, Netlify, GitHub Pages, or an S3 bucket.
Generating Dynamic Routes
For routes with parameters, supply a fetch map that tells toSSG which paths to crawl.
app.get("/posts/:id", (c) => {
const id = c.req.param("id");
return c.render(<article><h1>Post {id}</h1></article>);
});
await toSSG(app, {
dir: "./public",
fetch: {
"/posts/1": new Request("http://localhost/posts/1"),
"/posts/2": new Request("http://localhost/posts/2"),
"/posts/3": new Request("http://localhost/posts/3"),
},
});
Incremental Static Regeneration (ISR)
ISR combines the speed of SSG with the freshness of SSR. Pages are served from a cache and regenerated in the background after a configurable interval. Hono does not ship a built-in ISR primitive, but it is straightforward to implement with a cache layer.
ISR with an In-Memory Cache
This example caches rendered HTML for 60 seconds. The first request after expiry triggers a regeneration while still serving the stale page immediately.
import { Hono } from "hono";
import { jsxRenderer } from "hono/jsx-renderer";
const app = new Hono();
type CacheEntry = { html: string; generatedAt: number };
const cache = new Map<string, CacheEntry>();
const REVALIDATE_AFTER = 60_000; // 60 seconds
async function renderBlogPost(id: string): Promise<string> {
const post = await fetch(`https://api.example.com/posts/${id}`).then((r) => r.json());
return `<article><h1>${post.title}</h1><p>${post.body}</p></article>`;
}
app.get("/blog/:id", async (c) => {
const id = c.req.param("id");
const key = `/blog/${id}`;
const entry = cache.get(key);
const now = Date.now();
if (!entry) {
const html = await renderBlogPost(id);
cache.set(key, { html, generatedAt: now });
return c.html(html);
}
if (now - entry.generatedAt > REVALIDATE_AFTER) {
// Serve stale content immediately, regenerate in the background
renderBlogPost(id).then((html) => {
cache.set(key, { html, generatedAt: Date.now() });
});
}
return c.html(entry.html);
});
ISR with Cloudflare KV
On Cloudflare Workers, use KV or Cache API for durable storage. This pattern scales across edge regions.
import { Hono } from "hono";
type Env = {
BLOG_CACHE: KVNamespace;
};
const app = new Hono<{ Bindings: Env }>();
const TTL = 60; // seconds
app.get("/blog/:id", async (c) => {
const id = c.req.param("id");
const cached = await c.env.BLOG_CACHE.getWithMetadata(`blog:${id}`);
if (cached.value && cached.metadata) {
const age = Date.now() - (cached.metadata.generatedAt as number);
if (age < TTL * 1000) {
return c.html(cached.value);
}
// Stale: serve now, revalidate in background
c.executionCtx.waitUntil(revalidate(c, id));
return c.html(cached.value);
}
const html = await renderBlogPost(id);
await c.env.BLOG_CACHE.put(`blog:${id}`, html, {
metadata: { generatedAt: Date.now() },
});
return c.html(html);
});
async function revalidate(c: Context<{ Bindings: Env }>, id: string) {
const html = await renderBlogPost(id);
await c.env.BLOG_CACHE.put(`blog:${id}`, html, {
metadata: { generatedAt: Date.now() },
});
}
On-Demand Revalidation
Sometimes you want to regenerate a page immediately when content changes, rather than waiting for the TTL. Expose a webhook endpoint protected by a secret token.
app.post("/api/revalidate", async (c) => {
const token = c.req.header("x-revalidate-token");
if (token !== process.env.REVALIDATE_TOKEN) {
return c.json({ error: "Unauthorized" }, 401);
}
const { path } = await c.req.json<{ path: string }>();
if (!path) return c.json({ error: "Missing path" }, 400);
const id = path.split("/").pop()!;
const html = await renderBlogPost(id);
cache.set(`/blog/${id}`, { html, generatedAt: Date.now() });
return c.json({ revalidated: true, path });
});
Combining Strategies
Real applications rarely use a single strategy. A common pattern is to SSG the marketing site, SSR the authenticated dashboard, and ISR the blog. Hono's middleware architecture makes this easy because each route can opt into a different rendering approach.
// Static marketing pages — generated at build time
app.get("/", (c) => c.render(<HomePage />));
app.get("/pricing", (c) => c.render(<PricingPage />));
// Blog with ISR
app.get("/blog/:id", isrHandler);
// Dashboard — always SSR because it depends on the user
app.get("/dashboard", authMiddleware, async (c) => {
const user = c.get("user");
return c.render(<Dashboard user={user} />);
});
Best Practices
- Profile before optimizing. SSR is fast enough for most pages. Only reach for SSG or ISR when you have measured a real bottleneck.
- Keep components pure. Components should not read from the request directly. Pass data in as props so the same component works for SSR, SSG, and ISR.
- Cache at the edge. Combine Hono with Cloudflare Cache or a CDN. ISR in front of a CDN gives you near-instant responses with controlled freshness.
- Handle errors gracefully. If a background revalidation fails, keep serving the stale page and log the error. Never block a user request on regeneration.
- Version your cache keys. When you change your component markup, bump a version in the cache key so stale HTML from the previous deploy is not served.
- Use streaming for slow data. Suspense lets you ship the shell fast and stream expensive sections, improving perceived performance without giving up SSR.
- Separate build and runtime. Keep your SSG build script in a separate entry point so your production server bundle stays lean.
Conclusion
Hono gives you a single, consistent JSX-based rendering model that scales from per-request SSR to build-time SSG to cache-backed ISR. By understanding the trade-offs of each strategy and combining them within one application, you can deliver fast, SEO-friendly pages without overpaying for compute. Start with SSR for everything, promote stable content to SSG, and introduce ISR where freshness matters but per-request rendering is too expensive. With the patterns in this tutorial, you have everything you need to build a production-grade rendering pipeline on Hono.