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.
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.
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();
Source: NestJS: Model-View-Controller
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.
<h1>{{ title }}</h1>
<ul>
{{#each books}}
<li>{{ this.title }} by {{ this.author }}</li>
{{/each}}
</ul>
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.
app.setLocal('layout', 'layouts/app');
app.useStaticAssets(join(import.meta.dirname, '..', 'public'));
app.setBaseViewsDir(join(import.meta.dirname, '..', 'views'));
app.setViewEngine('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()).
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: [] });
}
}
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.
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():
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.
app.setViewEngine({
engine: { handlebars: Handlebars },
templates: join(import.meta.dirname, '..', 'views'),
layout: 'layouts/app.hbs',
includeViewExtension: true,
});
Source: @fastify/view README
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.
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 {}
Source: NestJS: Serve static - Bootstrap
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.
@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.
@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:
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' });
Source: NestJS: Serve static
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.
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:
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
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 |
|---|---|
|
|
|
|
|
|
|
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.
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:
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’spath-to-regexp. -
ESM. The current docs examples use
import.meta.dirnameand top-levelawait; Nest 12 packages are ESM-only and the default template is ESM (CommonJS differences are limited to__dirnameand thebootstrap()call). -
@nestjs/serve-static12.0 requires Express 5 and@fastify/static10 as peer dependencies. -
@fastify/compressnow preferszstdwhere Node.js supports it, ahead of Brotli, gzip and deflate, and defaults Brotli to quality 4. -
The
compressionpackage 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
__dirnameuse against the ESM template and its encoding assumptions against the@fastify/compressdefaults above.
Source: NestJS: Migration guide