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:
- Fast rebuilds during development — your SSR server can hot-reload in milliseconds.
- Efficient SSG builds — generating hundreds of pages becomes a sub-second operation.
- Minimal configuration — ESBuild's API is small and predictable.
- Native ESM and TypeScript support — no transpilation step required.
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
- Separate client and server bundles — never ship Node code to the browser. Use ESBuild's
platformoption to enforce this. - Externalize server dependencies — bundling
expressor native modules into your server bundle causes issues. Always add them toexternal. - Sanitize serialized props — when injecting
window.__INITIAL_PROPS__, escape<characters to prevent XSS via script injection in data. - Use streaming for large pages — for pages with heavy content, consider streaming HTML using Node's
Readablestreams andrenderToPipeableStream(requires React 18 or a Preact equivalent). - Cache at multiple layers — combine ISR's in-memory cache with a CDN in front of your server. Set appropriate
Cache-Controlheaders. - Handle errors gracefully — wrap render calls in try/catch and provide a fallback HTML shell so users never see a raw stack trace.
- Watch mode for development — use ESBuild's
context()API withwatch: trueto rebuild instantly on file changes. - Code-split the client bundle — use ESBuild's
splitting: truewith ESM output to reduce initial JavaScript payload. - Measure render times — add timing logs around
renderPageto identify slow pages and optimize data fetching. - Persist ISR cache to disk — in-memory caches are lost on restart. Persisting to disk (as shown above) allows warm starts.
Choosing the Right Strategy
- SSR — use for personalized dashboards, search results, or any page where content is unique per user or per request.
- SSG — use for marketing pages, documentation, and blogs where content changes infrequently and you want maximum CDN performance.
- ISR — use for e-commerce product pages, news sites, or any content that updates periodically but doesn't need real-time freshness.
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.