← Back to DevBytes

Server-Side Rendering with TypeScript Compiler: SSR, SSG, ISR

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?

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles