← Back to DevBytes

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

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.

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles