Server-Side Rendering with Bun: SSR, SSG, ISR
Bun has rapidly become one of the most exciting JavaScript runtimes for modern web development. With its blazing-fast startup time, native TypeScript support, and built-in APIs for handling HTTP requests, Bun is an excellent foundation for building rendering strategies that power modern web applications. In this tutorial, we'll explore three of the most important rendering techniques — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — and how to implement each of them using Bun.
Why Rendering Strategy Matters
The way your application delivers HTML to the browser directly affects performance, SEO, and user experience. A poorly chosen strategy can lead to slow Time to First Byte (TTFB), poor search engine indexing, or unnecessary server load. Choosing between SSR, SSG, and ISR — or combining them — lets you balance freshness, performance, and infrastructure cost.
- SSR: HTML is generated on every request. Best for highly dynamic, personalized content.
- SSG: HTML is generated once at build time. Best for content that rarely changes.
- ISR: Static HTML is generated and then regenerated in the background at a configurable interval. Best for content that updates periodically but doesn't need real-time freshness.
Setting Up a Bun Project
Start by initializing a new Bun project and installing the dependencies we'll use throughout this tutorial.
bun init my-bun-renderer
cd my-bun-renderer
bun install
Bun ships with a built-in HTTP server accessible through Bun.serve. We'll use this as the backbone for all three rendering strategies. Create a src/index.ts file and add the following minimal server:
// src/index.ts
const server = Bun.serve({
port: 3000,
fetch(req) {
return new Response("Hello from Bun!");
},
});
console.log(`Listening on http://localhost:${server.port}`);
Run the server with bun src/index.ts. You should see "Hello from Bun!" when visiting http://localhost:3000. Now let's build something more substantial.
Building a Shared Render Function
Before diving into each strategy, let's create a reusable HTML template function. This keeps our rendering logic consistent across SSR, SSG, and ISR.
// src/render.ts
export interface PageProps {
title: string;
body: string;
data?: unknown;
}
export function renderHTML(props: PageProps): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${props.title}</title>
</head>
<body>
<main>${props.body}</main>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(props.data ?? null)};
</script>
</body>
</html>`;
}
We'll also create a mock data source that simulates fetching posts from a database or external API.
// src/data.ts
export interface Post {
id: number;
title: string;
content: string;
updatedAt: string;
}
export async function getPosts(): Promise<Post[]> {
// Simulate network/database latency
await Bun.sleep(50);
return [
{
id: 1,
title: "Getting Started with Bun",
content: "Bun is a fast all-in-one JavaScript runtime.",
updatedAt: new Date().toISOString(),
},
{
id: 2,
title: "Understanding Rendering Strategies",
content: "SSR, SSG, and ISR each serve different use cases.",
updatedAt: new Date().toISOString(),
},
];
}
export async function getPost(id: number): Promise<Post | null> {
const posts = await getPosts();
return posts.find((p) => p.id === id) ?? null;
}
Implementing Server-Side Rendering (SSR)
SSR generates HTML on the server for every incoming request. This ensures users always receive the latest content, making it ideal for personalized dashboards, e-commerce carts, or any page where data changes frequently per user.
SSR Implementation
// src/ssr.ts
import { getPosts, getPost } from "./data";
import { renderHTML } from "./render";
export async function handleSSR(req: Request): Promise<Response> {
const url = new URL(req.url);
// List page
if (url.pathname === "/") {
const posts = await getPosts();
const body = `
<h1>Blog Posts</h1>
<ul>
${posts
.map(
(p) => `<li><a href="/post/${p.id}">${p.title}</a></li>`
)
.join("")}
</ul>
<p>Rendered at: ${new Date().toISOString()}</p>
`;
return new Response(renderHTML({ title: "Blog", body, data: posts }), {
headers: { "Content-Type": "text/html" },
});
}
// Detail page
const match = url.pathname.match(/^\/post\/(\d+)$/);
if (match) {
const post = await getPost(Number(match[1]));
if (!post) {
return new Response("Not Found", { status: 404 });
}
const body = `
<article>
<h1>${post.title}</h1>
<p>${post.content}</p>
<small>Updated at: ${post.updatedAt}</small>
</article>
<a href="/">Back</a>
`;
return new Response(renderHTML({ title: post.title, body, data: post }), {
headers: { "Content-Type": "text/html" },
});
}
return new Response("Not Found", { status: 404 });
}
Wire this handler into your Bun server:
// src/index.ts
import { handleSSR } from "./ssr";
const server = Bun.serve({
port: 3000,
fetch(req) {
return handleSSR(req);
},
});
console.log(`SSR server running on http://localhost:${server.port}`);
Every request triggers a fresh data fetch and HTML generation. This guarantees up-to-date content but adds latency and server CPU cost per request.
Implementing Static Site Generation (SSG)
SSG pre-renders all pages at build time into static HTML files. The server then simply serves these files, resulting in extremely fast responses and minimal resource usage. SSG is perfect for blogs, documentation sites, and marketing pages.
SSG Build Step
Create a build script that generates static HTML files into an out directory.
// src/build-ssg.ts
import { getPosts, getPost, Post } from "./data";
import { renderHTML } from "./render";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
async function build() {
const outDir = join(import.meta.dir, "..", "out");
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
const posts = await getPosts();
// Generate index page
const indexBody = `
<h1>Blog Posts</h1>
<ul>
${posts
.map(
(p) => `<li><a href="/post/${p.id}.html">${p.title}</a></li>`
)
.join("")}
</ul>
<p>Built at: ${new Date().toISOString()}</p>
`;
await writeFile(
join(outDir, "index.html"),
renderHTML({ title: "Blog", body: indexBody, data: posts })
);
// Generate each post page
for (const post of posts) {
const fullPost = await getPost(post.id);
if (!fullPost) continue;
const postDir = join(outDir, "post");
await mkdir(postDir, { recursive: true });
const body = `
<article>
<h1>${fullPost.title}</h1>
<p>${fullPost.content}</p>
<small>Updated at: ${fullPost.updatedAt}</small>
</article>
<a href="/">Back</a>
`;
await writeFile(
join(postDir, `${fullPost.id}.html`),
renderHTML({ title: fullPost.title, body, data: fullPost })
);
}
console.log(`SSG build complete. ${posts.length + 1} pages generated.`);
}
build();
Run the build with bun src/build-ssg.ts. Now serve the generated files using Bun's static file serving capabilities:
// src/serve-ssg.ts
import { join } from "node:path";
const outDir = join(import.meta.dir, "..", "out");
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
let path = url.pathname;
// Default to index.html
if (path === "/") path = "/index.html";
const filePath = join(outDir, path);
const file = Bun.file(filePath);
if (await file.exists()) {
return new Response(file);
}
return new Response("Not Found", { status: 404 });
},
});
console.log(`SSG server running on http://localhost:${server.port}`);
With SSG, the server does almost no work at request time — it just reads and returns a file. The trade-off is that content only updates when you rebuild the site.
Implementing Incremental Static Regeneration (ISR)
ISR combines the speed of SSG with the freshness of SSR. Pages are generated statically and cached, but the cache is invalidated and regenerated in the background after a specified time-to-live (TTL). Users always receive a fast cached response, while the content stays reasonably fresh.
ISR Cache Implementation
// src/isr.ts
import { getPosts, getPost } from "./data";
import { renderHTML } from "./render";
interface CacheEntry {
html: string;
generatedAt: number;
regenerating: boolean;
}
const cache = new Map<string, CacheEntry>();
const DEFAULT_TTL = 10_000; // 10 seconds for demo purposes
async function regenerateList(): Promise<string> {
const posts = await getPosts();
const body = `
<h1>Blog Posts</h1>
<ul>
${posts
.map(
(p) => `<li><a href="/post/${p.id}">${p.title}</a></li>`
)
.join("")}
</ul>
<p>Generated at: ${new Date().toISOString()}</p>
`;
return renderHTML({ title: "Blog", body, data: posts });
}
async function regeneratePost(id: number): Promise<string | null> {
const post = await getPost(id);
if (!post) return null;
const body = `
<article>
<h1>${post.title}</h1>
<p>${post.content}</p>
<small>Updated at: ${post.updatedAt}</small>
</article>
<a href="/">Back</a>
`;
return renderHTML({ title: post.title, body, data: post });
}
export async function handleISR(req: Request): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
const now = Date.now();
const entry = cache.get(path);
if (entry) {
const isStale = now - entry.generatedAt > DEFAULT_TTL;
// Serve stale content immediately, regenerate in background
if (isStale && !entry.regenerating) {
entry.regenerating = true;
// Fire and forget background regeneration
regenerate(path).finally(() => {
entry.regenerating = false;
});
}
return new Response(entry.html, {
headers: {
"Content-Type": "text/html",
"X-Cache": isStale ? "STALE" : "HIT",
"X-Generated-At": new Date(entry.generatedAt).toISOString(),
},
});
}
// Cache miss — generate synchronously (first request)
const html = await regenerate(path);
if (!html) {
return new Response("Not Found", { status: 404 });
}
cache.set(path, { html, generatedAt: now, regenerating: false });
return new Response(html, {
headers: {
"Content-Type": "text/html",
"X-Cache": "MISS",
"X-Generated-At": new Date(now).toISOString(),
},
});
}
async function regenerate(path: string): Promise<string | null> {
let html: string | null = null;
if (path === "/") {
html = await regenerateList();
} else {
const match = path.match(/^\/post\/(\d+)$/);
if (match) {
html = await regeneratePost(Number(match[1]));
}
}
if (html) {
cache.set(path, {
html,
generatedAt: Date.now(),
regenerating: false,
});
}
return html;
}
Update the server to use the ISR handler:
// src/index.ts
import { handleISR } from "./isr";
const server = Bun.serve({
port: 3000,
fetch(req) {
return handleISR(req);
},
});
console.log(`ISR server running on http://localhost:${server.port}`);
When you first visit a page, you'll see X-Cache: MISS and a slight delay while the page is generated. Subsequent visits return instantly with X-Cache: HIT. After the TTL expires, the next request still receives the cached version (marked STALE), but a background regeneration is triggered so the following request gets fresh content.
Combining All Three Strategies
In a real application, you rarely use just one strategy. A blog might use SSG for the homepage, ISR for individual posts, and SSR for a user dashboard. Here's how to route between them:
// src/index.ts
import { handleSSR } from "./ssr";
import { handleISR } from "./isr";
import { join } from "node:path";
const outDir = join(import.meta.dir, "..", "out");
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
// SSR for dynamic, personalized routes
if (url.pathname.startsWith("/dashboard")) {
return handleSSR(req);
}
// ISR for blog content that updates periodically
if (url.pathname === "/" || url.pathname.startsWith("/post/")) {
return handleISR(req);
}
// SSG for truly static assets
const filePath = join(outDir, url.pathname);
const file = Bun.file(filePath);
if (await file.exists()) {
return new Response(file);
}
return new Response("Not Found", { status: 404 });
},
});
console.log(`Hybrid server running on http://localhost:${server.port}`);
Best Practices
- Choose the right strategy per route: Don't default to SSR for everything. Use SSG wherever possible, ISR for semi-dynamic content, and SSR only when truly needed.
- Set appropriate TTLs: A TTL that's too short defeats the purpose of ISR. A TTL that's too long makes content stale. Match the TTL to how often your data actually changes.
- Handle regeneration failures gracefully: If background regeneration fails, keep serving the stale cache rather than returning an error to users.
- Use cache headers: Even with ISR, set HTTP cache headers like
Cache-Controlandstale-while-revalidateto leverage CDN and browser caching. - Persist the ISR cache: The in-memory cache shown here is lost on restart. For production, persist cached pages to disk or a key-value store like Redis.
- Warm the cache: After deploying, trigger requests to important pages so the first real user doesn't experience a cache miss delay.
- Measure performance: Use the
X-Cacheheaders and tools likebun --inspectto monitor cache hit rates and regeneration times. - Sanitize all dynamic content: When interpolating data into HTML strings, always escape user-generated content to prevent XSS attacks. Consider using a templating library or Bun's built-in HTML escaping utilities.
Conclusion
Bun's speed and simplicity make it an excellent runtime for implementing SSR, SSG, and ISR. By understanding the trade-offs between these three strategies, you can build applications that are fast, SEO-friendly, and always serving fresh content where it matters. Start with SSG for static pages, layer in ISR for content that updates periodically, and reserve SSR for truly dynamic, per-request rendering. With the patterns shown in this tutorial, you have a solid foundation for building a production-grade rendering layer on top of Bun that scales with your application's needs.