nestjs

API Versioning: The Line Between Junior and Senior Backend Engineering

Let me be direct.

Most developers don’t think about versioning until something breaks in production. An endpoint changes, a mobile app stops working, a client complains, and suddenly everyone is scrambling.

Versioning is not damage control. It’s architecture.

If you truly understand versioning and its application, you build robust APIs that evolve safely.

Let’s break it down properly, starting with what versioning really is.

1. What is Versioning (Really)?

At a surface level, versioning means assigning versions such as v1, v2, and v3 to your API.

But that definition is incomplete. Versioning is about managing change over time without breaking existing consumers. Think about this:

You have an endpoint:

GET /users

It returns:

{
"id": 1,
"name": "alif"
}

Now you decide to split the name into firstName and lastName.

{
"id": 1,
"firstName": "alif",
"lastName": "xyz"
}

Looks harmless.

But any client expecting a name will now break.

That’s where versioning comes in. Instead of changing the existing contract, you create a new one:

GET /v2/users

Old clients continue using v1. New clients move to v2. Nothing breaks. That’s versioning.

2. Why Versioning Matters

Here’s the uncomfortable truth:

Your API is not just code. It’s a contract.

And contracts cannot change arbitrarily.

Without versioning:

  • You deploy a change → clients break.
  • Third-party integrations fail silently.
  • Debugging becomes a nightmare.

With versioning:

  • You can iterate safely.
  • Clients upgrade on their own timeline.
  • You maintain backward compatibility.
  • You reduce production risk significantly.

A simple way to think about it:

Versioning is insurance for your API.

You might not need it today. But when you do, you really do.

3. Types of API Versioning

There isn’t just one way to version an API. There are multiple strategies, each with trade-offs.

Let’s go through the important ones.

3.1 URI Versioning (Most Common)

This is the one you’ve definitely seen:

/v1/users
/v2/users

Most production systems use this because it’s predictable and practical.

3.2 Header Versioning

2nd approach is to pass the version in the headers:

GET /users
Headers:
Accept: application/vnd.myapp.v1+json

or

x-api-version: 1

3.3 Query Parameter Versioning

GET /users?version=1

Why use it:

  • Easy to implement

Downside:

  • Feels hacky
  • Not ideal for long-term API design

3.4 Media Type Versioning

It is used in more strict REST systems.

Accept: application/vnd.myapp.v2+json

It’s such a powerful approach, but overkill for most applications.

So which one should you use?

If you want a practical answer:

Use URI versioning unless you have a strong reason not to.

It’s the most maintainable for teams and the easiest to communicate.

4. Versioning Strategy (This Is Where Most People Fail)

Versioning is not just about adding v1.

It’s about when and why you create a new version.

Here’s the rule most experienced teams follow:

Create a new version when:

  • You remove a field
  • You rename a field
  • You change the response structure.
  • You change the business logic behavior.

Don’t version for:

  • Adding optional fields
  • Adding new endpoints
  • Internal refactoring

If your change is backward compatible, don’t version. If it’s breaking, version it.

That’s the line.

5. How Versioning Works in NestJS

Now let’s make this practical. NestJS has built-in support for versioning, which is something many frameworks don’t handle cleanly. You don’t need hacks. You don’t need custom middleware. You just enable it.

Step 1: Enable Versioning

In your main.ts:

import { VersioningType } from '@nestjs/common';

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

That’s it. Versioning is now active.

Step 2: Define Versions in Controllers

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

@Controller('users')
export class UsersController {

  @Get()
  @Version('1')
  getUsersV1() {
    return [{ id: 1, name: 'alif' }];
  }

  @Get()
  @Version('2')
  getUsersV2() {
    return [{ id: 1, firstName: 'alif', lastName: 'xyz' }];
  }
}

Now:

GET /v1/users → getUsersV1
GET /v2/users → getUsersV2

Same route. Different versions.

Step 3: Default Version

You can optionally define a default version:

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

So if someone hits /users, it resolves to v1.

Step 4: Header Versioning

Alternatively, you can define versions in the header:

app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'x-api-version',
});

Request:

GET /users
x-api-version: 2

6. Real-World Pattern

Here’s how mature systems handle versioning:

  • Keep v1 stable
  • Introduce v2 for breaking changes.
  • Deprecate old versions gradually.
  • Never delete immediately

You’ll often see:

/v1 → legacy clients
/v2 → current production
/v3 → in development

Versioning becomes part of your release strategy, not an afterthought.

Internally, each of these versions is evolving like this:

v1.0.0 → v1.0.1 → v1.1.0 → v1.2.3

That’s MAJOR.MINOR.PATCH at work. This is called semantic Versioning.

Conclusion:

Most developers treat versioning as a technical detail, while it’s a design decision about how your system evolves. If you ignore it, you’ll ship faster in the short term and pay for it later. If you understand it early, you build systems that don’t collapse under change. And in backend engineering, that’s the difference between code that works and systems that last.