Skip to content
ExponaBLOG
NESTJS · DAY 082 / 100

API versioning in NestJS: a hands-on guide for engineers

Built-in versioning for evolving APIs.

Expona Admin7 min read

Why API versioning matters in production

You're running a mobile app that thousands of users have installed. A backend change breaks older clients because they expect a different response shape. A partner's webhook integration stops working because you renamed a field. These scenarios are painful, and they're preventable. API versioning in NestJS: a hands-on guide for engineers walks you through the built-in strategies that let you evolve your API safely while keeping existing clients working.

Versioning isn't about perfection; it's about control. It gives your team a runway to deprecate old endpoints, support multiple client versions simultaneously, and ship breaking changes without waking anyone at 3 AM. NestJS provides three clean mechanisms—URI, header, and media type versioning—each suited to different architectures and client ecosystems. Let's see how to wire them up and use them in anger.

Enable versioning at the application level

Before you can version any route or controller, you need to tell NestJS which versioning strategy you're using. This happens in your main.ts file during bootstrap. NestJS supports three types: URI versioning (routes like /v1/users), header versioning (a custom header like X-API-Version: 1), and media type versioning (version in the Accept header).

URI versioning is the most visible and easiest for clients to reason about. Header versioning keeps your URLs clean but requires clients to know about the header convention. Media type versioning is REST-purist but adds cognitive load on clients.

Here's how to enable URI versioning with a default version:

typescript
import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';

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

  app.enableVersioning({
    type: VersioningType.URI,
    defaultVersion: '1',
  });

  await app.listen(3000);
}

bootstrap();

The defaultVersion parameter is crucial. It determines which version handles requests that don't explicitly specify one. Without it, unversioned requests will fail. Setting it to '1' means /users gets routed the same way as /v1/users.

If you prefer header versioning instead, swap the type:

typescript
app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'X-API-Version',
  defaultVersion: '1',
});

Clients would then send X-API-Version: 2 in their request headers. This keeps your URL structure clean but requires better client documentation.

Version controllers or individual routes

Once versioning is enabled globally, you apply versions at either the controller or route level. A versioned controller applies the version to all its routes; a versioned route applies only to that endpoint. This granularity is powerful: you can version a single problematic route while keeping others unversioned.

Here's a controller versioned to v2:

typescript
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller({
  path: 'users',
  version: '2',
})
export class UsersControllerV2 {
  constructor(private usersService: UsersService) {}

  @Get()
  async findAll() {
    return this.usersService.findAllV2();
  }

  @Post()
  async create(@Body() createUserDto: any) {
    return this.usersService.createV2(createUserDto);
  }
}

With URI versioning, this controller's routes become GET /v2/users and POST /v2/users. If you have another UsersController without a version decorator, it handles GET /users and inherits the default version.

For finer control, decorate individual routes:

typescript
import { Controller, Get, Post, Version } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Get()
  @Version('1')
  findAllV1() {
    return this.usersService.findAllV1();
  }

  @Get()
  @Version('2')
  findAllV2() {
    return this.usersService.findAllV2();
  }

  @Post()
  @Version(['1', '2'])
  create(@Body() createUserDto: any) {
    return this.usersService.create(createUserDto);
  }
}

This pattern is crucial in production. The POST handler supports both v1 and v2 clients because the payload format didn't change; the GET handler branches because the response schema diverged. You specify versions as a string or an array—perfect for gradual rollouts.

Use VERSION_NEUTRAL for infrastructure endpoints

Not every endpoint needs versioning. Health checks, metrics, webhooks, and admin endpoints often live outside the versioning scheme. That's where VERSION_NEUTRAL comes in. It tells NestJS: "This route exists at every version and has no version prefix."

typescript
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get()
  @Version(VERSION_NEUTRAL)
  check() {
    return { status: 'ok', timestamp: Date.now() };
  }
}

