Versioning & Global Prefix
|
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. |
An HTTP API has to change without breaking the clients that already call it. NestJS gives you three tools for the URL and
negotiation side of that problem: versioning (several versions of a controller or a route live in one application),
the global prefix (one path segment in front of every route, such as /api) and the RouterModule (a prefix per
module). This page is written against NestJS 12.1 (@nestjs/core and @nestjs/common 12.1.2 on npm), Express 5 (the
default platform) or Fastify 5, TypeScript 6 and Node.js 20.19+ or 22.12+, with the versions and the official
documentation checked on 2026-10-02. All three features apply only to HTTP applications; they do nothing for
microservices or WebSocket gateways. Every example below targets NestJS 12.1.2 with the Express adapter (the custom-extractor
example uses Fastify), tested with Vitest and supertest.
The running scenario is the Bookshelf API from the other pages of this section: a books resource whose first version
returns a bare array and whose second version wraps the list in an envelope with a meta object.
URL anatomy: prefix, version, module path, controller path
When all three features are active, Nest builds a route path by concatenating, from left to right:
setGlobalPrefix('api')"] --> V["Version (URI type only)
v1, v2"] V --> R["RouterModule path
admin/users"] R --> C["Controller path
@Controller('users')"] C --> H["Route path
@Get(':id')"]
A route that is excluded from the global prefix and a route whose version is VERSION_NEUTRAL simply skip that
segment. The other versioning types (header, media type, custom) never put the version into the path, so for them the
path is just prefix, module path, controller path and route path. The official documentation states the order for URI
versioning: the version is added after the global prefix and before the controller and route paths. The tests behind this
page confirm that the module path of a RouterModule comes after the version, for example /api/v1/admin/users/users.
Enabling versioning
Versioning is switched on with app.enableVersioning() on the application instance, normally in main.ts. The type
property selects one of four strategies; the VersioningType enum is exported by @nestjs/common. Until you call
enableVersioning(), version metadata on controllers and routes has no effect on routing.
import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common';
@Controller({ path: 'books', version: '1' })
export class BooksV1Controller {
@Get()
findAll() {
return [{ id: 1, title: 'Dune', author: 'Frank Herbert' }];
}
}
@Controller({ path: 'books', version: '2' })
export class BooksV2Controller {
@Get()
findAll() {
return {
data: [{ id: 1, title: 'Dune', authorId: 7 }],
meta: { total: 1 },
};
}
// Route-level version overrides the controller-level one.
@Version(['2', '3'])
@Get('featured')
featured() {
return { title: 'Dune' };
}
}
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}
Source: Versioning
The next four sections enable each versioning type for this same module, without changing the controllers. That is the point of the feature: the way a client states its version is an application-level choice, while the controllers only declare which versions they implement.
URI versioning
URI versioning puts the version in the path and is the default type documented for enableVersioning(). The version
is prefixed with v automatically, so it appears as /v1/books, not /books/v1.
import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableVersioning({
type: VersioningType.URI,
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Source: Versioning: URI versioning type
curl -s localhost:3000/v1/books
curl -s localhost:3000/v2/books
[{ "id": 1, "title": "Dune", "author": "Frank Herbert" }]
{ "data": [{ "id": 1, "title": "Dune", "authorId": 7 }], "meta": { "total": 1 } }
A request for /books/v1, /books, or a version that no controller implements (/v3/books) answers 404. The prefix
option changes or removes the leading v: prefix: 'ver' serves /ver1/books and prefix: false serves /2/books.
app.enableVersioning({ type: VersioningType.URI, prefix: 'ver' }); // /ver1/books
app.enableVersioning({ type: VersioningType.URI, prefix: false }); // /2/books
Source: Versioning: URI versioning type
Header versioning
Header versioning keeps the URL stable and reads the version from a request header whose name you choose. The header
property is the name of that header.
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
});
curl -s localhost:3000/books -H 'X-API-Version: 2'
A request without the header does not match any versioned route and answers 404, unless you set a default version
(see below). HealthController, being version neutral, answers regardless of the header.
Media type versioning
Media type versioning reuses the Accept header. The version follows the media type after a semicolon, as in
Accept: application/json;v=2. The key property holds both the key and the separator, so for that header it is v=.
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=',
});
curl -s localhost:3000/books -H 'Accept: application/json;v=2'
This style keeps URLs as resource identifiers and moves the version into content negotiation, at the price of being
harder to try from a browser address bar and to cache by URL alone. A plain Accept: application/json carries no
version and answers 404 unless there is a default version.
Custom versioning
Custom versioning accepts any aspect of the request. You provide an extractor function that receives the platform
request object and returns a version string or an array of version strings. If the client may support several versions,
the extractor must return them sorted from highest to lowest, because Nest tries them in that order. If the extractor
returns an empty string or an empty array, no route matches and the answer is 404.
import { VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import type { FastifyRequest } from 'fastify';
import { AppModule } from './app.module.js';
const extractor = (request: FastifyRequest): string | string[] =>
[request.headers['x-api-versions'] ?? '']
.flatMap((v) => v.split(','))
.filter((v) => !!v)
.sort()
.reverse();
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor,
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
curl -s localhost:3000/books -H 'X-API-Versions: 1,2,3'
Here the request supports versions 1, 2 and 3, the extractor returns ['3', '2', '1'], only versions 1 and 2 have
routes, and version 2 is served. With the Express adapter, the documentation says selecting the highest match from a
multi-element array does not work reliably because of design limitations of that adapter, so with Express make the
extractor return a single version (a string, or an array with one element). Use Fastify if you need the highest-match
behaviour.
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor: (req: { headers: Record<string, string | string[] | undefined> }) =>
String(req.headers['x-api-version'] ?? ''),
});
Applying versions: controllers, routes and arrays
Versioning is declared the same way whichever type the application uses.
-
@Controller({ version: '1' })sets the version of every route in the controller. Thepathandversionoptions live in the same object, as in@Controller({ path: 'books', version: '1' })above. -
@Version('2')on a handler sets the version of that route and overrides the controller’s version, asfeatured()does above. -
An array declares several versions at once:
@Controller({ version: ['1', '2'] })or@Version(['2', '3']). One implementation then answers to every listed version. -
If versioning is enabled and a controller or route declares no version (and no default version is set), requests to it answer
404. A request for a version without a matching route also answers404.
Both the exact version strings and the arrays behave as expected: /v2/books/featured and
/v3/books/featured answer 200, while /v1/books/featured answers 404 because the route-level @Version(['2', '3'])
replaces the controller’s '2' rather than adding to it.
@Controller({ path: 'authors', version: ['1', '2'] })
export class AuthorsController {
@Get()
findAll() {
return [{ id: 7, name: 'Frank Herbert' }];
}
}
Source: Versioning: Multiple versions
Version neutral routes: VERSION_NEUTRAL
Some endpoints behave identically in every version, such as a health check. Set the version to the VERSION_NEUTRAL
symbol exported by @nestjs/common. A request reaches a neutral controller or route whatever version it carries,
including no version at all. With URI versioning a neutral resource has no version segment in its URL, so
HealthController above answers at /health, and /v1/health does not exist.
Source: Versioning: Version Neutral
A default version: defaultVersion
If most controllers should share a version, setting it on each one is noise. The defaultVersion option of
enableVersioning() applies to every controller and route that does not declare its own version. It accepts a single
version, an array of versions, or VERSION_NEUTRAL.
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
// or: defaultVersion: ['1', '2'],
// or: defaultVersion: VERSION_NEUTRAL,
});
With the books controllers above (they declare versions explicitly, so this exercises the option rather than the
fallback): defaultVersion: '1' leaves /v1/books at 200; ['1', '2'] leaves /v2/books at
200; VERSION_NEUTRAL removes the version segment from the URLs of unversioned controllers, and the explicitly
versioned controllers keep their own segments, so /books answers 404 for them.
A common migration pattern is to give every existing controller no version at all, set defaultVersion: '1' and add only
the new v2 controllers: legacy clients keep calling /v1/… and nothing in the old controllers changes.
Versioning middleware
Middleware can target a specific version of a route. In the route object given to MiddlewareConsumer.forRoutes(), set
version next to path and method. The middleware then runs only for requests that Nest resolves to that version,
whichever versioning type is active. This is a handy place for a deprecation logger or a Sunset header for an old
version, and the general mechanics (classes, functional middleware, ordering) are on Middleware.
import {
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { BooksV1Controller, BooksV2Controller } from './books.controller.js';
export function deprecationLogger(
req: Request,
_res: Response,
next: NextFunction,
) {
console.warn(`deprecated version called: ${req.method} ${req.originalUrl}`);
next();
}
@Module({ controllers: [BooksV1Controller, BooksV2Controller] })
export class BooksModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(deprecationLogger)
.forRoutes({ path: 'books', method: RequestMethod.GET, version: '1' });
}
}
A request to /v1/books runs the middleware and a request to /v2/books does not.
The global prefix
setGlobalPrefix() on the application instance puts one path segment in front of every route registered by the
application. A leading slash is not needed.
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
Source: Global path prefix
With URI versioning the final URL is /api/v1/books, in line with the order described at the top of the page. Call
setGlobalPrefix() before app.listen() (and before app.init() in tests). In an e2e test that builds the application
from Test.createTestingModule(), the prefix and versioning are not applied unless the test calls the same methods on
the application it creates, so put them in a shared function that both main.ts and the tests call.
import { INestApplication, RequestMethod, VersioningType } from '@nestjs/common';
export function configureHttp(app: INestApplication) {
app.setGlobalPrefix('api', {
exclude: [{ path: 'health', method: RequestMethod.GET }],
});
app.enableVersioning({ type: VersioningType.URI });
}
Source: Global path prefix
Excluding routes: the exclude option
Health probes, metrics endpoints and OAuth callbacks often have to stay where external systems expect them. The second
argument of setGlobalPrefix() takes an exclude array. Each element is either a route object with path and method,
or a plain string, which excludes that path for every HTTP method.
import { RequestMethod } from '@nestjs/common';
app.setGlobalPrefix('api', {
exclude: [{ path: 'health', method: RequestMethod.GET }],
});
// or, for every HTTP method:
app.setGlobalPrefix('api', { exclude: ['health'] });
Source: Global path prefix
With the first form and HealthController from earlier, GET /health answers 200, GET /api/health answers 404, and
the books routes live at /api/v1/books. Both forms behave the same. The exclude array has been part of this option since
before NestJS 12; it is not the new thing in 12.1 (see the next section).
The path of an excluded route supports path-to-regexp parameters. Bare asterisks are deprecated in favour of named
wildcards, and a named wildcard needs at least one extra segment, as the documentation says: with exclude: ['health/*splat'], /health/status is excluded but /health is not (it stays under /api/health);
with the braced form exclude: ['health/{*splat}'], both /health and /health/status are excluded.
app.setGlobalPrefix('api', { exclude: ['health/*splat'] }); // /health/status, not /health
app.setGlobalPrefix('api', { exclude: ['health/{*splat}'] }); // /health and /health/status
Source: Global path prefix
The same convention for named wildcards appears in the middleware routes of Middleware and in the route paths on Controllers and Routing.
Several global prefixes (12.1, and a caveat)
The v12.1.0 release notes list feat(core): add support for array of global prefixes (pull request nestjs/nest#17713),
so that app.setGlobalPrefix(['api', 'v1']) registers every route under each prefix. This replaces the pattern
(prefixOne|prefixTwo) that stopped working when path-to-regexp 8 arrived with NestJS 11. At the time of writing
the feature is not usable in the published packages and not documented: with @nestjs/core and
@nestjs/common 12.1.2 from npm, the typing of setGlobalPrefix() is still (prefix: string, options?: GlobalPrefixOptions),
and at run time a call with an array answers 404 for /api/…, for /v1/… and for a literal /api,v1/…. The
published 12.1.0 and 12.1.2 tarballs do not contain the getGlobalPrefixes() method that the pull request adds, and the
documentation page for it (docs.nestjs.com pull request #3531) was still open on 2026-10-02. Treat the array form as
unavailable until a release both ships and documents it; check the release notes of your installed version. Until then,
use a single prefix and serve a second base path with a separate version or a RouterModule path, or let a reverse proxy
rewrite the second path.
RouterModule: module-level prefixes
When a whole area of the API shares a prefix, repeating it in every @Controller() is noisy and easy to get wrong.
RouterModule (exported by @nestjs/core) assigns a path to a module; every controller registered in that module,
and in its children, receives that path as an extra prefix. Routes are described in RouterModule.register() with
path and module, and the module must also be imported in the usual way for its controllers to load.
import { Module } from '@nestjs/common';
import { RouterModule } from '@nestjs/core';
@Module({
imports: [
DashboardModule,
RouterModule.register([
{
path: 'dashboard',
module: DashboardModule,
},
]),
],
})
export class AppModule {}
Source: Router module
A StatsController declared as @Controller('stats') in DashboardModule is then served at /dashboard/stats.
Hierarchies use children: a child module inherits the prefix of its parent and the paths are concatenated from the top
down.
@Module({
imports: [
AdminModule,
UsersModule,
PoliciesModule,
RouterModule.register([
{
path: 'admin',
module: AdminModule,
children: [
{ path: 'users', module: UsersModule },
{ path: 'policies', module: PoliciesModule },
],
},
]),
],
})
export class AppModule {}
Source: Router module
With @Controller('users') in UsersModule this serves /admin/users/users, and with setGlobalPrefix('api') plus URI
versioning with defaultVersion: '1' it serves /api/v1/admin/users/users, which is the order drawn in the diagram at the
top. The official page advises using the feature carefully because overusing it makes code hard to maintain: the URL of a
handler is then spread across the @Controller() argument, the module hierarchy and the application setup. A good
compromise is one RouterModule array in a dedicated routes.ts file that lists every area once. See
Modules for how modules are declared and imported.
Express versus Fastify
-
enableVersioning(),setGlobalPrefix()andRouterModulebehave the same on both adapters for URI, header and media-type versioning; the choice only matters for custom extractors, which receive the platform’s request object (Requestfrom Express,FastifyRequestfrom Fastify). -
The documentation warns that selecting the highest matching version from a multi-element array returned by a custom extractor does not reliably work on Express; Fastify supports it. See Fastify and HTTP Platforms for the other differences between the adapters.
-
The versioning middleware example imports its types from
express; with Fastify the middleware receives the raw Node request and response objects by default, so type it accordingly.
Choosing a versioning strategy and a deprecation policy
| Strategy | Strengths | Weaknesses |
|---|---|---|
URI ( |
Visible, trivially testable with |
The version becomes part of the resource’s identity; clients must change URLs to upgrade. |
Header ( |
Stable URLs; the version stays out of links. |
Invisible in logs and browsers unless added; caches must vary on the header ( |
Media type ( |
Fits content negotiation; one URL per resource. |
Harder for clients and tooling; the same caching concern. |
Custom |
Any signal: a tenant setting, a token claim, a query parameter. |
You own the semantics and the documentation; Express supports one version only. |
Versions are expensive to maintain, so decide a policy before shipping the second one. Typical elements are: add
backwards-compatible changes (new optional fields, new endpoints) to the current version without bumping it; reserve a new
version for breaking changes; announce a sunset date for the old version, send a Deprecation or Sunset header from a
version-scoped middleware like the one above, and remove the old controllers when the date passes. Shared logic belongs in
providers so that BooksV1Controller and BooksV2Controller are thin mappers over the same service. For the broader
discussion of evolving a backend over time see Decisions and Migrations.
Versioning and OpenAPI
Generated API documentation has to know the versions as well, otherwise a single document lists /v1/books and
/v2/books together. OpenAPI and Swagger covers @nestjs/swagger;
check there how versioned routes appear in the generated document.
Pitfalls
-
Forgetting that unversioned means
404. AfterenableVersioning(), every controller needs a version, aVERSION_NEUTRALmarker or adefaultVersion. Adding versioning to an existing application without a default version turns all existing routes into `404`s. -
Adding the version to the controller path by hand.
@Controller('v1/books')works without versioning, but combined with URI versioning the version segment is added in front of it, so the URL no longer matches what you wrote. Use theversionoption instead. -
Ignoring the prefix in tests. A test that does not call the same
setGlobalPrefix()andenableVersioning()asmain.tshits different URLs than production. -
Using the book’s URL shape. The URL for URI versioning is
/v1/books, not/books/v1; a client or a gateway rule written for the latter never matches. -
Excluding a wildcard that also has to match the bare path.
health/*splatdoes not match/health; use the braced form when both are meant. -
Expecting the array prefix on a published release. See the note above.
Where the book is outdated and what changed recently
-
The book shows URI versioning producing
books/v1. In NestJS the version is added before the controller and route paths, after the global prefix, so the URL is/v1/books(withprefix: falseit is/1/books). -
Documentation of the global prefix and of versioning moved with the Sep 2026 documentation restructure to
/faq/global-prefixand/http/versioning(versioning is under the HTTP group; older write-ups may link to a/techniques/path). -
Bare wildcard asterisks in
excludepaths and in middleware routes are deprecated in the current documentation (the path-to-regexp 8 syntax); use:paramor named wildcards such as*splat, with braces when the bare path must match too. -
The v12.1.0 release lists support for an array of global prefixes (see above); it is not yet in the published packages as of 2026-10-02.
-
The imports in all examples use
.jsextensions because the NestJS 12 template is ESM; in the CommonJS template (Jest) the same code works with extension-less imports. -
NestJS 12 stays on Express 5 by default, where custom-versioning extractors keep the single-version limitation noted above.