hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses API versioning and evolution with @hectoday/http

Why versioning

  • Breaking changes
  • The versioning contract
  • Project setup

Versioning strategies

  • URL path versioning
  • Header versioning
  • Query parameter versioning
  • Choosing a strategy

Building versioned APIs

  • Side-by-side versions
  • Version routers
  • Validation per version

Evolving without breaking

  • Additive changes
  • Deprecation
  • Field renaming and removal
  • Changing response shapes

Lifecycle management

  • Sunset policies
  • Monitoring version usage
  • Checklist

Validation per version

Input shapes change too

So far we’ve focused on response shapes: how data goes out. But request shapes, what clients send in, also change between versions. v1 accepts author_id (snake_case), v2 accepts authorId (camelCase). v1 accepts genre as any string, v2 validates it against a specific list.

Each version needs its own Zod schemas.

v1 schemas

Code along
// src/v1/schemas.ts
import { z } from "zod/v4";

export const CreateBookV1 = z.object({
  title: z.string().trim().min(1).max(200),
  author_id: z.string().min(1),
  genre: z.string().min(1),
  description: z.string().max(5000).optional(),
});

export const UpdateBookV1 = CreateBookV1.partial();

export const BookQueryV1 = z.object({
  genre: z.string().optional(),
  sort: z.enum(["title", "created_at"]).default("title"),
});

Look at the field names: author_id with an underscore, matching v1’s snake_case convention. Genre is a free string, so the client can send anything. The sort options use snake_case too.

UpdateBookV1 is created by calling .partial() on the create schema, which makes every field optional. That way, a PATCH request only needs to include the fields being changed.

v2 schemas

Code along
// src/v2/schemas.ts
import { z } from "zod/v4";

export const CreateBookV2 = z.object({
  title: z.string().trim().min(1).max(200),
  authorId: z.string().uuid(),
  genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]),
  description: z.string().max(5000).optional(),
});

export const UpdateBookV2 = CreateBookV2.partial();

export const BookQueryV2 = z.object({
  genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]).optional(),
  sort: z.enum(["title", "createdAt", "rating"]).default("title"),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

Notice the differences. authorId is camelCase and validated as a UUID. In v1, author_id accepted any string. v2 is stricter. Genre is now an enum, not a free string. If the client sends genre: "romance", validation fails because it’s not in the allowed list. Sort options use camelCase. And v2 adds pagination with page and limit, something v1 doesn’t support at all.

[!NOTE] The Zod for Beginners course built schemas with .pick(), .omit(), and .partial() for create/update variants. The same patterns apply here, but each version has its own base schema because the field names and validations differ.

Using schemas in routes

Here’s where it all comes together. Each version uses its own schema to validate the request body:

Code along
// src/v1/routes.ts
import { CreateBookV1 } from "./schemas.js";

route.post("/v1/books", {
  request: { body: CreateBookV1 },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json(
        { error: "Validation failed", details: c.input.issues },
        { status: 400 },
      );
    }
    // c.input.body has: title, author_id, genre, description
    const { body } = c.input;
    const book = createBook({
      title: body.title,
      authorId: body.author_id, // map snake_case to internal
      genre: body.genre,
      description: body.description,
    });
    return Response.json(formatBookV1(book), { status: 201 });
  },
});

// src/v2/routes.ts
import { CreateBookV2 } from "./schemas.js";

route.post("/v2/books", {
  request: { body: CreateBookV2 },
  resolve: (c) => {
    if (!c.input.ok) {
      return Response.json(
        { error: "Validation failed", details: c.input.issues },
        { status: 400 },
      );
    }
    // c.input.body has: title, authorId, genre, description
    const { body } = c.input;
    const book = createBook({
      title: body.title,
      authorId: body.authorId, // already camelCase
      genre: body.genre,
      description: body.description,
    });
    return Response.json(formatBookV2(book), { status: 201 });
  },
});

Both routes call the same createBook function with the same internal shape. The difference is in the mapping. v1’s schema gives us author_id (snake_case), so we map it to authorId (camelCase) for the internal function. v2’s schema already uses authorId, so no mapping is needed.

Try creating a book through v1:

curl -X POST http://localhost:3000/v1/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Parable of the Sower", "author_id": "author-1", "genre": "science-fiction"}'

Now try v2:

curl -X POST http://localhost:3000/v2/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Parable of the Sower", "authorId": "author-1", "genre": "science-fiction"}'

What do you think happens if you send a v1-shaped body to the v2 endpoint? Try it:

curl -X POST http://localhost:3000/v2/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Parable of the Sower", "author_id": "author-1", "genre": "science-fiction"}'

The validation fails. author_id isn’t a valid field in the v2 schema, and authorId is missing. Each version enforces its own contract.

Shared creation logic

The function that actually creates the book doesn’t know or care about versions:

Code along
// src/shared/mutations.ts
import db from "./db.js";
import type { BookRow } from "./queries.js";

interface CreateBookInput {
  title: string;
  authorId: string;
  genre: string;
  description?: string;
}

export function createBook(input: CreateBookInput): BookRow {
  const id = crypto.randomUUID();
  db.prepare(
    "INSERT INTO books (id, title, author_id, genre, description) VALUES (?, ?, ?, ?, ?)",
  ).run(id, input.title, input.authorId, input.genre, input.description ?? null);

  return getBookById(id)!;
}

The internal interface uses a consistent naming convention, camelCase. v1 schemas map author_id to authorId. v2 schemas already use authorId. The shared function receives the same shape regardless of which version called it.

This is the pattern. Version-specific schemas handle the external contract. Shared functions handle the internal logic. The two layers are cleanly separated.

Next, we’ll look at how to evolve an API without creating a new version at all. Not every change needs a version bump.

Exercises

Exercise 1: Create v1 and v2 Zod schemas for book creation. Test that v1 accepts author_id and v2 accepts authorId.

Exercise 2: Send a v1-shaped body to the v2 endpoint. Verify it fails validation (wrong field names).

Exercise 3: Add pagination to the v2 query schema but not v1. Verify v2 supports ?page=2&limit=10 while v1 ignores those params.

Why do v1 and v2 need separate Zod schemas?

← Version routers Additive changes →

© 2026 hectoday. All rights reserved.