MVC, Static Files & Compression

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Most NestJS applications return JSON, but the HTTP layer can also render HTML pages, serve the files of a front-end build and compress what it sends. This page is written against NestJS 12.1 (Express 5 by default, Fastify 5 optional, TypeScript 6, Node.js 20.19+ or 22.12+), with the versions checked on 2026-10-02: hbs 4.3, handlebars 4.7, @fastify/view 12, @fastify/static 10, @nestjs/serve-static 12.0 and @fastify/compress 9. It covers the classic Model-View-Controller setup with @Render(), setBaseViewsDir() and setViewEngine(), the Fastify variant with @fastify/view, @nestjs/serve-static for single-page application (SPA) builds, and response compression with compression or @fastify/compress, including when it belongs in a reverse proxy instead of the application.

Everything here is a feature of the HTTP platform adapter underneath Nest, not of Nest core, so the Express and Fastify code differ. The two adapters are compared on Fastify and HTTP Platforms, and the difference between a NestJS server-rendered view and a Next.js page is explained on NestJS vs Next.js. The examples are exercised with Vitest and supertest (or Fastify’s inject()) on NestJS 12.1.

Who serves what

A single process can serve HTML views, static files, an SPA and a JSON API at once. The adapter dispatches in the order the handlers were registered: static file handlers, then the routes of your controllers, then, for an SPA, a catch-all that returns index.html. A reverse proxy in front often handles compression and, in production, the static files too.

flowchart LR B["Browser"] --> P["Reverse proxy
TLS, compression, caching"] P --> N["NestJS application"] N --> S["Static files
public/ or client/"] N --> V["MVC views
@Render() and Handlebars"] N --> A["JSON API
controllers"] N --> F["SPA fallback
index.html"]

MVC with Express

Nest runs on Express by default, so every Express template-engine technique applies. An MVC application needs a template engine to turn a view and a model into HTML. The official example uses Handlebars through the hbs package, but any Express-compatible engine works.

npm install --save hbs

The Express instance is configured on the NestExpressApplication, which is why the type argument is passed to NestFactory.create(). useStaticAssets() sets the folder with static assets, setBaseViewsDir() the folder with the templates and setViewEngine() the engine, given by the extension of the template files.

main.ts (Express)
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'node:path';
import { AppModule } from './app.module.js';

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

  app.useStaticAssets(join(import.meta.dirname, '..', 'public'));
  app.setBaseViewsDir(join(import.meta.dirname, '..', 'views'));
  app.setViewEngine('hbs');

  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();

The code uses the ESM conventions of the current template: imports end in .js, import.meta.dirname replaces dirname and a top-level await starts the application. In a CommonJS project (the Jest template) dirname is still available and the call to bootstrap() is not awaited at the top level.

Rendering a template with @Render()

@Render('index') names the template, and the value returned by the handler is the model passed to it. The view is looked up in the folder given to setBaseViewsDir(), and the extension comes from the view engine, so the name has no .hbs. In Handlebars, a double-brace expression prints a value with HTML escaping and #each loops over a list.

views/index.hbs
<h1>{{ title }}</h1>
<ul>
  {{#each books}}
  <li>{{ this.title }} by {{ this.author }}</li>
  {{/each}}
</ul>
books.controller.ts
import { Controller, Get, Render } from '@nestjs/common';

@Controller()
export class BooksPageController {
  @Get()
  @Render('index')
  home() {
    return {
      title: 'Bookshelf',
      books: [
        { title: 'Dune', author: 'Frank Herbert' },
        { title: 'Emma', author: 'Jane Austen' },
      ],
    };
  }
}

A request for / returns the rendered page:

<h1>Bookshelf</h1>
<ul>
  <li>Dune by Frank Herbert</li>
  <li>Emma by Jane Austen</li>
</ul>

Because the handler returns a plain object, the usual machinery still runs around it: guards, interceptors and pipes apply as on any route (see Request Lifecycle). The Handlebars double braces escape HTML, so user-supplied values such as a review text are safe to print; the triple-brace form prints raw HTML and must be reserved for trusted markup such as the layout body below.

Layouts

A layout is a wrapper template that every view is rendered into. With hbs it is enabled by setting the layout local variable with setLocal(), Nest’s wrapper around Express’s app.locals. The layout is a file under views/, and the view content is inserted where the layout prints body with triple braces.

main.ts (excerpt)
app.setLocal('layout', 'layouts/app');
app.useStaticAssets(join(import.meta.dirname, '..', 'public'));
app.setBaseViewsDir(join(import.meta.dirname, '..', 'views'));
app.setViewEngine('hbs');
views/layouts/app.hbs
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Bookshelf</title>
    <link rel="stylesheet" href="/app.css" />
  </head>
  <body>
    {{{body}}}
  </body>
</html>

With that layout, the index.hbs above only needs the fragment shown earlier (no <html> wrapper), and the file tree is:

views/
  layouts/
    app.hbs
  index.hbs
public/
  app.css

The request for / now returns the layout with the fragment in place of body, and /app.css is served from public/ because useStaticAssets() mounts the folder at the root.

The layout applies to every render. A view that is already a complete HTML document ends up nested inside the layout and produces two <!DOCTYPE html> declarations, which browsers tolerate but which is not valid HTML. Keep every view a fragment, or opt a single render out with the layout: false option, which hbs reads from the model passed to res.render() (the next section shows that call).

Dynamic rendering

When the controller must choose the template at run time, drop @Render() and inject the response with @Res(). Nest then hands over the Express Response, and calling res.render(view, model) sends the page. Injecting @Res() puts the handler in library-specific mode, so Nest no longer sends the return value for you and interceptors that map the response do not apply; the handler must send the response itself (here it returns the value of res.render()).

books.controller.ts (dynamic view)
import { Controller, Get, Query, Res } from '@nestjs/common';
import type { Response } from 'express';

@Controller()
export class BooksPageController {
  @Get('page')
  page(@Query('view') view: string | undefined, @Res() res: Response) {
    const name = view === 'plain' ? 'plain' : 'index';
    return res.render(name, { title: 'Dynamic', message: 'Hello world!', books: [] });
  }
}
Opting a single render out of the layout
return res.render('plain', { message: 'Hello world!', layout: false });

Never feed the view name from raw request input without an allow-list: the example maps the query value to one of two known templates instead of passing it through.

MVC with Fastify

With the FastifyAdapter the view support comes from Fastify plugins that Nest wraps, so the packages are @fastify/static for assets and @fastify/view with the engine itself (handlebars rather than hbs):

npm install --save @fastify/static @fastify/view handlebars

The configuration follows the same steps with different arguments. The application must be created as a NestFastifyApplication so that the typed useStaticAssets() and setViewEngine() methods exist. Static assets take an options object whose prefix is the URL path they are mounted at, and setViewEngine() takes the @fastify/view options, with the engine keyed by its name.

main.ts (Fastify)
import { NestFactory } from '@nestjs/core';
import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
import { join } from 'node:path';
import Handlebars from 'handlebars';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  app.useStaticAssets({
    root: join(import.meta.dirname, '..', 'public'),
    prefix: '/public/',
  });
  app.setViewEngine({
    engine: {
      handlebars: Handlebars,
    },
    templates: join(import.meta.dirname, '..', 'views'),
  });
  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();

The notable difference in controllers is that the template name passed to @Render() must include the extension. To render dynamically, inject the Fastify reply with @Res() and call res.view():

pages.controller.ts (Fastify)
import { Controller, Get, Render, Res } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import '@fastify/view'; // augments FastifyReply with view()

@Controller()
export class FastifyPagesController {
  @Get()
  @Render('index.hbs') // with Fastify the extension is part of the name
  home() {
    return { title: 'Bookshelf', books: [{ title: 'Dune', author: 'Frank Herbert' }] };
  }

  @Get('page')
  page(@Res() res: FastifyReply) {
    return res.view('plain.hbs', { message: 'Hello world!' });
  }
}

@fastify/view also has its own options that setViewEngine() forwards. Two that are useful: layout names a layout file (with the extension) that every Handlebars render is wrapped in, and includeViewExtension: true lets @Render('index') omit the extension, so the same controller code works on both platforms.

main.ts (Fastify, layout and no extension in view names)
app.setViewEngine({
  engine: { handlebars: Handlebars },
  templates: join(import.meta.dirname, '..', 'views'),
  layout: 'layouts/app.hbs',
  includeViewExtension: true,
});

When MVC is the right tool

Server-rendered Handlebars views suit small admin screens, email-like pages, dashboards for an internal tool and documentation that has to work without a build step. They do not give you component hydration, client-side routing or React Server Components; for a rich front end, build it with a front-end framework and serve it as static files (next section) or deploy it separately and call the Nest API, as discussed on NestJS vs Next.js. For the general idea of rendering on the server see Server Rendering, SSG and React Server Components.

Serving a single-page application

A framework-built SPA (React, Vue, Angular) compiles to a folder of static files. @nestjs/serve-static serves that folder from the Nest application, which is convenient for a single deployable unit that holds both the API and its front end.

npm install --save @nestjs/serve-static

The ServeStaticModule is imported in the root module and configured with forRoot(). Its rootPath is the folder with the build output; the default is a folder named client.

spa.module.ts
import { Controller, Get, Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'node:path';

@Controller('api')
export class HealthController {
  @Get('health')
  health() {
    return { status: 'ok' };
  }
}

@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(import.meta.dirname, 'client'),
      exclude: ['/api/{*any}'], // keep the API out of the SPA fallback
    }),
  ],
  controllers: [HealthController],
})
export class SpaModule {}

With that module, GET / returns client/index.html, GET /assets/main.js returns the file, and GET /books/42, a client-side route that matches no file, also returns index.html so the SPA’s router can take over. The API route still answers as JSON:

curl -s localhost:3000/api/health
{"status":"ok"}

The fallback and exclude

By default renderPath matches every path ({any} on Express, on Fastify) and the module answers with index.html. That is what makes client-side routing work, while the routes declared in your controllers are still handled by the server. Without exclude, an unknown API path would also be answered with index.html and status 200, which hides typos from API clients; with the exclusion above an unknown API path returns Nest’s normal 404:

{"message":"Cannot GET /api/nope","error":"Not Found","statusCode":404}

The exclude paths use path-to-regexp syntax, which changed with Express 5: the wildcard is the named {any}, not the old (.). A regular expression is also accepted. Excluding the API prefix is the single most useful option, and it pairs naturally with a global prefix (see Versioning and Global Prefix); useGlobalPrefix is an option that prepends the global prefix to serveRoot.

Mounting under a prefix and cache headers

serveRoot mounts the SPA under a URL prefix, and serveStaticOptions passes options through to express.static() (or @fastify/static): maxAge, etag, immutable, index, fallthrough, setHeaders and others (the full list is the ServeStaticModuleOptions interface). Content-hashed build assets such as main.3f2a.js can be cached for a long time, while index.html must not be.

Serving the SPA under /app with a one-hour cache
@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(import.meta.dirname, 'client'),
      serveRoot: '/app',
      serveStaticOptions: { maxAge: 3600 * 1000, index: false },
      exclude: ['/api/{*any}'],
    }),
  ],
})
export class SpaPrefixedModule {}

A request for /app/assets/main.js returns 200 with Cache-Control: public, max-age=3600, /app/books/1 returns index.html, and / returns 404 because nothing is mounted at the root.

Fastify

With the Fastify adapter the module uses @fastify/static (a peer dependency, version 10 for @nestjs/serve-static 12). The fallback differs: serveStaticOptions.fallthrough must be true for the SPA fallback, otherwise an unknown route returns a 404 instead of index.html. In @nestjs/serve-static 12.0 the Fastify loader does not read exclude; routes declared by controllers are matched before the wildcard, so an existing API route is unaffected, but an unknown API path still gets index.html on Fastify.

spa-fastify.module.ts
@Module({
  imports: [
    ServeStaticModule.forRoot({
      rootPath: join(import.meta.dirname, 'client'),
      serveStaticOptions: { fallthrough: true },
    }),
  ],
  controllers: [HealthController],
})
export class SpaFastifyModule {}

Testing the SPA module

ServeStaticModule chooses its Express or Fastify loader when its providers are created, and the HTTP adapter exists at that moment only if the application is created with NestFactory.create(). In a test built with Test.createTestingModule().compile() and createNestApplication() the adapter is not yet available when providers are instantiated, and the module silently serves nothing, so create the test application with NestFactory:

spa.e2e-spec.ts (excerpt)
const app = await NestFactory.create<NestExpressApplication>(SpaModule, { logger: false });
await app.init();

await request(app.getHttpServer()).get('/books/42').expect(200).expect(/Bookshelf SPA/);
await request(app.getHttpServer()).get('/api/health').expect(200).expect({ status: 'ok' });

This behaviour is not documented; the module’s options objects are also mutated when applied (the default renderPath is written back to them), so build them fresh for each application rather than sharing one object between an Express and a Fastify application.

Compression

Compression reduces the size of the response body so pages load faster. The official recommendation is explicit: for high-traffic production websites, offload compression from the application server, typically to a reverse proxy such as Nginx, and then do not add compression middleware at all. Compression is CPU work on the same event loop that runs your handlers, and the proxy does it with native code, can cache compressed assets and can serve static files without touching Node.js. The in-app option is the right choice when there is no proxy (a small deployment, a development environment, a PaaS that does not compress) or when the proxy cannot do it.

flowchart TD Q["Is there a reverse proxy or CDN in front?"] -->|Yes| P["Compress there
do not add middleware"] Q -->|No| E{"Which adapter?"} E -->|Express| C["app.use(compression())"] E -->|Fastify| F["app.register(@fastify/compress)"]

Express

compression is Express middleware that supports gzip, deflate and Brotli and picks one from the request’s Accept-Encoding header.

npm install --save compression
npm install --save-dev @types/compression

It is applied globally with app.use(), ahead of the routes:

compression.ts (Express)
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import compression from 'compression';

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.use(compression());

A request that accepts gzip gets a compressed body and the response carries Vary: Accept-Encoding; a request with Accept-Encoding: identity gets the plain body:

curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' localhost:3000/big
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding

Middleware registered with app.use() runs before guards and interceptors (see Middleware), so it sees and compresses every response, including error bodies.

Fastify

With the FastifyAdapter, use the @fastify/compress plugin. It is registered on the application, which is why the application must be typed as NestFastifyApplication; otherwise register() is not available. By default it compresses every eligible response.

npm install --save @fastify/compress
compression.ts (Fastify)
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compression from '@fastify/compress';

const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
await app.register(compression);

The plugin uses the first encoding the client accepts, in this order of preference: zstd (on Node.js versions that support it), br (Brotli), gzip and deflate. With the plugin registered and a large JSON body, the encoding chosen for each Accept-Encoding header is:

Accept-Encoding Content-Encoding chosen

gzip, deflate, br

br

gzip, deflate

gzip

zstd, br, gzip

zstd

identity

none

Brotli compresses best but its higher quality levels are slow, so @fastify/compress sets the Brotli quality to 4 by default, instead of the zlib default of 11. The BROTLI_PARAM_QUALITY parameter accepts 0 (fastest) to 11 (smallest output); tune it for your workload.

Setting the Brotli quality explicitly
import { constants } from 'node:zlib';

await app.register(compression, {
  brotliOptions: { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } },
});

To trade size for speed, restrict the plugin to gzip and deflate with the encodings option; gzip is preferred when the client supports both, and a client that accepts br, gzip receives gzip:

Restricting the encodings
await app.register(compression, { encodings: ['gzip', 'deflate'] });

Pitfalls

  • Double compression. If a proxy compresses as well, the application should not: remove the middleware or plugin there. Typical proxies skip a response that already has a Content-Encoding, but compressing twice wastes CPU.

  • Already-compressed content. Images, videos and archives gain nothing from gzip; leave them to the static server or proxy rules.

  • Security. Compressing responses that mix a secret with attacker-controlled text over TLS has been exploited by BREACH-style attacks; pages that echo tokens should use per-response masking or not be compressed.

What changed recently, and where older material is outdated

  • Express 5 syntax. The default SPA render path is {any} and exclusions use {*any} too; older examples with (.) or * fail to compile under Express 5’s path-to-regexp.

  • ESM. The current docs examples use import.meta.dirname and top-level await; Nest 12 packages are ESM-only and the default template is ESM (CommonJS differences are limited to __dirname and the bootstrap() call).

  • @nestjs/serve-static 12.0 requires Express 5 and @fastify/static 10 as peer dependencies.

  • @fastify/compress now prefers zstd where Node.js supports it, ahead of Brotli, gzip and deflate, and defaults Brotli to quality 4.

  • The compression package is the same Express middleware as in older material; what changed is that the official page now recommends doing compression in a reverse proxy for high-traffic production sites.

  • The book (published January 2025) predates these changes. Where it shows MVC, serve-static or compression, check its wildcard paths against Express 5, its __dirname use against the ESM template and its encoding assumptions against the @fastify/compress defaults above.