Published Updated on 6 min read
API Versioning in NestJS: URI, Header and Media Type
NestJS has built-in API versioning: call app.enableVersioning with URI, header, media type or custom extraction, then mark controllers or single routes with a version. URI versioning such as /v1/users is the simplest and most cache friendly choice for public APIs. Version only breaking changes, keep old versions thin, and announce removals with Deprecation and Sunset headers.
By Sami Ullah · #nestjs #nodejs
API versioning is a way to change your API's contract without breaking clients that depend on the old one, by serving more than one version of a route at the same time. NestJS has it built in since version 8. In NestJS 11 you enable it with one call in main.ts and mark controllers or individual handlers with the version they belong to.
This post covers the four strategies Nest supports, how I organise versioned controllers so the old code does not rot, and how to deprecate a version properly. Versions: NestJS 11, Node.js 22, TypeScript strict (tested Nov 2026). The NestJS versioning docs are the primary reference.
Which versioning strategies does NestJS support?#
NestJS supports four: URI, header, media type and custom. They differ only in where the version number is read from; the controller code is the same for all of them.
| Strategy | Example request | Pros | Cons |
|---|---|---|---|
| URI | GET /v2/users | Visible, cacheable, works everywhere | Version in every URL, bookmarks break on bump |
| Header | GET /users with X-API-Version: 2 | Clean URLs | Invisible in logs and browser, CDNs need Vary |
| Media type | Accept: application/json;v=2 | Follows HTTP content negotiation | Clients get it wrong, hard to test by hand |
| Custom | Anything you parse | Fits odd legacy contracts | You own the parsing logic and its bugs |
I default to URI versioning for anything public. When a mobile app reports a bug, the URL in the crash log already tells me which version it hit. For internal service-to-service APIs, header versioning is fine because I control every client.
How do I enable URI versioning in NestJS?#
Call app.enableVersioning({ type: VersioningType.URI }) in main.ts. Nest then prefixes versioned routes with v and the version number.
// src/main.ts
import { VersioningType } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api");
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: "1",
});
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();With a global prefix of api, a controller at users now answers at /api/v1/users. The defaultVersion applies to any controller that does not declare a version, which is how I add versioning to an existing API without touching every file. Existing routes become v1 automatically.
One gotcha when adding this to a live API: old clients still call /api/users, which now returns 404. Either keep a short-lived redirect at the proxy, or add VERSION_NEUTRAL to the default list during the transition, as shown further down.
How do I mark a controller or route with a version?#
Pass version in the @Controller options for a whole controller, or use @Version() on a single handler. A handler-level version overrides the controller's.
// src/users/users.controller.ts
import { Controller, Get, Param, ParseUUIDPipe, Version } from "@nestjs/common";
import { UsersService } from "./users.service";
import { UserV1Dto, UserV2Dto } from "./dto/user.dto";
@Controller({ path: "users", version: "1" })
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get(":id")
async findOneV1(@Param("id", ParseUUIDPipe) id: string): Promise<UserV1Dto> {
const user = await this.users.findById(id);
return UserV1Dto.from(user);
}
@Version("2")
@Get(":id")
async findOneV2(@Param("id", ParseUUIDPipe) id: string): Promise<UserV2Dto> {
const user = await this.users.findById(id);
return UserV2Dto.from(user);
}
}// src/users/dto/user.dto.ts
export interface UserRecord {
id: string;
firstName: string;
lastName: string;
email: string;
}
export class UserV1Dto {
id!: string;
name!: string; // v1 sent a single combined name
email!: string;
static from(u: UserRecord): UserV1Dto {
return { id: u.id, name: `${u.firstName} ${u.lastName}`, email: u.email };
}
}
export class UserV2Dto {
id!: string;
firstName!: string;
lastName!: string;
email!: string;
static from(u: UserRecord): UserV2Dto {
return { id: u.id, firstName: u.firstName, lastName: u.lastName, email: u.email };
}
}The pattern that keeps this maintainable: one service, many response mappers. The business logic lives once in UsersService. Each version is a thin DTO that maps the current data shape to the contract that version promised. When v1 is finally removed, I delete a handler and a DTO class, and nothing else changes.
The pattern I avoid is copying the whole controller and service into a v2 folder. Within a few months, bug fixes land in one copy and not the other.
A controller can also serve several versions with the same handler: @Controller({ path: "orders", version: ["1", "2"] }). I use this when v2 changed one route in a controller and the rest stayed identical.
What is VERSION_NEUTRAL for?#
VERSION_NEUTRAL marks a route that answers regardless of which version the client asks for, or when no version is sent at all. Health checks, webhooks from third parties and OAuth callbacks are the usual cases.
// src/health/health.controller.ts
import { Controller, Get, VERSION_NEUTRAL } from "@nestjs/common";
@Controller({ path: "health", version: VERSION_NEUTRAL })
export class HealthController {
@Get()
check() {
return { status: "ok" };
}
}With URI versioning, this route answers at /api/health, without the v1 segment. That suits load balancer health checks and provider webhooks, whose URLs you configure once in someone else's dashboard and do not want to change on every version bump.
You can also set defaultVersion: ["1", VERSION_NEUTRAL] during a migration, so unversioned legacy calls keep working while new clients move to /v1.
How do I use header or media type versioning?#
Change the type in enableVersioning and tell Nest where to read the version from. Controllers stay exactly the same.
// Header versioning: X-API-Version: 2
app.enableVersioning({
type: VersioningType.HEADER,
header: "X-API-Version",
defaultVersion: "1",
});
// Media type versioning: Accept: application/json;v=2
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: "v=",
defaultVersion: "1",
});For header and media type versioning, put the version header into your CDN or reverse proxy cache key, or send Vary: X-API-Version (or Vary: Accept). Otherwise a cache can serve a v1 response to a v2 client, which is a confusing bug because the URL is identical in both requests.
Custom versioning takes an extractor function that receives the request and returns a version string or an array of them, most preferred first. A typical case is a partner integration that sends the version as a query parameter.
import type { Request } from "express";
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor: (req: unknown): string => {
const v = (req as Request).query["api-version"];
return typeof v === "string" ? v : "1";
},
});How do I deprecate an old API version?#
Announce it in the response itself with the Deprecation and Sunset HTTP headers, log which clients still call it, and remove it only after the sunset date. An interceptor applied to the old handlers does the first part.
// src/common/deprecation.interceptor.ts
import { CallHandler, ExecutionContext, Logger, NestInterceptor } from "@nestjs/common";
import type { Request, Response } from "express";
import { Observable } from "rxjs";
export class DeprecationInterceptor implements NestInterceptor {
private readonly logger = new Logger("Deprecation");
constructor(
private readonly deprecatedAt: Date,
private readonly sunset: Date,
private readonly link: string,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<Request>();
const res = http.getResponse<Response>();
// RFC 9745: a structured-field date, "@" followed by Unix seconds.
res.setHeader("Deprecation", `@${Math.floor(this.deprecatedAt.getTime() / 1000)}`);
res.setHeader("Sunset", this.sunset.toUTCString());
res.setHeader("Link", `<${this.link}>; rel="deprecation"`);
this.logger.warn(`${req.method} ${req.originalUrl} from ${req.get("user-agent") ?? "unknown"}`);
return next.handle();
}
}
// usage on a v1 handler:
// @UseInterceptors(new DeprecationInterceptor(new Date("2027-06-30"), new Date("2027-12-31"), "https://example.com/docs/v2-migration"))The Sunset header is defined in RFC 8594, and the Deprecation header in RFC 9745, which uses a date such as @1782777600 rather than the older true value from early drafts. Few clients read them automatically, but they show up in API tools and in any integration test that checks headers, and they give you something concrete to point to when a client says nobody warned them.
The log line matters more than the headers. Before I remove a version, I want a week of logs showing zero calls, or a short list of named clients I can contact directly.
Do I need to version every change?#
No. Most changes should ship in the current version, because additive changes do not break well-written clients. Version bumps are for breaking changes only.
Here is how I sort changes:
- Not breaking, ship in place: a new endpoint, a new optional request field, a new response field, a new enum value that clients are told to tolerate, a performance fix.
- Breaking, needs a new version: removing or renaming a field, changing a field's type (a number id becoming a string UUID), making an optional input required, changing the meaning of a status code, changing pagination from offset to cursor.
- Grey area, decide per client: a new enum value when you know a mobile client uses a strict switch statement. If you control the client, coordinate a release. If you do not, treat it as breaking.
This only works if clients ignore unknown fields. I say that explicitly in API docs, and on the frontend side I make sure parsers such as zod schemas strip unknown keys rather than fail on them.
How do versions show up in Swagger docs?#
@nestjs/swagger picks up the versioned paths automatically, so URI-versioned routes appear as /api/v1/users and /api/v2/users in one document. For large APIs I generate one document per version by passing the version's modules to the include option of SwaggerModule.createDocument, which only works if each version lives in separate modules. With the one-controller pattern above, a single document with both paths is usually clear enough.
For a full API built on the same NestJS conventions, with roles and guards on top, see the NestJS REST API with RBAC project. You can browse its endpoints and try them with demo accounts in the Swagger UI of my NestJS REST API demo. More on how I build backends is on the NestJS skill page and the Node.js skill page.
What should you do first?#
If your API has no versioning yet, add app.enableVersioning with URI type and defaultVersion: "1" today, before you need it. Every existing route becomes v1 with no controller changes, and the next breaking change becomes a single @Version("2") handler plus a DTO instead of a coordinated release across every client. Add VERSION_NEUTRAL to your health check and webhook controllers in the same commit.
Frequently asked questions
Which API versioning strategy is best in NestJS?
For most public REST APIs, URI versioning is the safest choice. The version is visible in logs, works in a browser address bar, caches correctly at CDNs without extra Vary headers, and every HTTP client supports it. Header or media type versioning keeps URLs clean and suits internal APIs with controlled clients. NestJS supports all of them with the same controller decorators, so switching later only changes main.ts.
When should I create a new API version?
Only for breaking changes: removing or renaming a field, changing a field's type or meaning, making an optional input required, or changing status codes clients rely on. Adding a new optional field, a new endpoint or a new optional query parameter is not breaking, so ship it in the current version. Bumping versions for additive changes multiplies the code you maintain without helping any client.
How do I version only some routes in NestJS?
Apply the version at the controller level for the default, then override single handlers with the @Version decorator. A controller marked version 1 can have one route marked version 2 alongside its version 1 sibling. For routes that should answer every version, such as health checks or webhooks, use VERSION_NEUTRAL. Setting defaultVersion in enableVersioning covers controllers that declare no version at all.
How long should I keep an old API version running?
Long enough for every known client to move, with a date announced in advance. For a public API, six to twelve months after the replacement ships is common. For a mobile app you control, it depends on how many users still run old builds, which your analytics can tell you. Send Deprecation and Sunset headers on every old response and log which clients still call it.
Where I used this
- Case study: NestJS REST API with Auth, RBAC and Swagger · ▶ live demo
- Case study: GraphQL API with NestJS · ▶ live demo
- Skill: Node.js
- Skill: NestJS