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:

flowchart LR P["Global prefix
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.

Controllers for the two versions of the Bookshelf books API, plus a version-neutral health endpoint
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.

URI versioning in main.ts
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();
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.

Changing the URI prefix
app.enableVersioning({ type: VersioningType.URI, prefix: 'ver' }); // /ver1/books
app.enableVersioning({ type: VersioningType.URI, prefix: false }); // /2/books

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.

Header versioning
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=.

Media type versioning
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.

A custom extractor that reads a list of acceptable versions (Fastify)
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.

A single-version extractor that works on both Express and Fastify
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. The path and version options 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, as featured() 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 answers 404.

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.

An array of versions on a whole controller
@Controller({ path: 'authors', version: ['1', '2'] })
export class AuthorsController {
  @Get()
  findAll() {
    return [{ id: 7, name: 'Frank Herbert' }];
  }
}

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.

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.

Default versions
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.

Applying a middleware only to version 1 of GET /books
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.

A global prefix
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');

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.

A shared configuration function for main.ts and e2e tests
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 });
}

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.

Excluding routes from the prefix
import { RequestMethod } from '@nestjs/common';

app.setGlobalPrefix('api', {
  exclude: [{ path: 'health', method: RequestMethod.GET }],
});

// or, for every HTTP method:
app.setGlobalPrefix('api', { exclude: ['health'] });

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.

Wildcard exclusions
app.setGlobalPrefix('api', { exclude: ['health/*splat'] });   // /health/status, not /health
app.setGlobalPrefix('api', { exclude: ['health/{*splat}'] }); // /health and /health/status

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.

A dashboard module under a path
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.

Nested module paths
@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() and RouterModule behave 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 (Request from Express, FastifyRequest from 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 (/v1/books)

Visible, trivially testable with curl or a browser, cache- and log-friendly, easy to route in a gateway.

The version becomes part of the resource’s identity; clients must change URLs to upgrade.

Header (X-API-Version: 2)

Stable URLs; the version stays out of links.

Invisible in logs and browsers unless added; caches must vary on the header (Vary).

Media type (Accept: application/json;v=2)

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. After enableVersioning(), every controller needs a version, a VERSION_NEUTRAL marker or a defaultVersion. 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 the version option instead.

  • Ignoring the prefix in tests. A test that does not call the same setGlobalPrefix() and enableVersioning() as main.ts hits 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/*splat does 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 (with prefix: false it is /1/books).

  • Documentation of the global prefix and of versioning moved with the Sep 2026 documentation restructure to /faq/global-prefix and /http/versioning (versioning is under the HTTP group; older write-ups may link to a /techniques/ path).

  • Bare wildcard asterisks in exclude paths and in middleware routes are deprecated in the current documentation (the path-to-regexp 8 syntax); use :param or 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 .js extensions 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.