DEV Community

楊東霖
楊東霖

Posted on • Originally published at devtoolkit.cc

API Versioning Strategies: URL, Header, and Query Parameter Approaches Compared

API versioning is one of those decisions that looks simple on day one and becomes a major headache on day 1000. The goal of versioning is to let you evolve your API over time without breaking existing clients — but the wrong strategy creates confusing URLs, complex infrastructure, and frustrated consumers who never know which version to use.

This guide compares the four main API versioning strategies, covers when you actually need versioning (and when you don't), and shows how to implement each approach cleanly in Node.js with Express.

When Do You Actually Need Versioning?

Not every API change requires a version bump. Distinguish between breaking changes (require a new version) and non-breaking changes (safe to deploy without a version bump):

Breaking changes — require new version:

  • Removing a field from a response
  • Renaming a field (e.g., user_nameusername)
  • Changing a field's type (e.g., string → number)
  • Changing HTTP method (e.g., PUT → PATCH)
  • Removing an endpoint
  • Changing authentication requirements
  • Changing required fields in requests

Non-breaking changes — safe to ship without versioning:

  • Adding new optional fields to responses
  • Adding new optional request parameters
  • Adding new endpoints
  • Bug fixes that correct behavior to match documented intent
  • Performance improvements

Many teams version too eagerly — every change bumps a version number, leading to v47 of their API that nobody uses. Version conservatively: only when you genuinely need to break backward compatibility.

Strategy 1: URL Path Versioning

URL path versioning embeds the version in the URL path:

GET /api/v1/users/123
GET /api/v2/users/123
POST /api/v1/products
Enter fullscreen mode Exit fullscreen mode

Implementation in Express

// src/app.ts
import express from 'express';
import { v1Router } from './v1/router';
import { v2Router } from './v2/router';

const app = express();

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

export { app };

// src/v1/router.ts
import { Router } from 'express';
import { usersRouter as usersV1 } from './users/users.router';
import { productsRouter as productsV1 } from './products/products.router';

export const v1Router = Router();
v1Router.use('/users', usersV1);
v1Router.use('/products', productsV1);

// src/v2/router.ts — can reuse v1 handlers or override
import { Router } from 'express';
import { usersRouter as usersV2 } from './users/users.router'; // New v2 implementation
import { usersRouter as usersV1 } from '../v1/users/users.router'; // Reuse unchanged routes

export const v2Router = Router();
v2Router.use('/users', usersV2);  // v2 has breaking changes
v2Router.use('/products', usersV1); // Products unchanged — reuse v1
Enter fullscreen mode Exit fullscreen mode

Organizing Shared Code

// When v1 and v2 share most logic, use a service layer:
src/
├── v1/
│   └── users/
│       ├── users.router.ts    # v1-specific routes and response shapes
│       └── users.controller.ts
├── v2/
│   └── users/
│       ├── users.router.ts    # v2-specific routes and response shapes
│       └── users.controller.ts
└── services/
    └── users.service.ts       # Shared business logic (no versioning here)
Enter fullscreen mode Exit fullscreen mode

Pros of URL versioning:

  • Immediately visible in browser, logs, and curl commands
  • Easy to route in proxies, CDNs, and API gateways
  • Easy to test — just change the URL
  • Most commonly understood by API consumers

Cons:

  • Violates REST principle that URIs should identify resources, not versions
  • Multiple URLs for the same resource can confuse caching
  • URLs must change even if the resource didn't

Strategy 2: Header Versioning

Header versioning passes the version in a request header, keeping URLs clean:

GET /api/users/123
Accept-Version: v2

# Or using a custom header:
GET /api/users/123
API-Version: 2026-03-01
Enter fullscreen mode Exit fullscreen mode

Implementation in Express

// src/middleware/versionRouter.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

type VersionedHandlers = {
  [version: string]: RequestHandler;
  default: RequestHandler;
};

export function versionRoute(handlers: VersionedHandlers): RequestHandler {
  return (req: Request, res: Response, next: NextFunction) => {
    const version = req.headers['accept-version'] as string
      || req.headers['api-version'] as string
      || 'default';

    const handler = handlers[version] ?? handlers['default'];
    return handler(req, res, next);
  };
}

// Usage:
import { getUserV1, getUserV2 } from './users.controller';

router.get('/:id', versionRoute({
  v1: getUserV1,
  v2: getUserV2,
  default: getUserV2, // Default to latest
}));
Enter fullscreen mode Exit fullscreen mode

Date-Based Header Versioning (Stripe's Approach)

Stripe uses date-based versioning: the version is a date string representing an API snapshot. When you sign up, you're locked to the Stripe API as it existed on that date. You can explicitly upgrade by changing the date:

// Server: track API version per request
app.use((req: Request, res: Response, next: NextFunction) => {
  const stripeVersion = req.headers['stripe-version'] as string;
  const apiDate = stripeVersion || process.env.DEFAULT_API_VERSION || '2026-01-01';

  // Validate it's a known version
  const knownVersions = ['2024-06-01', '2025-01-15', '2026-01-01'];
  if (!knownVersions.includes(apiDate)) {
    return res.status(400).json({
      error: { code: 'INVALID_API_VERSION', message: `Unknown API version: ${apiDate}` }
    });
  }

  req.apiVersion = apiDate;
  res.setHeader('Stripe-Version', apiDate);
  next();
});

// Route handler selects behavior based on version
router.get('/charges/:id', async (req, res) => {
  const charge = await getCharge(req.params.id);

  // Transform response based on version
  if (req.apiVersion >= '2026-01-01') {
    // New: amount is in decimal format
    res.json({ id: charge.id, amount: charge.amount / 100 });
  } else {
    // Old: amount is in cents
    res.json({ id: charge.id, amount: charge.amount });
  }
});
Enter fullscreen mode Exit fullscreen mode

Pros of header versioning:

  • Clean URLs that don't change between versions
  • Follows HTTP spec (Accept header content negotiation)
  • Per-request version negotiation possible

Cons:

  • Invisible in browser address bar — harder to share/debug
  • Requires custom client configuration
  • Harder to test with simple curl
  • CDNs and caches may ignore custom headers

Strategy 3: Query Parameter Versioning

The version is passed as a query parameter:

GET /api/users/123?version=2
GET /api/users/123?api-version=2026-03-01
Enter fullscreen mode Exit fullscreen mode

Implementation

// Simple query parameter version extraction
function getApiVersion(req: Request): string {
  return (req.query.version as string)
    || (req.query['api-version'] as string)
    || 'v1'; // Default
}

app.use((req, res, next) => {
  req.apiVersion = getApiVersion(req);
  next();
});
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Easy to test in a browser or with curl
  • Version is visible in URLs
  • Backward compatible default (no version = default version)

Cons:

  • Query parameters are meant for filtering, not versioning
  • Can conflict with other query parameters
  • URLs look messier when combined with actual query params
  • Breaks semantic URI principles

Strategy 4: Content Negotiation (Accept Header)

The most RESTful approach uses the HTTP Accept header with media type versioning:

GET /api/users/123
Accept: application/vnd.myapi.v2+json

# Or using parameters:
Accept: application/json;version=2
Enter fullscreen mode Exit fullscreen mode
// Express content negotiation handler
router.get('/:id', (req, res, next) => {
  const accept = req.headers['accept'] || '';

  if (accept.includes('vnd.myapi.v2')) {
    return getUserV2(req, res, next);
  }
  return getUserV1(req, res, next); // Default to v1
});
Enter fullscreen mode Exit fullscreen mode

This approach is technically correct per HTTP spec but rarely used in practice because it's the most complex for clients to implement and the hardest to debug.

Versioning at the Field Level: Additive Changes

Before creating a whole new API version, consider additive changes that don't require version bumps:

// Instead of breaking change: rename "name"  "full_name" in v2
// Use additive approach: add "full_name" while keeping "name" (v1 compatible)
{
  "id": "123",
  "name": "John Doe",       // Keep for backward compatibility
  "full_name": "John Doe",   // Add new field
  "email": "john@example.com"
}

// Deprecate the old field with a response header
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
res.setHeader('Link', '</api/v2/users>; rel="successor-version"');
Enter fullscreen mode Exit fullscreen mode

Sunsetting Old Versions

API versioning without a sunset plan leads to maintaining v1 indefinitely. Use IETF's Sunset header to communicate deprecation:

// Middleware: add deprecation warnings to v1 responses
function v1DeprecationWarning(req: Request, res: Response, next: NextFunction) &#123;
  res.setHeader('Deprecation', '"2026-01-01"');
  res.setHeader('Sunset', '"2026-12-31T23:59:59Z"');
  res.setHeader('Link', '</api/v2>; rel="successor-version", <https://docs.example.com/migration>; rel="deprecation"');
  next();
&#125;

// Usage tracking — log which versions are still being called
app.use('/api/v1', (req, res, next) => &#123;
  metrics.increment('api.v1.request', &#123;
    path: req.path,
    method: req.method,
  &#125;);
  next();
&#125;, v1Router);

// Alert when usage drops below threshold (safe to sunset)
// Check metrics: if v1 traffic < 1% for 30 days, proceed with sunset
Enter fullscreen mode Exit fullscreen mode

Making the Right Choice

Choose your strategy based on your context:

  • Public API with many external consumers → URL path versioning (most discoverable and easiest for consumers)
  • Internal API within a controlled ecosystem → Header versioning (clean URLs, controlled clients)
  • API that evolves frequently (like Stripe) → Date-based header versioning (precise version control per customer)
  • Simple API with occasional breaking changes → URL path versioning (pragmatic choice for most teams)

Whatever strategy you choose, the most important discipline is only versioning when you have to. Design your API response formats to be forward-compatible from the start: use objects not arrays for response envelopes (easier to add fields), use consistent field names, and document your deprecation policy before you write the first endpoint.

For more on API design, see our guides on REST vs GraphQL and REST API testing. Use our API Tester to test versioned endpoints with different headers.

Free Developer Tools

If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.

Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder

🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.

Top comments (0)