hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses API documentation with OpenAPI and @hectoday/http

Why documentation

  • Undocumented APIs are unusable
  • The OpenAPI standard
  • Project setup

Describing your API

  • Paths and operations
  • Request parameters
  • Request bodies
  • Responses

Schemas and reuse

  • Component schemas
  • Zod to OpenAPI
  • Error schemas

Beyond endpoints

  • Authentication documentation
  • Tags and grouping
  • Examples and descriptions

Serving and consuming

  • Serving the spec
  • Interactive docs with Scalar
  • Generating client SDKs

Wrapping up

  • Versioned specs
  • Checklist

Component schemas

If you have been looking at the generated spec closely, you might have noticed something: the same Book schema appears in multiple places. It shows up in the GET /v2/books response (inside the array items), in the GET /v2/books/{id} response, and in the POST /v2/books response. Three full copies of the same structure.

Let’s look at this duplication and understand how the broader OpenAPI ecosystem handles it.

Spotting the duplication

Pull up the response schemas for two different endpoints and compare them:

curl http://localhost:3000/openapi.json | jq '.paths["/v2/books/{id}"].get.responses["200"].content["application/json"].schema'
{
  "type": "object",
  "properties": {
    "id": { "type": "string", "format": "uuid" },
    "title": { "type": "string" },
    "author": {
      "type": "object",
      "properties": {
        "id": { "type": "string", "format": "uuid" },
        "name": { "type": "string" }
      },
      "required": ["id", "name"],
      "additionalProperties": false
    },
    "genre": {
      "type": "string",
      "enum": ["fiction", "science-fiction", "fantasy", "non-fiction", "other"]
    },
    "ratings": {
      "type": "object",
      "properties": {
        "average": {
          "anyOf": [{ "type": "number" }, { "type": "null" }]
        },
        "count": { "type": "integer" }
      },
      "required": ["average", "count"],
      "additionalProperties": false
    },
    "createdAt": { "type": "string", "format": "date-time" }
  },
  "required": ["id", "title", "author", "genre", "ratings", "createdAt"],
  "additionalProperties": false
}

Now check the POST /v2/books response:

curl http://localhost:3000/openapi.json | jq '.paths["/v2/books"].post.responses["201"].content["application/json"].schema'

You will see the exact same schema. And if you dig into the GET /v2/books response, you will find it again inside the items of the data array. Three identical copies.

This works. The spec is valid and every tool can read it. But it means that if the Book shape ever changed, the spec would need to change in three places. In our case, @hectoday/openapi regenerates the entire spec from your Zod schemas every time the server starts, so the duplication is handled automatically. But it is still worth understanding the alternative, because you will encounter it when reading specs from other APIs.

How OpenAPI solves duplication: components and $ref

The OpenAPI standard includes a section called components.schemas specifically for this problem. Instead of repeating a schema inline in every operation, you define it once in components.schemas and reference it from each operation using a $ref pointer.

Here is what the same spec would look like if it used components:

{
  "paths": {
    "/v2/books/{id}": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Book" }
              }
            }
          }
        }
      }
    },
    "/v2/books": {
      "post": {
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Book" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Book": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "title": { "type": "string" }
        },
        "required": ["id", "title", "author", "genre", "ratings", "createdAt"]
      }
    }
  }
}

The $ref is a pointer. It says: “The schema for this response is defined at #/components/schemas/Book.” Any tool reading the spec follows the pointer and finds the full schema definition. The result is the same as if the schema were inline, but without the duplication.

How $ref works

The $ref syntax is a JSON Pointer path through the document:

$ref: "#/components/schemas/Book"
       |  |          |       |
       |  |          |       +-- Schema name
       |  |          +-- Section (schemas, parameters, responses, etc.)
       |  +-- Top-level key
       +-- Current document

The # means “this document.” Then you follow the path through the JSON structure: components, then schemas, then Book. When a tool encounters a $ref, it replaces it with the full definition from that location.

Component schemas can also reference other component schemas. For example, a BookList schema might reference Book in its items:

{
  "BookList": {
    "type": "object",
    "properties": {
      "data": {
        "type": "array",
        "items": { "$ref": "#/components/schemas/Book" }
      },
      "meta": {
        "type": "object",
        "properties": {
          "page": { "type": "integer" },
          "limit": { "type": "integer" },
          "total": { "type": "integer" }
        },
        "required": ["page", "limit", "total"]
      }
    },
    "required": ["data", "meta"]
  }
}

This is how you build up complex types from smaller pieces, the same way you compose objects in code.

Why our spec uses inline schemas

@hectoday/openapi currently inlines all schemas rather than using components.schemas and $ref references. Each operation contains its full schema definition. This means the raw JSON is larger, but every operation is self-contained and you do not need to follow pointers to understand what an endpoint returns.

The trade-off is straightforward. Inlined schemas are easier to read in isolation (each endpoint tells the whole story), but they are more verbose. Component schemas are more concise and enforce consistency, but you have to follow references to see the full picture.

Since @hectoday/openapi regenerates the spec from your Zod schemas on every server start, the duplication is not a maintenance problem. Your Zod schemas are the single source of truth. If you change BookSchema, every operation that uses it gets the updated schema automatically.

Reading other APIs’ specs

When you read OpenAPI specs from other APIs or tools, you will almost certainly encounter $ref and components.schemas. It is the standard way to organize specs, and most OpenAPI generators use it. Now you know how to read those references: follow the $ref path to find the schema definition in components.schemas.

Understanding both styles (inline and component-based) means you can work with any OpenAPI spec you encounter, whether it comes from @hectoday/openapi, another code-generation tool, or a hand-written spec.

In the next lesson, we will look at the full Zod-to-OpenAPI mapping in detail and understand how every Zod type translates.

Exercises

Exercise 1: Run curl http://localhost:3000/openapi.json | jq and find every place where the Book schema appears. Count how many times the same structure is repeated.

Exercise 2: Look at a public API’s OpenAPI spec (like the GitHub API or Stripe API). Find examples of $ref references and trace them back to components.schemas.

Exercise 3: Take the Book schema from our generated spec and imagine writing it as a component schema with $ref references. How many operations would reference it?

Why would you want to use component schemas with $ref instead of inlining schemas?

← Responses Zod to OpenAPI →

© 2026 hectoday. All rights reserved.