Server-Side Rendering with Fastify: SSR, SSG, and ISR Explained
Modern web applications demand fast initial page loads, SEO-friendly markup, and dynamic content that updates without sacrificing performance. While client-side rendering (CSR) dominated the JavaScript ecosystem for years, the pendulum has swung back toward server-rendered approaches. Fastify, with its low overhead and plugin architecture, is an excellent foundation for building rendering pipelines that support Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR).
This tutorial walks through each rendering strategy, shows how to implement them with Fastify, and covers best practices for production deployments.
Understanding the Rendering Strategies
Server-Side Rendering (SSR)
SSR generates HTML on the server for every incoming request. The browser receives a fully rendered page, which improves perceived performance and SEO. The trade-off is that every request requires server computation.
Static Site Generation (SSG)
SSG pre-renders pages at build time into static HTML files. These files can be served from a CDN, offering the fastest possible response times. The limitation is that content only updates when you rebuild the site.
Incremental Static Regeneration (ISR)
ISR bridges the gap between SSR and SSG. Pages are generated statically, but the server can regenerate them in the background when data changes or after a configurable time-to-live (TTL) expires. Users always receive the cached static page while a fresh version is built asynchronously.
Why Fastify for Rendering?
Fastify is a high-performance Node.js web framework known for its speed and developer experience. Several characteristics make it ideal for rendering workloads:
- Low overhead: Fastify's serialization and routing are highly optimized, leaving more CPU cycles for rendering.
- Plugin ecosystem: Official plugins like
@fastify/viewintegrate template engines seamlessly. - Encapsulated contexts: Plugins are scoped, preventing global state leakage between routes.
- Built-in schema validation: Useful for validating request data before rendering.
- TypeScript support: First-class types make rendering pipelines safer to maintain.
Project Setup
Start by initializing a new project and installing the necessary dependencies. This tutorial uses @fastify/view with eta, a lightweight template engine, but the patterns apply to Handlebars, Pug, or React's renderToString.
mkdir fastify-rendering
cd fastify-rendering
npm init -y
npm install fastify @fastify/view eta
npm install -D nodemon
Create a basic Fastify server file to confirm everything works:
// src/server.js
import Fastify from 'fastify';
import view from '@fastify/view';
import { Eta } from 'eta';
const fastify = Fastify({ logger: true });
await fastify.register(view, {
engine: { eta: new Eta() },
root: './src/templates',
layout: 'layout.eta',
});
fastify.get('/', async (request, reply) => {
return reply.view('index.eta', { title: 'Home', message: 'Hello from Fastify' });
});
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('Server running at http://localhost:3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Create a layout and a simple template:
<!-- src/templates/layout.eta -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= it.title %></title>
</head>
<body>
<nav><a href="/">Home</a> | <a href="/posts">Posts</a></nav>
<main><%~ it.body %></main>
</body>
</html>
<!-- src/templates/index.eta -->
<h1><%= it.message %></h1>
<p>This page is rendered on the server.</p>
Implementing SSR
With SSR, every request triggers a fresh render. This is ideal for pages with personalized or frequently changing content. The example below fetches data from a mock data source and renders it on each request.
// src/routes/ssr.js
export default async function ssrRoutes(fastify) {
// Simulated data fetch
async function fetchPosts() {
return [
{ id: 1, title: 'Understanding SSR', body: 'SSR renders HTML on each request.' },
{ id: 2, title: 'Fastify Performance', body: 'Fastify is built for speed.' },
{ id: 3, title: 'Template Engines', body: 'Eta is lightweight and fast.' },
];
}
fastify.get('/posts', async (request, reply) => {
const posts = await fetchPosts();
return reply.view('posts.eta', {
title: 'Posts',
posts,
renderedAt: new Date().toISOString(),
});
});
fastify.get('/posts/:id', async (request, reply) => {
const { id } = request.params;
const posts = await fetchPosts();
const post = posts.find((p) => p.id === Number(id));
if (!post) {
return reply.code(404).view('not-found.eta', { title: 'Not Found' });
}
return reply.view('post.eta', {
title: post.title,
post,
renderedAt: new Date().toISOString(),
});
});
}
<!-- src/templates/posts.eta -->
<h1>All Posts</h1>
<p><small>Rendered at: <%= it.renderedAt %></small></p>
<ul>
<% it.posts.forEach(function(post) { %>
<li><a href="/posts/<%= post.id %>"><%= post.title %></a></li>
<% }) %>
</ul>
Register the route plugin in your server:
// src/server.js (updated)
import ssrRoutes from './routes/ssr.js';
await fastify.register(ssrRoutes);
Every visit to /posts now produces a fresh HTML response with the current timestamp, demonstrating true server-side rendering.
Implementing SSG
SSG shifts rendering to build time. You write a script that iterates over your routes, calls the same rendering logic, and writes the output to static HTML files. These files can then be served by Fastify, a CDN, or any static file host.
First, extract the rendering logic into a reusable module so both the server and the build script can use it:
// src/lib/render.js
import { Eta } from 'eta';
const eta = new Eta({ views: './src/templates' });
export async function renderTemplate(template, data) {
return await eta.render(template, data);
}
export async function fetchPosts() {
return [
{ id: 1, title: 'Understanding SSR', body: 'SSR renders HTML on each request.' },
{ id: 2, title: 'Fastify Performance', body: 'Fastify is built for speed.' },
{ id: 3, title: 'Template Engines', body: 'Eta is lightweight and fast.' },
];
}
export async function renderLayout(title, body) {
return await renderTemplate('layout.eta', { title, body });
}
Now create the build script:
// src/build.js
import fs from 'fs/promises';
import path from 'path';
import { renderTemplate, renderLayout, fetchPosts } from './lib/render.js';
const OUTPUT_DIR = './public';
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeFile(filePath, content) {
await ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content);
console.log(`Generated: ${filePath}`);
}
async function build() {
await ensureDir(OUTPUT_DIR);
// Generate home page
const homeBody = await renderTemplate('index.eta', {
message: 'Hello from Fastify SSG',
});
await writeFile(path.join(OUTPUT_DIR, 'index.html'), await renderLayout('Home', homeBody));
// Generate posts listing
const posts = await fetchPosts();
const postsBody = await renderTemplate('posts.eta', {
posts,
renderedAt: new Date().toISOString(),
});
await writeFile(path.join(OUTPUT_DIR, 'posts', 'index.html'), await renderLayout('Posts', postsBody));
// Generate individual post pages
for (const post of posts) {
const postBody = await renderTemplate('post.eta', {
post,
renderedAt: new Date().toISOString(),
});
await writeFile(
path.join(OUTPUT_DIR, 'posts', `${post.id}.html`),
await renderLayout(post.title, postBody)
);
}
console.log('Build complete.');
}
build().catch((err) => {
console.error('Build failed:', err);
process.exit(1);
});
Run the build script and serve the output with Fastify's static plugin:
npm install @fastify/static
node src/build.js
// src/server-static.js
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fastify = Fastify({ logger: true });
await fastify.register(fastifyStatic, {
root: path.join(__dirname, '..', 'public'),
prefix: '/',
});
await fastify.listen({ port: 3000 });
console.log('Static server running at http://localhost:3000');
The generated files in public/ are now served as static assets. This approach delivers the fastest possible response times because no rendering happens at request time.
Implementing ISR
ISR combines the speed of SSG with the freshness of SSR. The server serves a cached static page immediately. In the background, if the cache is stale (based on a TTL) or explicitly invalidated, the server regenerates the page and updates the cache. Subsequent requests serve the freshly generated version.
Here is a complete ISR implementation using an in-memory cache. In production, you would use Redis or a similar store:
// src/lib/isr-cache.js
const cache = new Map();
export function getCached(key) {
const entry = cache.get(key);
if (!entry) return null;
return entry;
}
export function setCached(key, html, ttlSeconds) {
cache.set(key, {
html,
generatedAt: Date.now(),
ttl: ttlSeconds * 1000,
regenerating: false,
});
}
export function isStale(entry) {
return Date.now() - entry.generatedAt > entry.ttl;
}
export function markRegenerating(key) {
const entry = cache.get(key);
if (entry) entry.regenerating = true;
}
export function clearRegenerating(key) {
const entry = cache.get(key);
if (entry) entry.regenerating = false;
}
export function invalidate(key) {
cache.delete(key);
}
export function invalidateAll() {
cache.clear();
}
Now create an ISR route plugin:
// src/routes/isr.js
import { renderTemplate, renderLayout, fetchPosts } from '../lib/render.js';
import {
getCached,
setCached,
isStale,
markRegenerating,
clearRegenerating,
invalidate,
} from '../lib/isr-cache.js';
const DEFAULT_TTL = 30; // seconds
async function generatePostsPage() {
const posts = await fetchPosts();
const body = await renderTemplate('posts.eta', {
posts,
renderedAt: new Date().toISOString(),
});
return await renderLayout('Posts (ISR)', body);
}
async function generatePostPage(id) {
const posts = await fetchPosts();
const post = posts.find((p) => p.id === Number(id));
if (!post) return null;
const body = await renderTemplate('post.eta', {
post,
renderedAt: new Date().toISOString(),
});
return await renderLayout(post.title, body);
}
export default async function isrRoutes(fastify) {
fastify.get('/isr/posts', async (request, reply) => {
const cacheKey = 'posts-list';
const entry = getCached(cacheKey);
if (entry) {
// Serve cached content immediately
reply.type('text/html').send(entry.html);
// Regenerate in background if stale and not already regenerating
if (isStale(entry) && !entry.regenerating) {
markRegenerating(cacheKey);
generatePostsPage()
.then((html) => {
setCached(cacheKey, html, DEFAULT_TTL);
})
.catch((err) => fastify.log.error(err))
.finally(() => clearRegenerating(cacheKey));
}
return;
}
// No cache: generate synchronously (first request or cold start)
const html = await generatePostsPage();
setCached(cacheKey, html, DEFAULT_TTL);
reply.type('text/html').send(html);
});
fastify.get('/isr/posts/:id', async (request, reply) => {
const { id } = request.params;
const cacheKey = `post-${id}`;
const entry = getCached(cacheKey);
if (entry) {
reply.type('text/html').send(entry.html);
if (isStale(entry) && !entry.regenerating) {
markRegenerating(cacheKey);
generatePostPage(id)
.then((html) => {
if (html) setCached(cacheKey, html, DEFAULT_TTL);
})
.catch((err) => fastify.log.error(err))
.finally(() => clearRegenerating(cacheKey));
}
return;
}
const html = await generatePostPage(id);
if (!html) {
return reply.code(404).send('Not found');
}
setCached(cacheKey, html, DEFAULT_TTL);
reply.type('text/html').send(html);
});
// Manual revalidation endpoint
fastify.post('/isr/revalidate/:key', async (request, reply) => {
const { key } = request.params;
invalidate(key);
return { success: true, message: `Cache invalidated for ${key}` };
});
}
Register the ISR routes in your server:
// src/server.js (final version)
import Fastify from 'fastify';
import view from '@fastify/view';
import { Eta } from 'eta';
import ssrRoutes from './routes/ssr.js';
import isrRoutes from './routes/isr.js';
const fastify = Fastify({ logger: true });
await fastify.register(view, {
engine: { eta: new Eta() },
root: './src/templates',
layout: 'layout.eta',
});
await fastify.register(ssrRoutes);
await fastify.register(isrRoutes);
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
console.log('Server running at http://localhost:3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
When you visit /isr/posts, the first request generates the page and caches it. For the next 30 seconds, every request receives the cached HTML instantly. After the TTL expires, the next request still receives the cached version, but the server regenerates the page in the background. The following request gets the fresh content. You can also trigger immediate revalidation by sending a POST request to /isr/revalidate/posts-list.
Using React with Fastify SSR
If you prefer React, you can swap the template engine for react-dom/server. The same SSR, SSG, and ISR patterns apply. Here is a minimal React SSR setup:
npm install react react-dom
// src/react-server.js
import Fastify from 'fastify';
import React from 'react';
import { renderToString } from 'react-dom/server';
function App({ title, posts }) {
return React.createElement('div', null, [
React.createElement('h1', { key: 'title' }, title),
React.createElement('ul', { key: 'list' },
posts.map((post) =>
React.createElement('li', { key: post.id }, post.title)
)
),
]);
}
const fastify = Fastify({ logger: true });
fastify.get('/', async (request, reply) => {
const posts = [
{ id: 1, title: 'React SSR with Fastify' },
{ id: 2, title: 'Streaming React Components' },
];
const appHtml = renderToString(
React.createElement(App, { title: 'My React App', posts })
);
const html = `<!DOCTYPE html>
<html>
<head><title>React SSR</title></head>
<body>
<div id="root">${appHtml}</div>
<script type="module" src="/client.js"></script>
</body>
</html>`;
reply.type('text/html').send(html);
});
await fastify.listen({ port: 3000 });
For more complex React applications, consider using renderToPipeableStream for streaming SSR, which lets the browser start parsing HTML before the full response is ready.
Best Practices
Choose the Right Strategy Per Route
Not every page needs the same rendering approach. Use SSG for marketing pages and documentation that rarely change. Use SSR for personalized dashboards or real-time data. Use ISR for content sites like blogs or news where pages update occasionally but not on every request.
Cache at Multiple Layers
ISR handles page-level caching, but you should also cache data fetches. Memoize expensive database queries and API calls. Use @fastify/redis for distributed caching when running multiple server instances.
Handle Errors Gracefully
Background regeneration in ISR should never crash the server. Always wrap regeneration promises in try/catch and log errors. If regeneration fails, the stale cache remains valid and users are unaffected:
generatePostsPage()
.then((html) => setCached(cacheKey, html, DEFAULT_TTL))
.catch((err) => {
fastify.log.error({ err }, 'ISR regeneration failed');
})
.finally(() => clearRegenerating(cacheKey));
Set Appropriate TTLs
Short TTLs (5-30 seconds) suit frequently updated content. Long TTLs (hours or days) work for stable content. Combine long TTLs with manual revalidation endpoints triggered by webhooks from your CMS or database.
Use Streaming Where Possible
Fastify supports streaming responses. For SSR pages with heavy data dependencies, stream the static shell first, then flush dynamic sections as data arrives. This improves Time to First Byte (TTFB) and perceived performance.
Optimize Template Rendering
Precompile templates at startup rather than on each request. Eta and most template engines support precompilation. This eliminates parsing overhead during request handling:
import { Eta } from 'eta';
const eta = new Eta({
views: './src/templates',
cache: true, // Enable template caching
});
Monitor Cache Hit Rates
Instrument your ISR cache to track hit rates, miss rates, and regeneration frequency. Low hit rates indicate your TTL is too short or traffic is too sparse. High regeneration frequency suggests data changes more often than your TTL allows.
Secure Revalidation Endpoints
Manual revalidation endpoints should require authentication. Use API keys, HMAC signatures, or JWT tokens to ensure only authorized services can trigger cache invalidation:
fastify.post('/isr/revalidate/:key', {
preHandler: async (request, reply) => {
const token = request.headers['x-revalidate-token'];
if (token !== process.env.REVALIDATE_TOKEN) {
reply.code(401).send({ error: 'Unauthorized' });
}
},
}, async (request, reply) => {
const { key } = request.params;
invalidate(key);
return { success: true };
});
Plan for Horizontal Scaling
In-memory caches do not share state across instances. When scaling horizontally with multiple Fastify processes or containers, move the ISR cache to Redis or another shared store. This ensures consistent behavior regardless of which instance handles a request.
Conclusion
Fastify provides a fast, flexible foundation for implementing all three major server-side rendering strategies. SSR delivers fresh, personalized content on every request. SSG offers maximum performance by pre-rendering at build time. ISR combines the best of both worlds by serving cached pages instantly while regenerating them in the background. By extracting shared rendering logic, choosing the right strategy per route, and following caching and error-handling best practices, you can build rendering pipelines that scale from simple blogs to complex content platforms. Start with the patterns in this tutorial, adapt them to your data sources and template engine of choice, and iterate based on real performance metrics from your production environment.