← Back to DevBytes

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

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

NestJS is widely known as a backend framework for building REST APIs, microservices, and GraphQL gateways. However, it is also a powerful platform for rendering HTML on the server. In this tutorial, we will explore three rendering strategies — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) — and how to implement each one inside a NestJS application using its built-in integration with template engines and the @nestjs/serve-static and @nestjs/axios packages.

What Is Server-Side Rendering?

Server-Side Rendering (SSR) is the process of generating HTML on the server for every incoming request. Instead of sending an empty HTML shell and letting the browser fetch data via JavaScript (as in a typical Single Page Application), the server composes the full page using the latest data and sends it ready to display.

SSR is valuable because it improves first contentful paint, helps search engines index content reliably, and ensures users on slow devices or networks see meaningful content faster. The trade-off is that every request requires server CPU time and a data fetch, which can increase latency under load.

What Is Static Site Generation?

Static Site Generation (SSG) pre-renders pages at build time. The HTML is generated once, stored as a file, and served directly by a CDN or static file server. This produces the fastest possible response times and the lowest hosting costs, since no runtime computation is needed.

SSG is ideal for content that rarely changes: marketing pages, documentation, blog posts, and product catalogs that update infrequently. The downside is that any content change requires a rebuild and redeploy, which can be cumbersome for very large sites.

What Is Incremental Static Regeneration?

Incremental Static Regeneration (ISR) bridges the gap between SSR and SSG. Pages are generated statically, but the server can regenerate them in the background when the underlying data changes or after a configurable time-to-live (TTL) expires. The first visitor after expiration receives the cached version while a fresh version is built asynchronously, so subsequent visitors get updated content without a full rebuild.

ISR is perfect for large sites where rebuilding everything on every change is impractical, but where content still needs to stay reasonably fresh — for example, e-commerce product pages, news listings, or dashboards.

Why Rendering Strategy Matters in NestJS

Choosing the right rendering strategy affects performance, SEO, infrastructure cost, and developer experience. NestJS is uniquely positioned because it can act as both an API server and a rendering server, letting you mix strategies within a single application. You can serve a statically generated marketing page, an SSR-powered dashboard, and ISR-backed product pages from the same NestJS instance.

Setting Up a NestJS Project for Rendering

Begin by creating a new NestJS project and installing the dependencies needed for templating and static file serving. We will use Handlebars as the template engine because it is simple, widely understood, and well-supported by NestJS.

nest new rendering-demo
cd rendering-demo
npm install @nestjs/serve-static hbs
npm install --save-dev @types/hbs

Next, configure the application to use Handlebars and to serve static assets. Update src/main.ts:

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');

  app.useStaticAssets(join(__dirname, '..', 'public'));

  await app.listen(3000);
}
bootstrap();

Create a views directory at the project root and add a basic layout file views/layout.hbs:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>{{title}}</title>
</head>
<body>
  <header><h1>NestJS Rendering Demo</h1></header>
  <main>
    {{{body}}}
  </main>
</body>
</html>

Implementing SSR in NestJS

SSR is the most straightforward strategy in NestJS. For each request, the controller fetches data, passes it to the template engine, and returns rendered HTML. NestJS handles this automatically when you use the @Render() decorator combined with @Res() or a returned object.

Create a controller at src/ssr.controller.ts:

import { Controller, Get, Render } from '@nestjs/common';

interface Product {
  id: number;
  name: string;
  price: number;
}

@Controller('ssr')
export class SsrController {
  private products: Product[] = [
    { id: 1, name: 'Wireless Mouse', price: 25 },
    { id: 2, name: 'Mechanical Keyboard', price: 89 },
    { id: 3, name: 'USB-C Hub', price: 45 },
  ];

  @Get()
  @Render('ssr-page')
  renderSsr() {
    return {
      title: 'SSR Product List',
      products: this.products,
      renderedAt: new Date().toISOString(),
    };
  }
}

Create the template views/ssr-page.hbs:

