← Back to DevBytes

Server-Side Rendering with Hono: SSR, SSG, ISR

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.

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles