URL path versioning
The most common approach
There are a few different ways to tell clients which version of your API they’re talking to. The most popular one is also the simplest: put the version number right in the URL.
GET /v1/books
GET /v2/books
POST /v1/books
POST /v2/books Each version is a distinct set of routes. Our project already uses this pattern, with GET /v1/books. Adding v2 later just means adding new routes under /v2/.
Why it works
It’s visible. The version is right there in the URL. Developers see it in the documentation, in curl commands, in browser dev tools, in server logs. There’s never any confusion about which version a client is calling.
It’s cacheable. /v1/books and /v2/books are different URLs. CDNs and browser caches treat them as completely different resources. Each gets its own cache entry, and you don’t need to think about Vary headers or other caching complexity.
Routing is simple. Each version is a separate group of routes:
import { setup } from "@hectoday/http";
import { v1Routes } from "./v1/routes.js";
import { v2Routes } from "./v2/routes.js";
export const app = setup({
routes: [...v1Routes, ...v2Routes],
}); v1 routes handle /v1/*. v2 routes handle /v2/*. They coexist in the same app, sharing the same database. The code structure mirrors the API structure, which makes it easy to reason about.
It’s easy to test. curl https://api.example.com/v1/books and curl https://api.example.com/v2/books are two separate, testable calls. Copy and paste either URL, and you know exactly what you’ll get.
Implementing it in Hectoday HTTP
The implementation is straightforward. Each version gets its own route file:
// src/v1/routes.ts
export const v1Routes = [
route.get("/v1/books", {
resolve: (c) => {
/* v1 handler */
},
}),
route.get("/v1/books/:id", {
resolve: (c) => {
/* v1 handler */
},
}),
route.post("/v1/books", {
resolve: (c) => {
/* v1 handler */
},
}),
];
// src/v2/routes.ts
export const v2Routes = [
route.get("/v2/books", {
resolve: (c) => {
/* v2 handler */
},
}),
route.get("/v2/books/:id", {
resolve: (c) => {
/* v2 handler */
},
}),
route.post("/v2/books", {
resolve: (c) => {
/* v2 handler */
},
}),
]; Each version is a separate file with its own route definitions. The main app imports them and spreads them into the routes array. Clean, simple, and easy to understand.
The downsides
No approach is perfect. Here are the tradeoffs:
URL pollution. /v1/books/book-1/reviews becomes /v2/books/book-1/reviews. Every URL in every client includes the version. When a client wants to upgrade, they have to update every single URL in their codebase.
Duplication temptation. With separate route files, teams sometimes copy-paste the entire v1 folder into v2 and start modifying it. This creates duplicated code that drifts over time. We’ll solve this problem in Section 3 with shared queries and version-specific transformers.
Not purely RESTful. REST purists argue that URLs should identify resources, not API versions. /books/book-1 is the resource. The version is metadata about how to represent it. In practice, most teams accept this tradeoff because the simplicity is worth it.
This is exactly how most production APIs handle versioning. The approach is well understood, well documented, and works out of the box with every caching layer and debugging tool.
But it’s not the only option. The next lesson looks at header versioning, where the version disappears from the URL entirely.
Exercises
Exercise 1: Add a /v2/books route that returns the same data as /v1/books for now. Verify both routes work simultaneously.
Exercise 2: Add a /v2/books/:id route. Return a different response shape (nested author). Verify v1 and v2 return different shapes for the same book.
Exercise 3: Call both versions from a client script. Verify the v1 client still works while the v2 client gets the new format.
Why is URL path versioning the most popular approach?