Skip to content
Codeloom
Backend

API Versioning Strategies: URL, Header, and Query Param

Compare API versioning approaches: URL path, custom headers, query parameters, and content negotiation. Includes migration strategies and real-world tradeoffs.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • The four main API versioning strategies and when to use each
  • How to implement versioning in practice with code examples
  • How to plan version deprecation and migration

Prerequisites

  • REST API design basics
  • HTTP methods and status codes
  • Basic backend development experience

Every API eventually needs to change in ways that break existing clients. Versioning gives you a way to evolve your API without forcing all consumers to update simultaneously. There is no universally correct approach. Each strategy has tradeoffs around discoverability, caching, routing complexity, and client burden.

Strategy 1: URL path versioning

The version number is embedded directly in the URL path.

GET /api/v1/users/42
GET /api/v2/users/42

This is the most common approach, used by GitHub, Stripe, and Twitter/X.

Implementation (Express.js)

import express from 'express';
const app = express();

// v1 routes
const v1Router = express.Router();
v1Router.get('/users/:id', (req, res) => {
    // v1 returns flat structure
    res.json({
        id: 42,
        name: 'Alice Smith',
        email: 'alice@example.com'
    });
});

// v2 routes
const v2Router = express.Router();
v2Router.get('/users/:id', (req, res) => {
    // v2 returns nested structure with more fields
    res.json({
        id: 42,
        name: { first: 'Alice', last: 'Smith' },
        email: 'alice@example.com',
        createdAt: '2026-01-15T10:30:00Z',
        metadata: { loginCount: 47 }
    });
});

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

Implementation (Spring Boot)

@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {

    @GetMapping("/{id}")
    public UserV1Dto getUser(@PathVariable Long id) {
        return userService.getUserV1(id);
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {

    @GetMapping("/{id}")
    public UserV2Dto getUser(@PathVariable Long id) {
        return userService.getUserV2(id);
    }
}

Pros: Easy to understand. URLs are bookmarkable and shareable. CDN and proxy caching works out of the box. Clear routing at the infrastructure level.

Cons: URL space pollution. Clients must update URLs to migrate. Implies the entire API changed when often only a few endpoints differ between versions.

Strategy 2: custom header versioning

The version is specified in a custom request header.

GET /api/users/42
X-API-Version: 2

Implementation

function versionMiddleware(req, res, next) {
    const version = parseInt(req.headers['x-api-version'] || '1', 10);
    req.apiVersion = version;
    next();
}

app.use(versionMiddleware);

app.get('/api/users/:id', (req, res) => {
    const user = userService.getUser(req.params.id);

    if (req.apiVersion === 1) {
        res.json({ id: user.id, name: user.fullName, email: user.email });
    } else if (req.apiVersion === 2) {
        res.json({
            id: user.id,
            name: { first: user.firstName, last: user.lastName },
            email: user.email,
            createdAt: user.createdAt
        });
    } else {
        res.status(400).json({ error: `Unsupported API version: ${req.apiVersion}` });
    }
});

Pros: Clean URLs. Easy to default to the latest version. The resource identity (URL) stays the same across versions.

Cons: Not visible in browser address bars. Harder to test with curl without remembering the header. Some proxies strip custom headers. Documentation must clearly communicate the header requirement.

Strategy 3: query parameter versioning

The version is a query parameter.

GET /api/users/42?version=2

Implementation

app.get('/api/users/:id', (req, res) => {
    const version = parseInt(req.query.version || '1', 10);
    const user = userService.getUser(req.params.id);

    switch (version) {
        case 1:
            return res.json(formatV1(user));
        case 2:
            return res.json(formatV2(user));
        default:
            return res.status(400).json({
                error: `Unsupported version: ${version}`,
                supportedVersions: [1, 2]
            });
    }
});

Pros: Easy to add to existing APIs. Visible in URLs. Easy to test in browsers. Optional parameter with a default version.

Cons: Query parameters are typically used for filtering, not resource representation. Can conflict with caching strategies. Makes the version feel like an afterthought.

Strategy 4: content negotiation (Accept header)

The version is specified through the Accept header using vendor media types.

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

GitHub uses this approach.

Implementation

app.get('/api/users/:id', (req, res) => {
    const accept = req.headers.accept || '';

    let version = 1; // default
    const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
    if (match) {
        version = parseInt(match[1], 10);
    }

    const user = userService.getUser(req.params.id);

    switch (version) {
        case 1:
            res.type('application/vnd.myapi.v1+json');
            return res.json(formatV1(user));
        case 2:
            res.type('application/vnd.myapi.v2+json');
            return res.json(formatV2(user));
        default:
            return res.status(406).json({ error: 'Not Acceptable' });
    }
});

Pros: REST-purist approach. Keeps URLs clean. Uses HTTP semantics correctly. Allows fine-grained versioning per resource type.

Cons: Complex for clients. Difficult to test in browsers. Many developers are unfamiliar with vendor media types. Some HTTP clients make it awkward to set the Accept header.

Comparison table

CriterionURL PathHeaderQuery ParamContent Negotiation
DiscoverabilityHighLowMediumLow
CachingSimpleNeeds VaryNeeds VaryNeeds Vary
Client simplicityHighMediumHighLow
URL cleanlinessLowHighMediumHigh
Routing complexityLowMediumLowMedium
REST complianceLowMediumLowHigh

Version deprecation and sunset

Regardless of which strategy you choose, plan for deprecation:

// add deprecation headers to sunset versions
function deprecationMiddleware(req, res, next) {
    if (req.apiVersion === 1) {
        res.set('Deprecation', 'true');
        res.set('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
        res.set('Link', '</api/v2/docs>; rel="successor-version"');
    }
    next();
}

The Sunset header (RFC 8594) tells clients when a version will be removed. The Deprecation header signals the version is no longer recommended.

Deprecation timeline

  1. Announce: Document the deprecation date at least 6 months in advance.
  2. Warn: Add deprecation headers to all responses from the old version.
  3. Monitor: Track usage of the deprecated version. Reach out to high-volume consumers.
  4. Return 410 Gone: After the sunset date, return 410 Gone with a message pointing to the new version.

Practical recommendations

  1. Start with URL path versioning unless you have a strong reason not to. It is the simplest for both API developers and consumers.
  2. Version at the API level, not the endpoint level. Mixing versions per endpoint creates confusion.
  3. Only increment major versions for breaking changes. Additive changes (new fields, new endpoints) do not require a new version.
  4. Keep at most two versions active. Supporting three or more becomes a maintenance burden.
  5. Default to the latest stable version for new clients. Never default to a deprecated version.

The best versioning strategy is the one your team can maintain and your consumers can adopt without friction.