API Versioning: URL vs Header vs Query Parameter

TL;DR

URL versioning (/v1/users) is simplest and most discoverable. Header versioning is cleaner but harder to use. Start with URL versioning, only use headers if you have a strong reason.

I spent two weeks implementing API versioning with custom headers because I read that's what "RESTful" APIs should do. Testing was a nightmare. Documentation was confusing. Developers hated it.

I switched to URL versioning (/v1/, /v2/) and everything got simpler. Easy to test, easy to document, easy to understand. No more custom headers nobody knew about.

API versioning matters, but the strategy you choose matters more. Here's when to use each approach, with real implementations and tradeoffs.

Why APIs Need Versioning

Without versioning, breaking changes break clients:

// v1: Returns array
GET /api/users
[
  { "id": 1, "name": "John" }
]

// You change to pagination (breaking change!)
GET /api/users
{
  "data": [{ "id": 1, "name": "John" }],
  "page": 1,
  "total": 100
}

// All existing clients break

With versioning, old clients keep working:

// Old clients still use v1
GET /api/v1/users
[{ "id": 1, "name": "John" }]

// New clients use v2 with pagination
GET /api/v2/users
{
  "data": [{ "id": 1, "name": "John" }],
  "page": 1
}

The Three Main Approaches

1. URL Versioning

Version in the path:

GET /api/v1/users
GET /api/v2/users
GET /api/v3/users

Implementation:

const express = require('express');
const app = express();

// v1 routes
const v1Router = express.Router();

v1Router.get('/users', async (req, res) => {
    const users = await db.query('SELECT id, name FROM users');
    res.json(users); // Returns array
});

v1Router.get('/users/:id', async (req, res) => {
    const user = await db.query('SELECT id, name FROM users WHERE id = ?', [req.params.id]);
    res.json(user);
});

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

// v2 routes with breaking changes
const v2Router = express.Router();

v2Router.get('/users', async (req, res) => {
    const page = parseInt(req.query.page) || 1;
    const limit = 20;
    const offset = (page - 1) * limit;

    const users = await db.query('SELECT id, name FROM users LIMIT ? OFFSET ?', [limit, offset]);
    const [{ total }] = await db.query('SELECT COUNT(*) as total FROM users');

    res.json({
        data: users,
        page,
        total,
        totalPages: Math.ceil(total / limit)
    });
});

v2Router.get('/users/:id', async (req, res) => {
    const user = await db.query('SELECT id, name FROM users WHERE id = ?', [req.params.id]);

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    res.json({ data: user }); // Wrapped in object
});

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

Usage:

curl https://api.example.com/api/v1/users
curl https://api.example.com/api/v2/users?page=1

Pros:

  • Extremely clear which version you're using
  • Easy to test (just change URL)
  • Easy to document
  • Works with all HTTP clients
  • Can cache differently per version
  • Easy to deprecate (remove entire router)

Cons:

  • Version in URL (some say this violates REST principles)
  • URLs change between versions

2. Header Versioning

Version in Accept header:

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

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

Implementation:

// Middleware to extract version from header
function versionMiddleware(req, res, next) {
    const accept = req.headers.accept || '';
    const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);

    req.apiVersion = match ? parseInt(match[1]) : 1; // Default to v1

    next();
}

app.use(versionMiddleware);

app.get('/api/users', async (req, res) => {
    if (req.apiVersion === 1) {
        // v1 logic
        const users = await db.query('SELECT id, name FROM users');
        res.json(users);
    } else if (req.apiVersion === 2) {
        // v2 logic
        const page = parseInt(req.query.page) || 1;
        const limit = 20;
        const offset = (page - 1) * limit;

        const users = await db.query('SELECT id, name FROM users LIMIT ? OFFSET ?', [limit, offset]);
        const [{ total }] = await db.query('SELECT COUNT(*) as total FROM users');

        res.json({
            data: users,
            page,
            total
        });
    } else {
        res.status(400).json({ error: 'Unsupported API version' });
    }
});

Usage:

curl https://api.example.com/api/users \
  -H "Accept: application/vnd.myapi.v1+json"

curl https://api.example.com/api/users \
  -H "Accept: application/vnd.myapi.v2+json"

Pros:

  • URLs stay clean
  • Follows content negotiation principles
  • More "RESTful" (according to some)

Cons:

  • Harder to test (need to set headers)
  • Harder to document
  • Not visible in browser address bar
  • More complex middleware
  • Can't test in browser easily
  • Caching more complex

3. Query Parameter Versioning

Version as query string:

GET /api/users?version=1
GET /api/users?version=2

Implementation:

app.get('/api/users', async (req, res) => {
    const version = parseInt(req.query.version) || 1;

    if (version === 1) {
        const users = await db.query('SELECT id, name FROM users');
        res.json(users);
    } else if (version === 2) {
        const page = parseInt(req.query.page) || 1;
        const limit = 20;
        const offset = (page - 1) * limit;

        const users = await db.query('SELECT id, name FROM users LIMIT ? OFFSET ?', [limit, offset]);
        const [{ total }] = await db.query('SELECT COUNT(*) as total FROM users');

        res.json({
            data: users,
            page,
            total
        });
    } else {
        res.status(400).json({ error: 'Unsupported API version' });
    }
});

Usage:

curl https://api.example.com/api/users?version=1
curl https://api.example.com/api/users?version=2&page=1

Pros:

  • Easy to test
  • Easy to document
  • Works in browser
  • Simple implementation

Cons:

  • Mixes versioning with query parameters
  • Less clear than URL path
  • Can conflict with actual query params
  • Not as clean as URL versioning

Real-World Examples

Stripe: URL Versioning

# Latest version
curl https://api.stripe.com/v1/customers

# Specific version via header (for backwards compatibility)
curl https://api.stripe.com/v1/customers \
  -H "Stripe-Version: 2023-10-16"

Stripe uses URL (/v1/) for major version, header for minor version changes.

GitHub: URL Versioning

# v3 (REST API)
curl https://api.github.com/users/octocat

# v4 (GraphQL)
curl https://api.github.com/graphql

GitHub versions in URL and with separate endpoints for major versions.

Twitter: URL Versioning

curl https://api.twitter.com/1.1/statuses/home_timeline.json
curl https://api.twitter.com/2/tweets

Twitter uses URL versioning with decimal versions.

AWS: Header Versioning

curl https://s3.amazonaws.com/bucket/key \
  -H "x-amz-api-version: 2006-03-01"

AWS uses custom headers for versioning (but they have special requirements).

Code Organization Strategies

Strategy 1: Separate Routers

// routes/v1/users.js
const express = require('express');
const router = express.Router();

router.get('/', async (req, res) => {
    const users = await db.query('SELECT id, name FROM users');
    res.json(users);
});

module.exports = router;

// routes/v2/users.js
const express = require('express');
const router = express.Router();

router.get('/', async (req, res) => {
    const page = parseInt(req.query.page) || 1;
    const limit = 20;
    const offset = (page - 1) * limit;

    const users = await db.query('SELECT id, name FROM users LIMIT ? OFFSET ?', [limit, offset]);
    const [{ total }] = await db.query('SELECT COUNT(*) as total FROM users');

    res.json({ data: users, page, total });
});

module.exports = router;

// app.js
const v1Users = require('./routes/v1/users');
const v2Users = require('./routes/v2/users');

app.use('/api/v1/users', v1Users);
app.use('/api/v2/users', v2Users);

Pros:

  • Clean separation
  • Easy to add new versions
  • Can delete old versions easily

Cons:

  • Code duplication
  • Changes need to be made in multiple places

Strategy 2: Shared Code with Version-Specific Overrides

// controllers/users.js
class UsersController {
    // Shared logic
    async getUser(id) {
        return db.query('SELECT * FROM users WHERE id = ?', [id]);
    }

    // v1 endpoint
    async listV1(req, res) {
        const users = await db.query('SELECT id, name FROM users');
        res.json(users);
    }

    // v2 endpoint
    async listV2(req, res) {
        const page = parseInt(req.query.page) || 1;
        const limit = 20;
        const offset = (page - 1) * limit;

        const users = await db.query('SELECT id, name FROM users LIMIT ? OFFSET ?', [limit, offset]);
        const [{ total }] = await db.query('SELECT COUNT(*) as total FROM users');

        res.json({ data: users, page, total });
    }
}

const controller = new UsersController();

// routes/v1/users.js
router.get('/', controller.listV1.bind(controller));

// routes/v2/users.js
router.get('/', controller.listV2.bind(controller));

Pros:

  • Shared code reduces duplication
  • Clear which logic is version-specific

Cons:

  • Controller grows with versions
  • Harder to remove old versions

Strategy 3: Adapter Pattern

// adapters/users-adapter.js
class UsersAdapter {
    constructor(version) {
        this.version = version;
    }

    async list(req) {
        const users = await db.query('SELECT id, name FROM users');

        if (this.version === 1) {
            return users; // v1: array
        }

        if (this.version === 2) {
            // v2: paginated
            const page = parseInt(req.query.page) || 1;
            return {
                data: users,
                page,
                total: users.length
            };
        }
    }

    async get(id) {
        const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);

        if (this.version === 1) {
            return user; // v1: direct object
        }

        if (this.version === 2) {
            return { data: user }; // v2: wrapped
        }
    }
}

// routes/v1/users.js
const adapter = new UsersAdapter(1);

router.get('/', async (req, res) => {
    const result = await adapter.list(req);
    res.json(result);
});

// routes/v2/users.js
const adapter = new UsersAdapter(2);

router.get('/', async (req, res) => {
    const result = await adapter.list(req);
    res.json(result);
});

Pros:

  • Single source of truth
  • Easy to add version-specific logic
  • Shared business logic

Cons:

  • Adapter can get complex
  • Hard to remove old versions

Handling Breaking vs Non-Breaking Changes

Non-Breaking Changes (Same Version)

// v1: Original
{
  "id": 1,
  "name": "John"
}

// v1: Add new field (non-breaking)
{
  "id": 1,
  "name": "John",
  "email": "john@example.com"  // New field
}

// Old clients ignore new field
// No version bump needed

Non-breaking changes:

  • Adding new optional fields
  • Adding new endpoints
  • Adding new optional query parameters
  • Making required fields optional
  • Expanding enum values

Breaking Changes (New Version)

// v1
{
  "id": 1,
  "name": "John"
}

// v2 (breaking change: rename field)
{
  "id": 1,
  "full_name": "John Doe"  // Field renamed!
}

// Requires new version

Breaking changes:

  • Removing fields
  • Renaming fields
  • Changing field types
  • Making optional fields required
  • Removing endpoints
  • Changing response format

Version Deprecation Strategy

// v1 deprecated, but still works
app.use('/api/v1', (req, res, next) => {
    res.set('X-API-Deprecation', 'v1 is deprecated. Please migrate to v2. v1 will be removed on 2026-06-01');
    res.set('X-API-Sunset', '2026-06-01');
    res.set('Link', '<https://docs.example.com/migration-guide>; rel="deprecation"');
    next();
});

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

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

// v3 beta
app.use('/api/v3-beta', (req, res, next) => {
    res.set('X-API-Status', 'beta');
    next();
});

app.use('/api/v3-beta', v3Router);

Deprecation Timeline

Month 0: Release v2, announce v1 deprecation
Month 1: Email users about deprecation
Month 3: Add deprecation headers to v1
Month 6: Increase warning frequency
Month 9: Add delays to v1 responses (forcing migration)
Month 12: Remove v1

Tracking Version Usage

const versionStats = new Map();

app.use((req, res, next) => {
    const version = req.path.match(/\/v(\d+)\//)?.[1] || 'unknown';

    if (!versionStats.has(version)) {
        versionStats.set(version, 0);
    }

    versionStats.set(version, versionStats.get(version) + 1);

    next();
});

// Metrics endpoint
app.get('/admin/metrics/versions', (req, res) => {
    const total = Array.from(versionStats.values()).reduce((a, b) => a + b, 0);

    const stats = {};
    for (const [version, count] of versionStats.entries()) {
        stats[version] = {
            requests: count,
            percentage: ((count / total) * 100).toFixed(2) + '%'
        };
    }

    res.json(stats);
});

// Output:
// {
//   "v1": { "requests": 1234, "percentage": "5.23%" },
//   "v2": { "requests": 22189, "percentage": "94.77%" }
// }

Testing Different Versions

// test/v1/users.test.js
describe('GET /api/v1/users', () => {
    it('returns array of users', async () => {
        const response = await request(app)
            .get('/api/v1/users')
            .expect(200);

        expect(Array.isArray(response.body)).toBe(true);
        expect(response.body[0]).toHaveProperty('id');
        expect(response.body[0]).toHaveProperty('name');
    });
});

// test/v2/users.test.js
describe('GET /api/v2/users', () => {
    it('returns paginated users', async () => {
        const response = await request(app)
            .get('/api/v2/users')
            .expect(200);

        expect(response.body).toHaveProperty('data');
        expect(response.body).toHaveProperty('page');
        expect(response.body).toHaveProperty('total');
        expect(Array.isArray(response.body.data)).toBe(true);
    });

    it('supports pagination', async () => {
        const response = await request(app)
            .get('/api/v2/users?page=2')
            .expect(200);

        expect(response.body.page).toBe(2);
    });
});

Documentation Per Version

# API Documentation

## Version 2 (Current)

### GET /api/v2/users

Returns paginated list of users.

**Query Parameters:**
- `page` (optional): Page number, default 1
- `limit` (optional): Items per page, default 20

**Response:**
```json
{
  "data": [
    { "id": 1, "name": "John" }
  ],
  "page": 1,
  "total": 100,
  "totalPages": 5
}

Version 1 (Deprecated - Sunset: 2026-06-01)

GET /api/v1/users

Returns array of all users.

Response:

[
  { "id": 1, "name": "John" }
]

⚠️ This version is deprecated. Please migrate to v2.


## Client Libraries with Version Support

```javascript
// api-client.js
class APIClient {
    constructor(version = 2) {
        this.version = version;
        this.baseURL = 'https://api.example.com';
    }

    async getUsers(options = {}) {
        const url = `${this.baseURL}/api/v${this.version}/users`;

        const response = await fetch(url, {
            ...options,
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            }
        });

        return response.json();
    }
}

// Usage
const clientV1 = new APIClient(1);
const usersV1 = await clientV1.getUsers(); // Array

const clientV2 = new APIClient(2);
const usersV2 = await clientV2.getUsers(); // { data, page, total }

GraphQL Versioning

GraphQL handles versioning differently:

// No versioning in URL
// Use field deprecation instead
const typeDefs = `
  type User {
    id: ID!
    name: String! @deprecated(reason: "Use fullName instead")
    fullName: String!
    email: String
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }
`;

// Clients can still use deprecated fields
// But get warnings

GraphQL's approach:

  • Single endpoint
  • Evolve schema without breaking changes
  • Deprecate fields instead of removing
  • Clients explicitly request fields they need

My Decision Framework

Use URL Versioning when:

  • Building a public API
  • Want simplicity and clarity
  • Testing is important
  • Documentation matters
  • Most APIs (default choice)

Use Header Versioning when:

  • Need clean URLs for branding
  • Content negotiation is already in use
  • Have sophisticated API clients
  • Following strict REST principles

Use Query Parameter when:

  • Building internal APIs
  • Want even simpler implementation
  • URL versioning feels too heavy
  • Temporary versioning need

Use GraphQL approach when:

  • Already using GraphQL
  • Can evolve schema gradually
  • Clients explicitly request fields
  • Want maximum flexibility

Real Production Setup

My current approach:

// app.js
const express = require('express');
const app = express();

// Middleware for all versions
app.use(express.json());
app.use(cors());
app.use(helmet());

// Version routing
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');

// v1 (deprecated)
app.use('/api/v1', (req, res, next) => {
    res.set('X-API-Deprecation', 'true');
    res.set('X-API-Sunset', '2026-06-01');
    next();
});
app.use('/api/v1', v1);

// v2 (current)
app.use('/api/v2', v2);

// Default to latest version
app.use('/api', v2);

// 404 handler
app.use((req, res) => {
    res.status(404).json({
        error: 'Not found',
        availableVersions: ['v1', 'v2']
    });
});

// Error handler
app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).json({
        error: 'Internal server error',
        message: process.env.NODE_ENV === 'development' ? err.message : undefined
    });
});

app.listen(3000);

Structure:

routes/
  v1/
    index.js
    users.js
    posts.js
  v2/
    index.js
    users.js
    posts.js
controllers/
  users-controller.js
  posts-controller.js

The Bottom Line

API versioning is essential, but keep it simple:

Use URL versioning (/v1/, /v2/) unless you have a compelling reason not to. It's the clearest, easiest to test, and most widely understood approach.

Don't overthink it. Header versioning isn't inherently better. It's just different (and more complex).

Version early. Start with /v1/ from day one. Easier to maintain than retrofitting later.

Document deprecations clearly. Give users time and guidance to migrate.

Track version usage. Know when it's safe to remove old versions.

I wasted two weeks implementing "proper" header-based versioning because that's what I read was RESTful. URL versioning would have taken an afternoon and worked better.

Stripe, GitHub, and Twitter all use URL versioning. If it's good enough for them, it's good enough for your API.

Pick URL versioning. Ship your API. Solve real problems.