Loading content...
Of all versioning strategies, URL versioning (also called path versioning or URI versioning) is the most immediately recognizable. When you see https://api.example.com/v1/users, the version is right there in the URL—explicit, discoverable, and unambiguous.
This approach has become the de facto standard for public APIs, adopted by industry leaders including Google, Stripe, Twitter, Facebook, and countless others. Its popularity stems from a simple truth: visibility reduces confusion.
In this page, we'll explore URL versioning in depth—from basic patterns to sophisticated routing architectures, examining why this approach dominates the API landscape while understanding its trade-offs and limitations.
By the end of this page, you will master URL versioning implementation patterns, understand routing architectures for multi-version support, and be equipped to design URL versioning strategies for APIs ranging from simple services to globe-spanning platforms.
URL versioning embeds version information directly in the request URL. This can be done in several locations within the URL structure:
1. Path Prefix (Most Common)
The version appears as the first path segment after the domain:
https://api.example.com/v1/usershttps://api.example.com/v2/usershttps://api.example.com/v3/users2. Subdomain
The version appears in the subdomain:
https://v1.api.example.com/usershttps://v2.api.example.com/users3. Path Suffix (Rare)
The version appears after the resource:
https://api.example.com/users/v1https://api.example.com/users/v2Each placement has implications for routing, caching, and infrastructure. The path prefix approach dominates due to its balance of clarity and infrastructure simplicity.
1234567891011121314151617181920212223242526272829303132333435363738394041
// URL Versioning Pattern Examples /** * Pattern 1: Path Prefix (Industry Standard) * Version as first path segment */const pathPrefixExamples = { stripe: "https://api.stripe.com/v1/charges", twitter: "https://api.twitter.com/2/tweets", google: "https://www.googleapis.com/calendar/v3/calendars", github: "https://api.github.com/v3/repos", // Note: GitHub uses Accept header primarily}; /** * Pattern 2: Subdomain Versioning * Version in subdomain for complete isolation */const subdomainExamples = { legacy: "https://v1.api.example.com/users", current: "https://v2.api.example.com/users", beta: "https://beta.api.example.com/users",}; /** * Pattern 3: Date-Based Versioning (Stripe-style) * Uses dates instead of sequential numbers */const dateBasedExamples = { stripe: "https://api.stripe.com/v1/", // With Stripe-Version: 2023-10-16 header // Some APIs embed dates in URL: embedded: "https://api.example.com/2023-10-16/charges",}; /** * Pattern 4: Major.Minor Versioning * More granular version specification */const majorMinorExamples = { versioned: "https://api.example.com/v1.2/users", semantic: "https://api.example.com/v1.2.3/users", // Generally avoided};Most successful APIs version by major version only (v1, v2, v3) rather than semantic versioning (v1.2.3). Minor changes should be backward compatible within a major version, so they don't need URL differentiation. Reserve URL version changes for breaking changes.
URL versioning has become the default for public APIs not by accident, but because it excels on the dimensions that matter most for API adoption and maintenance.
Immediate Discoverability
Version information is visible without inspecting headers or documentation. A developer can paste a URL into a browser and immediately see which version they're accessing. This transparency reduces debugging time and eliminates confusion about which version is being tested.
Zero-Configuration for Clients
Clients don't need to set custom headers or negotiate versions. Standard HTTP libraries work without modification. This lowers the barrier to integration, especially for developers in languages without first-class SDK support.
Natural Caching and CDN Behavior
URLs are the primary cache key in HTTP. Different versions get cached separately without any custom configuration. CDNs, browsers, and proxy servers handle versioned URLs correctly out of the box.
Linkability and Shareability
Versioned URLs can be shared directly. Documentation can link to specific versions. Error reports include the exact version in stack traces. There's no hidden context—what you see is what you get.
| Advantage | How It Works | Business Impact |
|---|---|---|
| Discoverability | Version visible in URL | Reduced support tickets, faster debugging |
| Simplicity | No special headers or negotiation | Lower integration barrier, faster adoption |
| Caching | Different URLs = different cache entries | Better performance, no cache confusion |
| Linkability | URLs work in any context | Easy sharing, clear documentation |
| Testing | Version switchable via URL | Simpler QA process, clear test cases |
| Logging | Version in access logs automatically | Better analytics, easier debugging |
| Routing | Standard URL matching | Simple infrastructure, proven patterns |
REST purists argue that URLs should identify resources, not representations (versions). While academically valid, this objection has lost to pragmatism. The overwhelming success of URL-versioned APIs demonstrates that developer experience trumps architectural purity for most use cases.
Implementing URL versioning requires careful architectural decisions. Different patterns suit different scales and organizational structures.
Pattern 1: Versioned Controllers/Routes
The simplest approach: create separate controller classes or route files for each version. This provides complete isolation but can lead to code duplication.
12345678910111213141516171819202122232425262728293031323334353637
// Pattern 1: Versioned Controllers (Express.js example) import express from 'express'; const app = express(); // Version 1 routesimport v1UserRouter from './v1/users';import v1OrderRouter from './v1/orders';app.use('/v1/users', v1UserRouter);app.use('/v1/orders', v1OrderRouter); // Version 2 routesimport v2UserRouter from './v2/users';import v2OrderRouter from './v2/orders';app.use('/v2/users', v2UserRouter);app.use('/v2/orders', v2OrderRouter); // Version 3 routes (latest)import v3UserRouter from './v3/users';import v3OrderRouter from './v3/orders';app.use('/v3/users', v3UserRouter);app.use('/v3/orders', v3OrderRouter); // Directory structure:// src/// ├── v1/// │ ├── users/// │ │ ├── controller.ts// │ │ ├── routes.ts// │ │ └── types.ts// │ └── orders/// │ └── ...// ├── v2/// │ └── ...// └── v3/// └── ...Pattern 2: Version Middleware with Shared Logic
A more sophisticated approach: inject version context via middleware, then use version-aware service layers that handle differences internally.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Pattern 2: Version Middleware Pattern import express, { Request, Response, NextFunction } from 'express'; // Extend Request type to include versioninterface VersionedRequest extends Request { apiVersion: string; apiVersionNumber: number;} // Version extraction middlewarefunction versionMiddleware(req: VersionedRequest, res: Response, next: NextFunction) { // Extract version from URL path const match = req.path.match(/^\/v(\d+)/); if (!match) { return res.status(400).json({ error: 'API version required' }); } req.apiVersion = `v${match[1]}`; req.apiVersionNumber = parseInt(match[1], 10); // Strip version from path for downstream routing req.url = req.url.replace(/^\/v\d+/, ''); next();} // Version-aware controllerasync function getUser(req: VersionedRequest, res: Response) { const user = await userService.findById(req.params.id); // Transform response based on version const response = transformUserResponse(user, req.apiVersionNumber); res.json(response);} // Response transformer with version supportfunction transformUserResponse(user: User, version: number) { if (version === 1) { return { id: user.id, name: `${user.firstName} ${user.lastName}`, // V1: combined name email: user.email, created: user.createdAt.toISOString(), }; } else if (version === 2) { return { id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, createdAt: user.createdAt, roles: user.roles, }; } else { // v3+ return { id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, createdAt: user.createdAt, roles: user.roles, preferences: user.preferences, // V3: added preferences mfa: user.mfaEnabled, // V3: MFA status }; }}The version-aware transformation pattern works for response shaping but avoid embedding version checks deep in business logic. Keep versioning at the API layer; services should be version-agnostic. Otherwise, you create unmaintainable spaghetti with if/else version checks throughout your codebase.
Production systems require robust routing architectures to handle multiple API versions efficiently. The routing strategy you choose affects performance, maintainability, and deployment flexibility.
Gateway-Level Routing
For microservices architectures or large-scale systems, an API gateway handles version routing before requests reach application servers. This provides centralized version management and enables different versions to run as completely separate deployments.
123456789101112131415161718192021222324252627282930313233343536
# Gateway-Level Version Routing (Kong/AWS API Gateway style) # Route Configuration for Kong Gatewayservices: - name: users-v1 url: http://users-v1-service.internal:8080 routes: - name: users-v1-route paths: - /v1/users strip_path: true - name: users-v2 url: http://users-v2-service.internal:8080 routes: - name: users-v2-route paths: - /v2/users strip_path: true - name: users-v3 url: http://users-v3-service.internal:8080 routes: - name: users-v3-route paths: - /v3/users strip_path: true # Traffic distribution (for gradual migration)upstreams: - name: users-v2-service.internal targets: - target: users-v2-blue:8080 weight: 90 - target: users-v2-green:8080 # Canary deployment weight: 10Kubernetes Ingress Routing
In Kubernetes environments, Ingress controllers provide path-based routing to different services or deployments representing API versions.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
# Kubernetes Ingress for Version RoutingapiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: api-versioned-ingress annotations: nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/rewrite-target: /$2spec: ingressClassName: nginx rules: - host: api.example.com http: paths: # V1 API - Legacy, maintenance mode - path: /v1(/|$)(.*) pathType: Prefix backend: service: name: api-v1-service port: number: 80 # V2 API - Current stable version - path: /v2(/|$)(.*) pathType: Prefix backend: service: name: api-v2-service port: number: 80 # V3 API - Latest version - path: /v3(/|$)(.*) pathType: Prefix backend: service: name: api-v3-service port: number: 80 # Default route (latest stable) - path: / pathType: Prefix backend: service: name: api-v2-service # Points to current stable port: number: 80| Strategy | Best For | Advantages | Challenges |
|---|---|---|---|
| Application Routing | Monolithic apps, small teams | Simple, no infra overhead | All versions in one deployment |
| Gateway Routing | Microservices, large scale | Centralized control, flexible | Gateway complexity, latency |
| K8s Ingress | Container environments | Native K8s, GitOps friendly | K8s knowledge required |
| Service Mesh | Complex microservices | Fine-grained control | Complexity, learning curve |
A critical decision in URL versioning is how to handle requests without a version specifier. Three approaches dominate:
1. Require Version (Recommended for Public APIs)
Reject requests without version specification. This is the safest approach for public APIs as it ensures consumers are explicit about which contract they're using.
12345678910111213141516171819202122
// Strategy 1: Require Version (Recommended) app.use((req, res, next) => { const hasVersion = req.path.match(/^\/v\d+/); if (!hasVersion) { return res.status(400).json({ error: 'API version required', message: 'Please specify an API version in the URL path (e.g., /v1/users)', availableVersions: ['v1', 'v2', 'v3'], latestVersion: 'v3', documentation: 'https://docs.example.com/api/versions', }); } next();}); // Benefits:// - Consumers must be explicit about version// - No surprise when default changes// - Clear error guidance for migration2. Default to Latest (Risk for Production)
Requests without version get the latest version. Simple to implement but dangerous—consumers can break unexpectedly when you release new versions.
1234567891011121314151617181920212223242526
// Strategy 2: Default to Latest (Risky!) const LATEST_VERSION = 'v3'; app.use((req, res, next) => { const hasVersion = req.path.match(/^\/v\d+/); if (!hasVersion) { // Redirect to latest version return res.redirect(307, `/${LATEST_VERSION}${req.path}`); // Or rewrite internally (more transparent) // req.url = `/${LATEST_VERSION}${req.url}`; // next(); } next();}); // WARNING: When you release v4, all unversioned // consumers suddenly hit v4 without opting in! // This pattern is only safe for:// - Internal APIs with synchronized deployments// - Development/testing environments// - APIs where consumers explicitly accept "latest"3. Default to Stable (Balanced Approach)
Maintain a "stable" or "current" version that doesn't change with every release. This provides convenience without the risk of auto-upgrading consumers.
123456789101112131415161718192021222324252627282930
// Strategy 3: Default to Stable Version (Balanced) // Version lifecycle:// v1 - deprecated (supported until 2024-12)// v2 - stable (current default)// v3 - latest (opt-in, may have experimental features) const STABLE_VERSION = 'v2';const LATEST_VERSION = 'v3'; app.use((req, res, next) => { const hasVersion = req.path.match(/^\/v\d+/); if (!hasVersion) { // Use stable version, but inform about latest req.url = `/${STABLE_VERSION}${req.url}`; // Add header indicating default was used res.set('X-API-Version-Used', STABLE_VERSION); res.set('X-API-Version-Latest', LATEST_VERSION); // Optional: Add deprecation warning when stable is old if (STABLE_VERSION !== LATEST_VERSION) { res.set('X-API-Upgrade-Available', `Upgrade to ${LATEST_VERSION} available. See docs.example.com/upgrade`); } } next();});Stripe uses a clever hybrid: URL path is always /v1, but a Stripe-Version header specifies the exact API behavior version (a date). Unspecified versions default to your account's configured version (set at signup), not 'latest'. This ensures consistency while allowing per-account version pinning.
Consumers need to discover available versions, understand differences, and plan migrations. A well-designed API provides programmatic access to version information.
Version Discovery Endpoint
Provide an endpoint that returns available versions with their status and documentation links.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Version Discovery API app.get('/api/versions', (req, res) => { res.json({ versions: [ { version: 'v1', status: 'deprecated', sunsetDate: '2024-12-31', documentation: 'https://docs.example.com/api/v1', changelog: 'https://docs.example.com/api/v1/changelog', migrationGuide: 'https://docs.example.com/migrate/v1-to-v2', supportedUntil: '2024-12-31', releaseDate: '2021-03-15', }, { version: 'v2', status: 'stable', documentation: 'https://docs.example.com/api/v2', changelog: 'https://docs.example.com/api/v2/changelog', migrationGuide: 'https://docs.example.com/migrate/v2-to-v3', releaseDate: '2022-09-01', }, { version: 'v3', status: 'current', documentation: 'https://docs.example.com/api/v3', changelog: 'https://docs.example.com/api/v3/changelog', releaseDate: '2023-11-01', features: [ 'Enhanced authentication with passkeys', 'GraphQL subscriptions support', 'Batch operations endpoint', ], }, ], recommended: 'v2', latest: 'v3', deprecationPolicy: 'https://docs.example.com/deprecation-policy', supportContact: 'api-support@example.com', });}); // Individual version infoapp.get('/v:version', (req, res) => { res.json({ version: req.params.version, status: getVersionStatus(req.params.version), endpoints: getEndpointList(req.params.version), authentication: 'Bearer token or API key', rateLimit: { requests: 1000, window: '1 minute' }, });});OpenAPI Specification per Version
Each version should have its own OpenAPI specification, enabling automated SDK generation and clear documentation.
12345678910111213141516171819202122232425262728293031323334353637
# Versioned OpenAPI Specificationopenapi: 3.0.3info: title: Example API version: "3.0.0" description: | This is version 3 of the Example API. ## Version History - v3 (Current): Added passkey auth, batch operations - v2 (Stable): Added roles, improved error responses - v1 (Deprecated): Original release, sunset Dec 2024 ## Breaking Changes from v2 - Response field 'data' is now always an array - Authentication header changed from X-API-Key to Authorization - Rate limits now return 429 instead of 503 contact: email: api@example.com x-api-version: v3 x-api-status: current x-previous-version: v2 x-previous-version-sunset: null servers: - url: https://api.example.com/v3 description: Production - url: https://staging-api.example.com/v3 description: Staging paths: /users: get: summary: List users x-since-version: v1 # ... endpoint definitionTreat version documentation as first-class: maintain separate docs per version, clearly mark deprecated features, provide migration guides before sunset dates, and archive (don't delete) old version docs. Developers often need to reference old versions for maintenance.
Despite its popularity, URL versioning has real drawbacks that must be considered in your architecture decisions.
Disadvantages of URL Versioning
| Scenario | Why URL Versioning Is Suboptimal | Alternative |
|---|---|---|
| Frequent minor changes | Too many version bumps | Header versioning with date-based versions |
| Content negotiation needed | URLs don't capture format preferences | Accept header with media type versions |
| Gradual field-level migration | All-or-nothing version switch | Header versioning with feature flags |
| HATEOAS strict compliance | Version in URL breaks resource identity | Accept header versioning |
| Highly dynamic schemas | Schema evolution faster than major versions | GraphQL with schema evolution |
Many production APIs use URL versioning for major versions (v1, v2) combined with header versioning for minor variations within a major version. This hybrid approach captures the benefits of URL visibility while allowing finer-grained evolution.
Based on lessons from large-scale API deployments, these practices optimize URL versioning for production environments:
123456789101112131415161718192021222324252627282930313233343536373839404142
// Production Version Monitoring import { metrics } from './observability'; // Middleware to track version usageapp.use((req, res, next) => { const version = extractVersion(req.path); // Increment version usage counter metrics.increment('api.request.count', { version, endpoint: req.path, method: req.method, clientId: req.headers['x-client-id'] || 'unknown', }); // Check if version is deprecated if (isDeprecated(version)) { // Log deprecated version usage logger.warn('Deprecated API version in use', { version, clientId: req.headers['x-client-id'], endpoint: req.path, sunsetDate: getSunsetDate(version), }); // Add deprecation headers res.set('Deprecation', getSunsetDate(version)); res.set('Sunset', getSunsetDate(version)); res.set('Link', '</v3>; rel="successor-version"'); // Increment deprecation metric for alerting metrics.increment('api.deprecated_version.usage', { version }); } next();}); // Dashboard query examples for version distribution:// - SELECT version, COUNT(*) FROM api_requests GROUP BY version// - SELECT client_id, version, last_seen FROM deprecated_version_users// - SELECT date, version, request_count FROM daily_version_statsSuccessful APIs run for decades. AWS APIs from 2006 still work. Stripe maintains years of backward compatibility. Design your URL versioning expecting to support multiple versions for years, not months. The infrastructure investment pays dividends in developer trust and platform longevity.
We've comprehensively explored URL versioning—the most visible and widely adopted approach to API versioning. Let's consolidate the key insights:
/v1/, /v2/ prefix, providing immediate visibility and discoverability.What's Next:
URL versioning isn't the only approach. In the next page, we'll explore Header Versioning—a more RESTful approach that separates resource identity from representation version. We'll examine when header versioning excels, how to implement it cleanly, and how major platforms combine URL and header versioning for maximum flexibility.
You now understand URL versioning deeply—from basic patterns to production architectures. This knowledge equips you to implement URL versioning that scales with your platform while providing the clarity and stability that developers expect.