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

Project setup

The scenario

Here’s the situation. You built a book catalog API six months ago. A mobile app and a partner integration both call it daily. It works. People depend on it.

Now you need to evolve the API. You want to change the author field from a flat string to a nested object. You want to restructure ratings. You want to rename fields from snake_case to camelCase. But the existing clients can’t update right away. They need both the old and new formats available at the same time.

Let’s set up the project so we can see exactly what we’re working with.

Create the project

Code along
mkdir versioned-catalog
cd versioned-catalog
npm init -y
npm install @hectoday/http zod srvx better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx

Create tsconfig.json:

Code along
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "./src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"]
}

Add "type": "module" and a dev script to package.json:

Code along
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts"
  }
}

The database

We need some data to work with. Let’s create a SQLite database with authors, books, and reviews:

Code along
// src/db.ts
import Database from "better-sqlite3";

const db = new Database("catalog.db");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

db.exec(`
  CREATE TABLE IF NOT EXISTS authors (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    bio TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS books (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    author_id TEXT NOT NULL,
    genre TEXT NOT NULL,
    description TEXT,
    published_at TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (author_id) REFERENCES authors(id)
  );

  CREATE TABLE IF NOT EXISTS reviews (
    id TEXT PRIMARY KEY,
    book_id TEXT NOT NULL,
    user_id TEXT NOT NULL,
    rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
    body TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (book_id) REFERENCES books(id)
  );
`);

// Seed data (same as the Caching course)
const authorCount = (db.prepare("SELECT COUNT(*) AS c FROM authors").get() as any).c;
if (authorCount === 0) {
  db.prepare("INSERT INTO authors (id, name, bio) VALUES (?, ?, ?)").run(
    "author-1",
    "Ursula K. Le Guin",
    "American novelist",
  );
  db.prepare("INSERT INTO authors (id, name, bio) VALUES (?, ?, ?)").run(
    "author-2",
    "Jorge Luis Borges",
    "Argentine writer",
  );
  db.prepare("INSERT INTO books (id, title, author_id, genre) VALUES (?, ?, ?, ?)").run(
    "book-1",
    "The Left Hand of Darkness",
    "author-1",
    "science-fiction",
  );
  db.prepare("INSERT INTO books (id, title, author_id, genre) VALUES (?, ?, ?, ?)").run(
    "book-2",
    "Ficciones",
    "author-2",
    "fiction",
  );
  db.prepare("INSERT INTO reviews (id, book_id, user_id, rating) VALUES (?, ?, ?, ?)").run(
    "rev-1",
    "book-1",
    "user-1",
    5,
  );
  db.prepare("INSERT INTO reviews (id, book_id, user_id, rating) VALUES (?, ?, ?, ?)").run(
    "rev-2",
    "book-1",
    "user-2",
    4,
  );
}

export default db;

This gives us two authors, two books, and two reviews. Enough to demonstrate everything we need.

The v1 API (the one clients already depend on)

This is the API that has been running for six months. This is what the mobile app and the partner integration call every day:

Code along
// src/v1/routes.ts
import { route } from "@hectoday/http";
import { z } from "zod/v4";
import db from "../db.js";

export const v1Routes = [
  route.get("/v1/books", {
    resolve: () => {
      const books = db
        .prepare(
          `
        SELECT books.id, books.title, authors.name AS author_name,
               books.genre, books.created_at
        FROM books JOIN authors ON books.author_id = authors.id
        ORDER BY books.title
      `,
        )
        .all();
      return Response.json(books);
    },
  }),

  route.get("/v1/books/:id", {
    request: { params: z.object({ id: z.string() }) },
    resolve: (c) => {
      if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
      const { id } = c.input.params;
      const book = db
        .prepare(
          `
        SELECT books.id, books.title, authors.name AS author_name,
               books.genre, books.description, books.created_at,
               (SELECT AVG(rating) FROM reviews WHERE book_id = books.id) AS rating
        FROM books JOIN authors ON books.author_id = authors.id
        WHERE books.id = ?
      `,
        )
        .get(id);
      if (!book) return Response.json({ error: "Not found" }, { status: 404 });
      return Response.json(book);
    },
  }),
];

Now we wire everything up:

Code along
// src/app.ts
import { setup } from "@hectoday/http";
import { v1Routes } from "./v1/routes.js";

export const app = setup({ routes: [...v1Routes] });

Notice something important: the routes already use a /v1/ prefix. That’s intentional. Starting with a version prefix from day one makes adding v2 later much cleaner. If we had started with just /books, adding versioning later would mean either breaking that URL or maintaining both /books and /v2/books forever.

Now create the server file. srvx is a small library that starts an HTTP server from a fetch function:

Code along
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";

serve({ fetch: app.fetch, port: 3000 });

The serve function takes the fetch handler from our app and starts listening on port 3000. Start the server and try it out:

npm run dev
curl http://localhost:3000/v1/books

You should see a list of books. Now try a single book:

curl http://localhost:3000/v1/books/book-1

The response looks like this:

{
  "id": "book-1",
  "title": "The Left Hand of Darkness",
  "author_name": "Ursula K. Le Guin",
  "genre": "science-fiction",
  "rating": 4.5,
  "created_at": "2024-01-15T10:30:00"
}

Notice the shape. Fields are snake_case. The author is a flat string, just the name, with no ID or other details. The rating is a single number, with no count of how many reviews contributed to it. This is the contract that existing clients depend on.

By the end of this course, this API will serve v1 and v2 simultaneously, with deprecation headers on v1 and a sunset timeline. But first, we need to understand the different ways you can put version numbers into an API. That’s what the next section covers.

Exercises

Exercise 1: Start the server. Call GET /v1/books and GET /v1/books/book-1. Note the response shapes. These are the contracts.

Exercise 2: Write a client function that reads author_name and rating from the v1 response. This is what would break if we made v2 changes.

Exercise 3: List the changes you want for v2: nested author object, ratings object with average and count, camelCase field names.

Why does the project start with a v1 prefix on routes?

← The versioning contract URL path versioning →

© 2026 hectoday. All rights reserved.