<section>
  <h2>Products (SSR)</h2>
  <p>Rendered at: {{renderedAt}}</p>
  <ul>
    {{#each products}}
      <li>{{this.name}} — ${{this.price}}</li>
    {{/each}}
  </ul>
</section>

Register the controller in src/app.module.ts:

import { Module } from '@nestjs/common';
import { SsrController } from './ssr.controller';

@Module({
  controllers: [SsrController],
})
export class AppModule {}

Now visiting http://localhost:3000/ssr will render the page fresh on every request. The renderedAt timestamp will change each time you reload, confirming that the server is regenerating the HTML per request.

Fetching Data Asynchronously in SSR

In real applications, data comes from a database or another API. Use an injectable service and async route handlers:

import { Controller, Get, Render } from '@nestjs/common';
import { ProductService } from './product.service';

@Controller('ssr')
export class SsrController {
  constructor(private readonly products: ProductService) {}

  @Get()
  @Render('ssr-page')
  async renderSsr() {
    const products = await this.products.findAll();
    return {
      title: 'SSR Product List',
      products,
      renderedAt: new Date().toISOString(),
    };
  }
}

Implementing SSG in NestJS

SSG in NestJS means generating HTML files at build time and serving them statically. NestJS does not have a built-in SSG command like Next.js, but we can achieve the same result by writing a small script that boots the application in a special mode, renders routes to strings, and writes the output to the public directory.

First, install nunjucks or use the same Handlebars engine to render templates to strings. We will reuse Handlebars directly:

npm install hbs

Create a service that can render a template to a string. Add src/render.service.ts:

import { Injectable } from '@nestjs/common';
import * as hbs from 'hbs';
import { join } from 'path';
import { promisify } from 'util';
import { readFile } from 'fs/promises';

const renderFile = promisify(hbs.renderFile) as (
  path: string,
  data: Record<string, unknown>,
) => Promise<string>;

@Injectable()
export class RenderService {
  private viewsDir = join(__dirname, '..', 'views');

  async renderToString(template: string, data: Record<string, unknown>) {
    const layoutPath = join(this.viewsDir, 'layout.hbs');
    const body = await renderFile(join(this.viewsDir, template), data);
    const layout = await readFile(layoutPath, 'utf-8');
    return layout.replace('{{{body}}}', body);
  }
}

Now create a build script at scripts/generate-static.ts that renders known routes and writes them to disk:

import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { RenderService } from '../src/render.service';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';

async function generate() {
  const app = await NestFactory.createApplicationContext(AppModule);
  const renderer = app.get(RenderService);

  const pages: Array<{ route: string; template: string; data: any }> = [
    {
      route: 'index.html',
      template: 'ssr-page.hbs',
      data: {
        title: 'Static Home',
        products: [
          { id: 1, name: 'Wireless Mouse', price: 25 },
          { id: 2, name: 'Mechanical Keyboard', price: 89 },
        ],
        renderedAt: 'generated at build time',
      },
    },
  ];

  const outDir = join(process.cwd(), 'public');
  await mkdir(outDir, { recursive: true });

  for (const page of pages) {
    const html = await renderer.renderToString(page.template, page.data);
    await writeFile(join(outDir, page.route), html, 'utf-8');
    console.log(`Generated ${page.route}`);
  }

  await app.close();
}

generate().catch((err) => {
  console.error(err);
  process.exit(1);
});

Add a script entry in package.json:

"scripts": {
  "build:static": "ts-node scripts/generate-static.ts"
}

After running npm run build:static, the public/index.html file will exist and be served instantly by useStaticAssets. Because it is a plain file, you can deploy it to any CDN or static host. The content is frozen at build time, so any data change requires re-running the script.

Serving Static Files with ServeStaticModule

For a cleaner setup, use the @nestjs/serve-static package to serve the generated files:

import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { SsrController } from './ssr.controller';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),
      serveRoot: '/static',
    }),
  ],
  controllers: [SsrController],
})
export class AppModule {}

Implementing ISR in NestJS

ISR combines the speed of static files with the freshness of SSR. The idea is to cache the rendered HTML for a period, serve the cached version to users, and regenerate it in the background when the cache expires or when an explicit revalidation request arrives.

We can implement ISR in NestJS with an in-memory or Redis-backed cache and a background regeneration task. Below is a self-contained implementation using an in-memory cache.

Create src/isr.service.ts:

import { Injectable, OnModuleInit } from '@nestjs/common';
import { RenderService } from './render.service';

interface CacheEntry {
  html: string;
  generatedAt: number;
  regenerating: boolean;
}

@Injectable()
export class IsrService implements OnModuleInit {
  private cache = new Map<string, CacheEntry>();
  private readonly ttlMs = 10_000; // 10 seconds for demo purposes

  constructor(private readonly renderer: RenderService) {}

  async get(
    key: string,
    template: string,
    data: () => Promise<Record<string, unknown>>,
  ): Promise<{ html: string; stale: boolean }> {
    const entry = this.cache.get(key);

    if (!entry) {
      const html = await this.renderer.renderToString(
        template,
        await data(),
      );
      this.cache.set(key, {
        html,
        generatedAt: Date.now(),
        regenerating: false,
      });
      return { html, stale: false };
    }

    const age = Date.now() - entry.generatedAt;
    const isStale = age > this.ttlMs;

    if (isStale && !entry.regenerating) {
      entry.regenerating = true;
      // Fire and forget background regeneration
      this.regenerate(key, template, data);
    }

    return { html: entry.html, stale: isStale };
  }

  private async regenerate(
    key: string,
    template: string,
    data: () => Promise<Record<string, unknown>>,
  ) {
    try {
      const html = await this.renderer.renderToString(
        template,
        await data(),
      );
      this.cache.set(key, {
        html,
        generatedAt: Date.now(),
        regenerating: false,
      });
    } catch (err) {
      console.error(`ISR regeneration failed for ${key}:`, err);
      const entry = this.cache.get(key);
      if (entry) entry.regenerating = false;
    }
  }

  onModuleInit() {
    console.log('ISR service initialized with TTL of', this.ttlMs, 'ms');
  }
}

Create a controller that uses the ISR service at src/isr.controller.ts:

import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { IsrService } from './isr.service';

@Controller('isr')
export class IsrController {
  constructor(private readonly isr: IsrService) {}

  @Get()
  async render(@Res() res: Response) {
    const { html, stale } = await this.isr.get(
      'product-list',
      'ssr-page.hbs',
      async () => ({
        title: stale ? 'ISR (stale)' : 'ISR (fresh)',
        products: [
          { id: 1, name: 'Wireless Mouse', price: 25 },
          { id: 2, name: 'Mechanical Keyboard', price: 89 },
          { id: 3, name: 'USB-C Hub', price: 45 },
        ],
        renderedAt: new Date().toISOString(),
      }),
    );

    res.setHeader('X-ISR-Stale', stale ? 'true' : 'false');
    res.type('text/html').send(html);
  }
}

Register both the service and controller in your module:

import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { SsrController } from './ssr.controller';
import { IsrController } from './isr.controller';
import { IsrService } from './isr.service';
import { RenderService } from './render.service';

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),
      serveRoot: '/static',
    }),
  ],
  controllers: [SsrController, IsrController],
  providers: [IsrService, RenderService],
})
export class AppModule {}

When you visit http://localhost:3000/isr, the first request generates and caches the page. Subsequent requests within the TTL return the cached HTML instantly. After the TTL expires, the next request still receives the cached (now stale) version, but the server regenerates the page in the background. The following request returns the fresh version. The X-ISR-Stale header lets you observe this behavior in your browser dev tools.

On-Demand Revalidation

For content that changes unpredictably, you can add an endpoint that triggers regeneration immediately. This mirrors the on-demand ISR pattern popularized by Next.js:

import { Controller, Post, Param, HttpCode } from '@nestjs/common';
import { IsrService } from './isr.service';

@Controller('isr/revalidate')
export class IsrRevalidateController {
  constructor(private readonly isr: IsrService) {}

  @Post(':key')
  @HttpCode(200)
  revalidate(@Param('key') key: string) {
    // In a real app, call isr.invalidate(key) to clear the cache entry
    return { revalidated: true, key };
  }
}

You would then add an invalidate method to IsrService that deletes the cache entry, forcing the next request to regenerate synchronously. Webhooks from a CMS can call this endpoint whenever content is published.

Best Practices

Conclusion

NestJS gives you the flexibility to implement SSR, SSG, and ISR within a single backend application, letting you match each route to the rendering strategy that best fits its content and traffic patterns. SSR delivers fresh, personalized HTML on every request; SSG produces blazing-fast static files at build time; and ISR combines the two by serving cached pages while regenerating them in the background. By understanding the trade-offs of each approach and applying the patterns shown in this tutorial, you can build NestJS applications that are fast, SEO-friendly, and cost-effective to operate, while keeping the door open to scale rendering strategies as your product grows.

— Ad —

Google AdSense will appear here after approval

← Back to all articles