With URI versioning, this endpoint lives at GET /health, not GET /v1/health or GET /v2/health. Load balancers, Kubernetes liveness probes, and monitoring tools can hit it without knowing about your versioning scheme.

Webhooks are another common use case. If you're posting events to partner systems, you don't want the URL to move every time you version your main API:

typescript
@Controller('webhooks')
export class WebhookController {
  @Post('user-created')
  @Version(VERSION_NEUTRAL)
  handleUserCreated(@Body() payload: any) {
    // Stable endpoint, no version in URL
    return { acknowledged: true };
  }
}

This approach reduces friction: your partners configure https://api.example.com/webhooks/user-created once and never worry about URLs changing.

Deprecate with care and intent

Versioning is only half the story. The hard part is deprecating old versions without breaking the world. A rushed removal of v1 while v1 clients are still active causes outages and erodes trust.

The deprecation process has four stages:

  1. Announce. Publish a changelog entry, email clients, or update your API documentation. Give a timeline: "v1 will be removed on March 1st, 2025."
  2. Add sunset headers. Include the Sunset and Deprecation headers in responses so automated tools can detect sunset dates.
  3. Monitor usage. Track how many requests hit v1 endpoints over time. Most clients migrate on their own; some need a nudge.
  4. Remove. Once usage is negligible, delete the old code. Keep the announcement up in your docs or changelog.

Here's a middleware that adds sunset headers to deprecated versions:

typescript
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class DeprecationMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const apiVersion = req.url.split('/')[1]; // Extract 'v1' from '/v1/users'

    if (apiVersion === 'v1') {
      res.setHeader('Deprecation', 'true');
      res.setHeader('Sunset', 'Sun, 01 Mar 2025 00:00:00 GMT');
      res.setHeader(
        'Link',
        '; rel="deprecation"'
      );
    }

    next();
  }
}

Apply this to your app:

typescript
app.use(DeprecationMiddleware);

Clients that parse these headers will log warnings and alert their teams. Tools like Postman and browser DevTools highlight deprecated endpoints. This shifts the burden away from you and toward the client teams, where it belongs.

Monitor usage in your logs or analytics. A log entry for each versioned request is cheap:

typescript
@Injectable()
export class VersionLoggingMiddleware implements NestMiddleware {
  constructor(private logger: Logger) {}

  use(req: Request, res: Response, next: NextFunction) {
    const version = req.url.split('/')[1];
    this.logger.log(`API request to ${version} from ${req.ip}`);
    next();
  }
}

After a month of near-zero traffic on v1, you can confidently remove it. Your team will thank you for the confidence.

Common mistakes

Forgetting the default version: Omitting defaultVersion means unversioned requests fail. Every endpoint must have a version, even if it's implicit.

Versioning too aggressively: Don't create a new version for every minor change. Batch breaking changes into a single version bump. Clients will update less often, and your codebase stays cleaner.

Leaving old versions forever: Every old version is a maintenance burden. Set a removal date upfront and stick to it. Clients who ignore deprecation warnings are choosing their own pain.

Mixing header and URI versioning: Pick one strategy and stick with it across your entire API. Mixing confuses clients and multiplies edge cases.

Versioning response formats inconsistently: If v1 returns { data: [...] } and v2 returns [...], document this clearly. Better yet, keep the wrapper consistent and only version the fields inside.

Key takeaways

  • Enable versioning at bootstrap with app.enableVersioning() and pick URI, header, or media type versioning based on your client ecosystem.
  • Version entire controllers with @Controller({ version: '2' }) or individual routes with @Version('1') or @Version(['1', '2']).
  • Use VERSION_NEUTRAL for health checks, webhooks, and infrastructure endpoints that shouldn't move.
  • Deprecate methodically: announce, add sunset headers, monitor usage, then remove. Rushing this step breaks trust and causes outages.
  • Batch breaking changes into version bumps; don't create a new version for every tweak. Clients will upgrade more willingly.
  • Version your API from day one, even if you never use it. Retrofitting versioning into a live API is painful and error-prone.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles