← Back to DevBytes

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

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

Modern web applications demand fast initial loads, SEO-friendly markup, and dynamic content. While client-side rendering (CSR) dominated the SPA era, rendering strategies like Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) have returned to the spotlight. ESBuild, with its extraordinary bundling speed, offers a compelling foundation for building a custom rendering pipeline without the overhead of a full meta-framework.

In this tutorial, you'll learn how to build a lightweight rendering engine using ESBuild that supports SSR, SSG, and ISR. We'll cover the architecture, write practical code, and discuss best practices for production use.

Why ESBuild for Rendering Pipelines?

ESBuild is a Go-based JavaScript bundler that is 10-100x faster than alternatives like webpack or Rollup. For rendering pipelines, this speed matters because:

Unlike Next.js or Remix, which bundle their own rendering engines, building on ESBuild gives you full control over how and when pages are rendered.

Project Setup

Let's start by creating a new project and installing dependencies. We'll use a minimal stack: ESBuild for bundling, a tiny virtual DOM library (we'll use preact-render-to-string for simplicity), and Express for the server.

mkdir esbuild-ssr && cd esbuild-ssr
npm init -y
npm install esbuild express preact preact-render-to-string
npm install -D typescript @types/express @types/node

Create the following directory structure:

esbuild-ssr/
├── src/
│   ├── components/
│   │   └── App.tsx
│   ├── pages/
│   │   ├── Home.tsx
│   │   └── Post.tsx
│   ├── server/
│   │   ├── render.ts
│   │   ├── ssr.ts
│   │   ├── ssg.ts
│   │   └── isr.ts
│   ├── client.tsx
│   └── router.ts
├── dist/
├── public/
├── esbuild.config.ts
└── tsconfig.json

Configuring ESBuild

ESBuild needs two separate builds: one for the client (browser bundle) and one for the server (Node bundle). The server bundle must externalize Node dependencies, while the client bundle should inline everything.

// esbuild.config.ts
import { build, BuildOptions } from "esbuild";

const sharedConfig: BuildOptions = {
  bundle: true,
  jsx: "automatic",
  jsxImportSource: "preact",
  loader: { ".tsx": "tsx", ".ts": "ts" },
  sourcemap: true,
  logLevel: "info",
};

async function buildClient() {
  await build({
    ...sharedConfig,
    entryPoints: ["src/client.tsx"],
    outfile: "dist/client.js",
    minify: true,
    format: "esm",
    target: ["es2020"],
  });
}

async function buildServer() {
  await build({
    ...sharedConfig,
    entryPoints: ["src/server/ssr.ts"],
    outfile: "dist/server.js",
    platform: "node",
    format: "cjs",
    target: ["node18"],
    external: ["express", "preact", "preact-render-to-string"],
  });
}

Promise.all([buildClient(), buildServer()])
  .then(() => console.log("Build complete"))
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });

Run the build with npx tsx esbuild.config.ts. You can also wrap this in a watch loop using context() for development.

Building the Router

A simple file-based router maps URL paths to page components. Each page exports a render function that returns JSX and an optional getInitialProps function for data fetching.

// src/router.ts
import Home from "./pages/Home";
import Post from "./pages/Post";

export interface PageModule {
  default: () => any;
  getInitialProps?: (params: Record<string, string>) => Promise<any>;
}

export interface Route {
  pattern: RegExp;
  keys: string[];
  component: PageModule;
}

function createRoute(path: string, component: PageModule): Route {
  const keys: string[] = [];
  const pattern = new RegExp(
    "^" +
      path.replace(/:([^/]+)/g, (_, key) => {
        keys.push(key);
        return "([^/]+)";
      }) +
      "$"
  );
  return { pattern, keys, component };
}

export const routes: Route[] = [
  createRoute("/", Home),
  createRoute("/posts/:id", Post),
];

export function matchRoute(url: string): { route: Route; params: Record<string, string> } | null {
  for (const route of routes) {
    const match = url.match(route.pattern);
    if (match) {
      const params: Record<string, string> = {};
      route.keys.forEach((key, i) => {
        params[key] = match[i + 1];
      });
      return { route, params };
    }
  }
  return null;
}

Creating Page Components

Each page is a Preact component with optional data fetching. Here are two example pages:

// src/pages/Home.tsx
import { h } from "preact";

export async function getInitialProps() {
  // Simulate fetching data
  return {
    posts: [
      { id: "1", title: "Getting Started with ESBuild" },
      { id: "2", title: "SSR vs SSG vs ISR" },
    ],
  };
}

export default function Home({ posts }: any) {
  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((p: any) => (
          <li key={p.id}>
            <a href={`/posts/${p.id}`}>{p.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
// src/pages/Post.tsx
import { h } from "preact";

export async function getInitialProps({ id }: Record<string, string>) {
  // Simulate fetching a single post
  const posts: Record<string, any> = {
    "1": { title: "Getting Started with ESBuild", body: "ESBuild is fast..." },
    "2": { title: "SSR vs SSG vs ISR", body: "Three rendering strategies..." },
  };
  return { post: posts[id] || null };
}

export default function Post({ post }: any) {
  if (!post) return <div>Post not found</div>;
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <a href="/">Back to home</a>
    </article>
  );
}

The Render Function

The render function is the heart of the pipeline. It takes a matched route, fetches data, renders the component to an HTML string, and wraps it in a document shell.

// src/server/render.ts
import render from "preact-render-to-string";
import { h } from "preact";
import { matchRoute } from "../router";

export interface RenderResult {
  html: string;
  props: any;
  status: number;
}

export async function renderPage(url: string): Promise<RenderResult> {
  const matched = matchRoute(url);
  if (!matched) {
    return {
      html: "<h1>404 Not Found</h1>",
      props: {},
      status: 404,
    };
  }

  const { route, params } = matched;
  const props = route.component.getInitialProps
    ? await route.component.getInitialProps(params)
    : {};

  const content = render(h(route.component.default, props));

  const html = `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>ESBuild SSR</title>
</head>
<body>
  <div id="app">${content}</div>
  <script>window.__INITIAL_PROPS__ = ${JSON.stringify(props).replace(/</g, "\\u003c")}</script>
  <script type="module" src="/client.js"></script>
</body>
</html>`;

  return { html, props, status: 200 };
}

Notice the window.__INITIAL_PROPS__ injection. This allows the client-side hydration to reuse the same data the server used, avoiding a duplicate fetch.

Client-Side Hydration

The client entry point imports the same router, reads the initial props from the global, and hydrates the DOM.

// src/client.tsx
import { h, hydrate } from "preact";
import { matchRoute } from "./router";

const url = window.location.pathname;
const matched = matchRoute(url);

if (matched) {
  const { route } = matched;
  const props = (window as any).__INITIAL_PROPS__ || {};
  hydrate(h(route.component.default, props), document.getElementById("app")!);
}

Implementing SSR (Server-Side Rendering)

SSR renders the page on every request. This is ideal for highly dynamic, personalized content where caching is not appropriate. The server fetches fresh data and returns HTML on each hit.

// src/server/ssr.ts
import express from "express";
import { renderPage } from "./render";
import path from "path";

const app = express();

// Serve static client assets
app.use(express.static(path.join(__dirname, "..", "..", "dist")));

app.get("*", async (req, res) => {
  try {
    const { html, status } = await renderPage(req.path);
    res.status(status).send(html);
  } catch (err) {
    console.error("SSR error:", err);
    res.status(500).send("<h1>Internal Server Error</h1>");
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`SSR server running on http://localhost:${PORT}`);
});

With SSR, every request triggers a full render. For a blog, this is wasteful since content rarely changes. That's where SSG comes in.

Implementing SSG (Static Site Generation)

SSG pre-renders all pages at build time. The output is plain HTML files that can be served from a CDN. This gives you the best possible performance for content that doesn't change often.

// src/server/ssg.ts
import { renderPage } from "./render";
import fs from "fs/promises";
import path from "path";

interface SSGRoute {
  path: string;
  output: string;
}

// For dynamic routes, you must enumerate all possible paths
async function getStaticPaths(): Promise<SSGRoute[]> {
  const staticRoutes: SSGRoute[] = [
    { path: "/", output: "index.html" },
  ];

  // Fetch all post IDs to generate dynamic pages
  const postIds = ["1", "2"];
  for (const id of postIds) {
    staticRoutes.push({
      path: `/posts/${id}`,
      output: path.join("posts", id, "index.html"),
    });
  }

  return staticRoutes;
}

async function generateStaticSite() {
  const outDir = path.join(process.cwd(), "dist", "static");
  await fs.mkdir(outDir, { recursive: true });

  const routes = await getStaticPaths();

  for (const route of routes) {
    const { html, status } = await renderPage(route.path);
    if (status !== 200) {
      console.warn(`Skipping ${route.path} (status ${status})`);
      continue;
    }

    const filePath = path.join(outDir, route.output);
    await fs.mkdir(path.dirname(filePath), { recursive: true });
    await fs.writeFile(filePath, html);
    console.log(`Generated: ${route.output}`);
  }

  console.log(`SSG complete: ${routes.length} pages generated`);
}

generateStaticSite().catch(console.error);

Build the SSG bundle separately and run it as a post-build step:

// Add to esbuild.config.ts
async function buildSSG() {
  await build({
    ...sharedConfig,
    entryPoints: ["src/server/ssg.ts"],
    outfile: "dist/ssg.js",
    platform: "node",
    format: "cjs",
    target: ["node18"],
    external: ["express", "preact", "preact-render-to-string"],
  });
}

Then run node dist/ssg.js after the main build. The generated HTML files in dist/static can be deployed to any static host.

Implementing ISR (Incremental Static Regeneration)

ISR is a hybrid approach. Pages are generated statically, but they are regenerated in the background at a configurable interval or on-demand. This gives you SSG performance with SSR freshness. Since ESBuild doesn't ship with ISR, we implement it ourselves using a cache with TTL.

// src/server/isr.ts
import express from "express";
import { renderPage } from "./render";
import fs from "fs/promises";
import path from "path";

interface CacheEntry {
  html: string;
  status: number;
  generatedAt: number;
}

const cache = new Map<string, CacheEntry>();
const REVALIDATE_SECONDS = 60; // Regenerate every 60 seconds

async function getOrRevalidate(url: string): Promise<CacheEntry> {
  const cached = cache.get(url);
  const now = Date.now();

  if (cached) {
    const age = (now - cached.generatedAt) / 1000;
    if (age < REVALIDATE_SECONDS) {
      // Serve stale cache
      return cached;
    }
    // Stale-while-revalidate: serve old, regenerate in background
    renderPage(url).then((result) => {
      cache.set(url, {
        html: result.html,
        status: result.status,
        generatedAt: Date.now(),
      });
      // Optionally persist to disk
      persistToDisk(url, result.html);
    }).catch(console.error);

    return cached;
  }

  // First render: generate synchronously
  const result = await renderPage(url);
  const entry: CacheEntry = {
    html: result.html,
    status: result.status,
    generatedAt: now,
  };
  cache.set(url, entry);
  await persistToDisk(url, result.html);
  return entry;
}

async function persistToDisk(url: string, html: string) {
  const filePath = path.join(process.cwd(), "dist", "isr-cache", url, "index.html");
  await fs.mkdir(path.dirname(filePath), { recursive: true });
  await fs.writeFile(filePath, html);
}

const app = express();
app.use(express.static(path.join(__dirname, "..", "..", "dist")));

app.get("*", async (req, res) => {
  try {
    const entry = await getOrRevalidate(req.path);
    res.status(entry.status).send(entry.html);
  } catch (err) {
    console.error("ISR error:", err);
    res.status(500).send("<h1>Internal Server Error</h1>");
  }
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`ISR server running on http://localhost:${PORT}`);
});

This implementation uses the stale-while-revalidate pattern. The first request after the TTL expires still gets the cached version, but a background regeneration updates the cache for subsequent requests. This ensures users never wait for regeneration.

On-Demand Revalidation

For content that changes unpredictably (like a CMS publish event), you can add an on-demand revalidation endpoint:

// Add to src/server/isr.ts
app.post("/api/revalidate", async (req, res) => {
  const secret = req.headers["x-revalidate-secret"];
  if (secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  const { path: url } = req.body;
  if (!url) {
    return res.status(400).json({ error: "Path required" });
  }

  try {
    const result = await renderPage(url);
    cache.set(url, {
      html: result.html,
      status: result.status,
      generatedAt: Date.now(),
    });
    await persistToDisk(url, result.html);
    res.json({ success: true, path: url });
  } catch (err) {
    res.status(500).json({ error: "Revalidation failed" });
  }
});

Best Practices

Choosing the Right Strategy

You can mix strategies within the same application. A blog might use SSG for the homepage and post pages, SSR for the admin dashboard, and ISR for a "trending posts" page that updates every few minutes.

Conclusion

Building a custom rendering pipeline with ESBuild is surprisingly straightforward. By leveraging ESBuild's speed for both client and server bundles, you get a development experience that rivals dedicated meta-frameworks while retaining full control over your architecture. SSR gives you real-time rendering, SSG delivers peak performance through pre-generation, and ISR bridges the gap with background revalidation. The key is understanding your content's update frequency and choosing the right strategy per route. With the patterns in this tutorial, you can scale from a simple static blog to a complex hybrid application, all powered by one of the fastest bundlers in the JavaScript ecosystem.

— Ad —

Google AdSense will appear here after approval

← Back to all articles