API Versioning in Node.js and TypeScript — Complete 2026 Guide

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why API Versioning Matters

Breaking changes in a public API force every consumer to update simultaneously — an operational nightmare. Versioning decouples your release cycle from your clients. You can deploy a v2 endpoint while v1 continues serving existing consumers, with a documented deprecation timeline.

In 2026, URL path versioning remains the gold standard. Header-based versioning is popular in enterprise contexts. Both are valid; pick one and stick with it.

Strategy 1 — URL Path Versioning

The most explicit and cache-friendly approach. Version is visible in the URL, easy to route, easy to document.

// routes/v1/users.ts
import { Router } from 'express';
export const usersV1Router = Router();
 
usersV1Router.get('/', async (req, res) => {
  const users = await db.users.findAll({ attributes: ['id', 'name', 'email'] });
  res.json({ users });
});
 
// routes/v2/users.ts — added pagination + role field
export const usersV2Router = Router();
 
usersV2Router.get('/', async (req, res) => {
  const page  = Number(req.query.page ?? 1);
  const limit = Number(req.query.limit ?? 20);
 
  const { rows, count } = await db.users.findAndCountAll({
    attributes: ['id', 'name', 'email', 'role', 'createdAt'],
    offset: (page - 1) * limit,
    limit,
  });
 
  res.json({
    data: rows,
    meta: { total: count, page, limit, pages: Math.ceil(count / limit) },
  });
});
 
// app.ts
import { usersV1Router } from './routes/v1/users';
import { usersV2Router } from './routes/v2/users';
 
app.use('/api/v1/users', usersV1Router);
app.use('/api/v2/users', usersV2Router);

Strategy 2 — Header-Based Versioning

Keeps URLs clean; version is communicated via a custom header. Useful in enterprise APIs where URL structure is locked.

import { Request, Response, NextFunction } from 'express';
 
type VersionedHandler = (req: Request, res: Response, next: NextFunction) => void;
 
function versionRouter(
  versions: Record<string, VersionedHandler>,
  defaultVersion = '1'
): VersionedHandler {
  return (req, res, next) => {
    const version = (req.headers['x-api-version'] as string) ?? defaultVersion;
    const handler = versions[version];
    if (!handler) {
      return res.status(400).json({
        error: `Unsupported API version: ${version}`,
        supported: Object.keys(versions),
      });
    }
    handler(req, res, next);
  };
}
 
// Usage
app.get(
  '/api/users',
  versionRouter({
    '1': getUsersV1,
    '2': getUsersV2,
  })
);

Strategy 3 — Accept Header (Content Negotiation)

RFC-compliant approach used by GitHub API. Version lives in the Accept header MIME type.

function acceptVersionMiddleware(req: Request, res: Response, next: NextFunction) {
  const accept = req.headers.accept ?? '';
  // e.g. Accept: application/vnd.myapp.v2+json
  const match = accept.match(/vnd\.myapp\.v(\d+)\+json/);
  (req as any).apiVersion = match ? match[1] : '1';
  next();
}
 
app.use(acceptVersionMiddleware);
 
app.get('/api/users', (req, res) => {
  const version = (req as any).apiVersion as string;
  if (version === '2') return getUsersV2(req, res);
  return getUsersV1(req, res);
});

Organizing Versioned Routes

src/
  routes/
    v1/
      users.ts
      posts.ts
      index.ts   ← mounts all v1 routers
    v2/
      users.ts
      index.ts
  app.ts
// routes/v1/index.ts
import { Router } from 'express';
import { usersV1Router } from './users';
import { postsV1Router } from './posts';
 
export const v1Router = Router();
v1Router.use('/users', usersV1Router);
v1Router.use('/posts', postsV1Router);
 
// routes/v2/index.ts
import { Router } from 'express';
import { usersV2Router } from './users';
 
export const v2Router = Router();
v2Router.use('/users', usersV2Router);
 
// app.ts
import { v1Router } from './routes/v1';
import { v2Router } from './routes/v2';
 
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

Deprecation Policy

// Deprecation middleware — adds headers on deprecated versions
function deprecationWarning(sunsetDate: string, link: string) {
  return (_req: Request, res: Response, next: NextFunction) => {
    res.setHeader('Deprecation', 'true');
    res.setHeader('Sunset', sunsetDate);  // RFC 8594
    res.setHeader('Link', `<${link}>; rel="successor-version"`);
    next();
  };
}
 
// Apply to v1 routes 6 months before removal
app.use('/api/v1',
  deprecationWarning('2026-12-31', 'https://docs.yourapi.com/v2/migration'),
  v1Router
);

Shared Controllers with Version Adapters

// Shared business logic — no versioning here
async function fetchUsers(options: { page: number; limit: number; includeRole: boolean }) {
  return db.users.findAll({
    attributes: ['id', 'name', 'email', ...(options.includeRole ? ['role'] : [])],
    offset: (options.page - 1) * options.limit,
    limit: options.limit,
  });
}
 
// Version adapters transform the shared result
export async function getUsersV1(req: Request, res: Response) {
  const users = await fetchUsers({ page: 1, limit: 100, includeRole: false });
  res.json({ users });
}
 
export async function getUsersV2(req: Request, res: Response) {
  const page  = Number(req.query.page ?? 1);
  const limit = Number(req.query.limit ?? 20);
  const users = await fetchUsers({ page, limit, includeRole: true });
  res.json({ data: users, meta: { page, limit } });
}

Common Mistakes

  • Introducing breaking changes within the same version — a new required field is a breaking change
  • Not communicating deprecation timelines — give consumers at least 6 months before removing a version
  • Duplicating business logic in each version — share controllers and adapt the request/response layer only
  • Using query string versioning (?version=2) — breaks caching and is less discoverable
  • Removing deprecated versions without monitoring usage — check analytics to confirm zero traffic first

Best Practices

  • Use URL path versioning (/api/v1/) for public APIs — it is explicit, cache-friendly, and firewall-friendly
  • Only create a new version when making a breaking change — additive changes do not require a new version
  • Add Deprecation, Sunset, and Link headers to deprecated routes per RFC 8594
  • Keep business logic in shared services; version only the request parsing and response shaping
  • Document each version in Swagger/OpenAPI with accurate examples and changelog notes

Key Takeaways

  • Breaking changes in a public API require a new version; additive changes (new optional fields) do not
  • URL path versioning (/api/v1/) is the most widely adopted strategy in 2026 — explicit and cache-friendly
  • Header-based versioning keeps URLs clean but requires clients to set custom request headers
  • Organize versioned routes in routes/v1/ and routes/v2/ directories for maintainability
  • Share business logic across versions; only version the HTTP adapter layer
  • Announce deprecation at least 6 months before sunset with Deprecation and Sunset headers
  • Monitor traffic per version before removing it — assume some consumers never update
  • Content negotiation (Accept: application/vnd.app.v2+json) is RFC-compliant but less common in practice

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading