Versioning
Your API is a contract, remember?
Back in the first lesson, we said your API is a promise to every developer who uses it. They wrote code against your endpoints. Their apps depend on the response shape. Their users depend on those apps.
Now imagine you need to change something. Maybe you want to embed the author object inside book responses instead of just returning authorId. That changes the response shape. Every consumer that expects authorId to be a string at the top level would need to update their code.
How do you evolve the API without breaking everyone who already uses it? That’s what versioning is for.
URL versioning
The most common approach puts the version right in the URL path:
GET /v1/books
GET /v2/books import { books, authors } from "../db.js";
// src/routes/v1/books.ts
route.get("/v1/books", {
resolve: () => {
// v1 format: authorId as a string
return Response.json({ data: books });
},
});
// src/routes/v2/books.ts
route.get("/v2/books", {
resolve: () => {
// v2 format: author embedded as an object, authorId removed
const enriched = books.map((book) => {
const author = authors.find((a) => a.id === book.authorId);
const { authorId, ...rest } = book;
return { ...rest, author: author ? { id: author.id, name: author.name } : null };
});
return Response.json({ data: enriched });
},
}); v1 consumers keep using /v1/books and nothing changes for them. New consumers use /v2/books and get the improved format. Everyone migrates at their own pace.
This is the approach that GitHub, Stripe, and Twilio use. It’s obvious, easy to route, and easy to test. The version is visible in every URL, so there’s no ambiguity about which version a client is using.
The downside: you’re maintaining two sets of routes. For a small API, that’s manageable. For a large API with dozens of endpoints, it can become a maintenance burden.
Header versioning
Instead of putting the version in the URL, some APIs put it in a header:
GET /books
Accept: application/vnd.bookstore.v2+json Or with a custom header:
GET /books
API-Version: 2 The URLs stay clean. /books is always /books, regardless of the version. But the tradeoff is that you can’t just paste a URL into a browser to test it. You need to include the header, which makes testing and debugging less convenient.
No versioning at all
There’s a third approach: never version. Instead, make only additive changes. Never remove fields. Never rename them. Never change their types. Just add new fields alongside old ones.
// Before: v1 behavior
{ "id": "book-1", "title": "...", "authorId": "author-1" }
// After: author added, authorId kept for backward compatibility
{ "id": "book-1", "title": "...", "authorId": "author-1", "author": { "id": "author-1", "name": "..." } } Old clients ignore the new author field because they don’t know about it. New clients can use it. Nobody breaks.
This works well for internal APIs where you control all the consumers. The downside: fields accumulate over time. You can’t fix design mistakes, and the API can get cluttered with deprecated fields that you’re afraid to remove.
Which approach should you use?
For most APIs: URL versioning. It’s the simplest to implement, the most visible, and the most widely understood.
For internal APIs: no versioning with additive changes. You control all the consumers, so coordination is easier.
For APIs that prioritize URL cleanliness: header versioning. But only if your consumers are sophisticated enough to set custom headers.
What actually needs a version bump?
Not every change requires a new version. The key question is: does this change break existing consumers?
Breaking changes (bump the version): removing a field, renaming a field, changing a field’s type, changing the response structure, removing an endpoint, changing how authentication works.
Non-breaking changes (no version bump): adding a new field to a response, adding a new endpoint, adding a new optional query parameter, adding a new value to an existing enum.
The difference comes down to this: a well-written client ignores fields it doesn’t recognize. So adding a new field is safe. But removing or renaming a field that clients depend on will break them.
This is exactly why we said earlier to ignore unknown query parameters and unknown fields. When your API and your consumers both follow this principle, additive changes are always safe.
What’s next
Versioning controls how the API evolves over time. But there’s another kind of negotiation that happens on every request: the data format. Most APIs just return JSON, and that’s fine. But sometimes clients need CSV exports, or you need to validate what format the request body is in. That’s content negotiation.
Exercises
Exercise 1: Create /v1/books and /v2/books with different response formats. v1 returns authorId, v2 returns an embedded author object.
Exercise 2: Add a deprecation header to v1: Deprecation: true and Sunset: 2025-06-01. This tells consumers that v1 will be removed.
Exercise 3: Think about an API change you need to make. Is it breaking or non-breaking? Does it need a version bump?
Why is adding a new field to a response not a breaking change?