Introduction to Server-Side Rendering with TypeScript
Modern web development requires a delicate balance between performance, SEO, and developer experience. While Single Page Applications (SPAs) offer fluid user experiences, they often fall short in search engine optimization and initial load performance. This is where Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) come into play. By leveraging the TypeScript compiler, developers can build robust, type-safe rendering pipelines that catch errors at compile time rather than runtime.
What is SSR, SSG, and ISR?
- Server-Side Rendering (SSR): The HTML for a page is generated on the server on every request. This ensures the client always receives the most up-to-date data, making it ideal for highly dynamic, user-specific content.
- Static Site Generation (SSG): HTML is generated once at build time. The resulting static files are served via a CDN. This provides the fastest possible load times and is perfect for blogs, documentation, and marketing pages.
- Incremental Static Regeneration (ISR): A hybrid approach where static pages are generated at build time, but can be updated in the background at runtime after a specified time-to-live (TTL) expires. ISR gives you the performance of SSG with the flexibility of SSR.
Why Does it Matter?
Using TypeScript with these rendering strategies ensures that the data passed from your server or build script to your HTML templates is strictly typed. This prevents common runtime errors, such as attempting to access undefined properties, and provides excellent autocompletion in your IDE. Furthermore, choosing the right rendering strategy for each route can drastically reduce server costs and improve Core Web Vitals.
Setting Up the TypeScript SSR Environment
To demonstrate these concepts, we will build a lightweight Node.js server using Express and the TypeScript compiler. First, initialize a new project and install the necessary dependencies.
npm init -y
npm install express
npm install -D typescript @types/express @types/node tsx
npx tsc --init
Next, configure your tsconfig.json to use modern JavaScript features and ensure proper module resolution.
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Create a basic data interface and a mock data fetcher in src/data.ts that we will use across all three rendering methods.
// src/data.ts
export interface Post {
id: number;
title: string;
content: string;
updatedAt: string;
}
export async function getPost(id: number): Promise {
// Simulating a database or API call
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id,
title: `Post ${id}`,
content: `This is the content for post ${id}.`,
updatedAt: new Date().toISOString()
});
}, 100);
});
}
export function renderTemplate(post: Post): string {
return `
<!DOCTYPE html>
<html>
<head><title>${post.title}</title></head>
<body>
<h1>${post.title}</h1>
<p>${post.content}</p>
<small>Last updated: ${post.updatedAt}</small>
</body>
</html>
`;
}
Implementing Server-Side Rendering (SSR)
SSR generates HTML on-demand. When a user requests a page, the server fetches the latest data, injects it into a template, and sends the fully formed HTML string back to the browser.
Creating the SSR Route
In src/ssr.ts, set up an Express server that handles requests dynamically.
// src/ssr.ts
import express from 'express';
import { getPost, renderTemplate, Post } from './data';
const app = express();
const PORT = 3000;
app.get('/post/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
// Fetch data on every request
const post: Post = await getPost(id);
// Generate HTML on the fly
const html = renderTemplate(post);
res.send(html);
} catch (error) {
res.status(500).send('Internal Server Error');
}
});
app.listen(PORT, () => {
console.log(`SSR Server running on http://localhost:${PORT}`);
});
Run this using npx tsx src/ssr.ts. Every time you refresh the page, the updatedAt timestamp will change, proving the server is rendering the page dynamically on each request.
Implementing Static Site Generation (SSG)
SSG shifts the rendering workload to the build step. Instead of a server processing requests, a script generates static HTML files that can be deployed to any static host.
Pre-rendering Pages at Build Time
Create a file named src/build.ts. This script will fetch data for a predefined list of posts and write the resulting HTML to the file system.
// src/build.ts
import fs from 'fs';
import path from 'path';
import { getPost, renderTemplate } from './data';
async function generateStaticSites() {
const outputDir = path.join(__dirname, 'out');
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
const postIds = [1, 2, 3];
for (const id of postIds) {
const post = await getPost(id);
const html = renderTemplate(post);
const filePath = path.join(outputDir, `post-${id}.html`);
fs.writeFileSync(filePath, html);
console.log(`Generated ${filePath}`);
}
}
generateStaticSites().catch(console.error);
Run this script using npx tsx src/build.ts. You will see an out directory populated with post-1.html, post-2.html, and post-3.html. These files contain the exact HTML that will be served to users, generated only once during the build.
Implementing Incremental Static Regeneration (ISR)
ISR combines the speed of SSG with the freshness of SSR. We serve a cached static page, but we track when it was generated. If the cache is older than our TTL, we serve the stale page immediately but trigger a background regeneration for the next request.
Updating Static Pages Without Rebuilding
We can implement a simple ISR cache in memory (or using Redis in a production environment). Create src/isr.ts.
// src/isr.ts
import express from 'express';
import { getPost, renderTemplate, Post } from './data';
const app = express();
const PORT = 3001;
// In-memory cache store
interface CacheEntry {
html: string;
generatedAt: number;
}
const cache = new Map();
// Time-to-live in milliseconds (e.g., 10 seconds)
const TTL = 10000;
app.get('/post/:id', async (req, res) => {
const id = parseInt(req.params.id, 10);
const now = Date.now();
const entry = cache.get(id);
if (entry) {
// Serve cached content immediately
res.send(entry.html);
// Check if cache is stale
if (now - entry.generatedAt > TTL) {
// Regenerate in the background
getPost(id).then((post: Post) => {
cache.set(id, {
html: renderTemplate(post),
generatedAt: Date.now()
});
console.log(`Regenerated cache for post ${id}`);
});
}
} else {
// First request, generate and cache
const post = await getPost(id);
const html = renderTemplate(post);
cache.set(id, { html, generatedAt: now });
res.send(html);
}
});
app.listen(PORT, () => {
console.log(`ISR Server running on http://localhost:${PORT}`);
});
Run this with npx tsx src/isr.ts. If you refresh the page repeatedly, the updatedAt timestamp will remain static for 10 seconds. After 10 seconds, the next request will still show the old timestamp, but it will trigger a background update. The subsequent request will then show the newly generated timestamp.
Best Practices for SSR, SSG, and ISR with TypeScript
- Strict Typing for Data Fetching: Always define interfaces for your external API responses. This ensures that your rendering functions receive the exact data shape they expect, preventing template rendering errors.
- Choose the Right Strategy per Route: Do not use a single rendering strategy for your entire application. Use SSG for marketing pages and blogs, SSR for user dashboards, and ISR for high-traffic pages that update periodically (like an e-commerce product page).
- Handle Errors Gracefully: In SSR and ISR, if a background fetch fails, ensure your application falls back to the stale cache rather than crashing or serving a broken page.
- Abstract the Rendering Logic: Keep your HTML template generation separate from your routing and caching logic. This makes it easier to reuse the same templates across SSR, SSG, and ISR pipelines.
- Use External Caching for ISR: While an in-memory cache works for a single server instance, production ISR implementations should use distributed caches like Redis or file-system-based caches to share state across multiple server instances.
Conclusion
Understanding and implementing SSR, SSG, and ISR is crucial for building high-performance, SEO-friendly web applications. By utilizing the TypeScript compiler, you add a strong layer of type safety to your rendering pipelines, ensuring data integrity from your database down to the HTML string. While frameworks like Next.js provide these features out of the box, building them from scratch with Express and TypeScript demystifies the underlying mechanics, giving you greater control and insight into your application's architecture. By carefully selecting the appropriate rendering strategy for each route, you can deliver an optimal user experience while keeping server costs manageable.