Changing response shapes
Structural changes
The previous lesson covered field-level changes: renaming a field, removing a field. This lesson tackles something bigger. What happens when you need to change the entire structure of a response? Wrapping an array in an object, changing how pagination works, switching error formats. These changes affect how clients parse the entire response, not just one field.
Wrapping responses
v1 returns a bare array:
[
{ "id": "book-1", "title": "Kindred" },
{ "id": "book-2", "title": "Ficciones" }
] v2 wraps it in an object with metadata:
{
"data": [
{ "id": "book-1", "title": "Kindred" },
{ "id": "book-2", "title": "Ficciones" }
],
"meta": { "total": 2, "page": 1, "limit": 20 }
} What do you think happens to a client that does response.json().map(...)? It crashes. The response used to be an array, so .map() worked. Now it’s an object, and objects don’t have a .map() method. This is a breaking change that belongs in a new major version.
// v1: bare array
route.get("/v1/books", {
resolve: () => Response.json(formatBooksV1(getAllBooks())),
});
// v2: wrapped with metadata
route.get("/v2/books", {
resolve: (c) => {
const query = parseV2Query(c);
const { books, total } = getBooksPaginated(query.page, query.limit, query.genre);
return Response.json({
data: formatBooksV2(books),
meta: {
total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(total / query.limit),
},
});
},
}); [!NOTE] The REST API Design course established the
{ data, meta }envelope pattern for list endpoints. If you adopted this from the start, you wouldn’t need a version change to add pagination. This is one of those design decisions that pays off later.
Changing pagination
v1 uses offset-based pagination with custom headers:
X-Total-Count: 42
X-Page: 1
X-Per-Page: 20 v2 moves to cursor-based pagination in the response body:
{
"data": [
/* ... */
],
"meta": {
"cursor": "eyJpZCI6ImJvb2stMjAifQ",
"hasMore": true
}
} This is a fundamental change in how pagination works. The client needs to understand cursors instead of page numbers. The pagination data moved from headers to the response body. This requires a new version.
Changing error formats
v1 returns errors as simple strings:
{ "error": "Book not found" } v2 returns structured errors:
{
"error": {
"code": "NOT_FOUND",
"message": "Book not found",
"details": { "resource": "book", "id": "book-999" }
}
} A client that checks response.error === "Book not found" (string comparison) breaks when the error becomes an object. "Book not found" === { code: "NOT_FOUND", ... } is always false.
// v1 error handler
function handleV1Error(status: number, message: string): Response {
return Response.json({ error: message }, { status });
}
// v2 error handler
function handleV2Error(status: number, code: string, message: string, details?: unknown): Response {
return Response.json({ error: { code, message, details } }, { status });
} [!NOTE] The Error Handling course’s structured error format (code, message, details) is the v2 approach. If you adopt structured errors from the start, you avoid a version change later. This is why designing good conventions early, as covered in the REST API Design course, pays off.
The lesson: design for evolution
The best way to avoid breaking changes is to design the API well from the start.
Wrap list responses in { data: [...], meta: {...} } from day one. Adding pagination later doesn’t change the shape, because you’re just adding fields to meta.
Use structured errors from day one. Adding error codes or extra details later is additive.
Use objects for complex values from day one. A single number like { rating: 4.5 } might eventually need to become { rating: { average: 4.5, count: 12 } }. But if you start with an object like { ratings: { average: 4.5 } }, adding count is just adding a field to an existing object. That’s additive and safe.
These aren’t just nice-to-haves. They’re the difference between being able to evolve your API smoothly and having to create a new version every time you want to add a feature.
We now know how to evolve an API within a version and across versions. But there’s still a question we haven’t answered: when can you actually turn off the old version? The next section covers the lifecycle of a version, from sunset policies to usage tracking.
Exercises
Exercise 1: Wrap a v2 list response in { data, meta }. Compare with v1’s bare array. Note why this is a breaking change.
Exercise 2: Implement different error formats for v1 (string) and v2 (structured). Return the same 404 with both formats.
Exercise 3: Design a response shape for a new endpoint that is easy to extend later. What structures would make future changes additive?
Why should list responses be wrapped in an object from the start?