JSON Schema for API Documentation and OpenAPI

Last updated:

OpenAPI is the industry standard for describing REST APIs, and JSON Schema is its schema language. Since OpenAPI 3.1, the two are fully unified — every JSON Schema keyword works inside an OpenAPI spec. This guide covers how to write schemas in components/schemas, reuse them with $ref, handle nullable fields, generate TypeScript types, and host interactive docs.

OpenAPI 3.1 and JSON Schema

OpenAPI 3.0 included a subset of JSON Schema Draft-04 with proprietary extensions like nullable and a modified exclusiveMinimum. OpenAPI 3.1 dropped those extensions and adopted JSON Schema Draft 2020-12 wholesale. This means any valid JSON Schema is a valid OpenAPI 3.1 schema object.

# openapi.yaml
openapi: "3.1.0"
info:
  title: User API
  version: "1.0.0"
paths:
  /users:
    get:
      summary: List users
      responses:
        "200":
          description: A list of users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/User"
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        name:
          type: string
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - email

The components/schemas block is where all reusable schemas live. The path item references the User schema with $ref rather than inlining the definition, keeping the spec DRY and the path items readable.

Defining Schemas in components/schemas

components/schemas is a flat map of schema name to schema object. Names are PascalCase by convention. Each schema is a full JSON Schema — you can use $defs, allOf, oneOf, if/then, and every other keyword.

components:
  schemas:
    # Request schema — no server-set fields
    CreateUserRequest:
      type: object
      properties:
        email:
          type: string
          format: email
          maxLength: 254
        name:
          type: string
          minLength: 1
          maxLength: 100
        plan:
          type: string
          enum: [free, pro, enterprise]
          default: free
      required:
        - email
        - name
      additionalProperties: false

    # Response schema — includes server-set fields
    User:
      type: object
      properties:
        id:
          type: string
          pattern: "^usr_[0-9a-zA-Z]{16}$"
        email:
          type: string
          format: email
        name:
          type: string
        plan:
          type: string
          enum: [free, pro, enterprise]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - email
        - name
        - plan
        - createdAt
        - updatedAt

    # Error envelope — reused across all error responses
    ApiError:
      type: object
      properties:
        code:
          type: string
          examples:
            - validation_error
            - not_found
            - unauthorized
        message:
          type: string
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              message:
                type: string
            required:
              - field
              - message
      required:
        - code
        - message

Separating request and response schemas is important. Request schemas typically omit id, createdAt, and other server-generated fields, and may add additionalProperties: false for strict input validation. Response schemas include all fields the API returns.

Request Bodies and Response Schemas

Every path operation can have a requestBody (for POST, PUT, PATCH) and one or more responses. Each uses the same JSON Schema under the content media type key.

paths:
  /users:
    post:
      summary: Create a user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
            # Example shown in Swagger UI / Redoc
            example:
              email: "alice@example.com"
              name: "Alice"
              plan: "pro"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "409":
          description: Email already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  /users/{id}:
    patch:
      summary: Partially update a user
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserRequest"
      responses:
        "200":
          description: Updated user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

Define a distinct UpdateUserRequest schema for PATCH endpoints. PATCH bodies make all fields optional and require at least one to be present using minProperties: 1.

components:
  schemas:
    UpdateUserRequest:
      type: object
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        plan:
          type: string
          enum: [free, pro, enterprise]
      minProperties: 1
      additionalProperties: false

Schema Reuse with $ref

$ref can point to schemas within the same file (using a JSON Pointer fragment) or to external files. For large APIs, split schemas into separate files and use a bundler.

# Internal $ref — points to the root document
$ref: "#/components/schemas/User"

# External $ref — relative file path
$ref: "./schemas/user.yaml"

# External $ref with JSON Pointer into the file
$ref: "./schemas/common.yaml#/components/schemas/Address"

# Cross-file composition with allOf
components:
  schemas:
    AdminUser:
      allOf:
        - $ref: "#/components/schemas/User"
        - type: object
          properties:
            adminLevel:
              type: integer
              minimum: 1
              maximum: 5
            permissions:
              type: array
              items:
                type: string
          required:
            - adminLevel

In OpenAPI 3.1, $ref can have sibling keywords such as description and summary. This is a change from 3.0 where siblings were silently ignored. Use it to provide context-specific descriptions for referenced schemas without duplicating the schema itself.

# OpenAPI 3.1 — $ref with sibling description
responses:
  "200":
    description: The authenticated user
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/User"
          description: "The currently authenticated user object"
          # In 3.1 this description overrides the referenced schema's description

Nullable and Optional Fields in OpenAPI

Nullability is a common point of confusion when migrating from OpenAPI 3.0 to 3.1. The proprietary nullable: true keyword is replaced by the standard JSON Schema type array syntax.

components:
  schemas:
    UserProfile:
      type: object
      properties:
        id:
          type: string

        # Optional field (not in "required") — can be omitted from the object
        bio:
          type: string

        # Nullable field (must be present but can be null)
        # OpenAPI 3.0 style (avoid in 3.1 specs):
        # avatarUrl:
        #   type: string
        #   nullable: true
        #
        # OpenAPI 3.1 / JSON Schema 2020-12 style:
        avatarUrl:
          type: ["string", "null"]
          format: uri

        # Optional AND nullable (can be omitted or present as null)
        # Not in "required" AND type includes null:
        deletedAt:
          type: ["string", "null"]
          format: date-time

        # Nullable reference type
        organization:
          oneOf:
            - $ref: "#/components/schemas/Organization"
            - type: "null"

      required:
        - id
        - avatarUrl    # present, but can be null
        # bio and deletedAt are optional (not required)
        # organization is optional

The key distinction: optional means the property key may be absent from the object (controlled by required). Nullable means the property key is present but its value may be null (controlled by the type array). A field can be optional, nullable, both, or neither independently.

TypeScript Code Generation

openapi-typescript generates TypeScript types directly from an OpenAPI spec with a single CLI command — no runtime dependency.

# Install
npm install -D openapi-typescript

# Generate types from a local file
npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts

# Generate from a URL (useful for API-first workflows)
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.d.ts

# Add to package.json scripts
{
  "scripts": {
    "generate:types": "openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts"
  }
}

The generated file exports two main types: paths (every endpoint with its request/response shapes) and components (all schemas from components/schemas).

// Import generated types
import type { paths, components } from "./types/api"

// Extract a schema type
type User = components["schemas"]["User"]
type ApiError = components["schemas"]["ApiError"]
type CreateUserRequest = components["schemas"]["CreateUserRequest"]

// Extract endpoint-specific types
type CreateUserResponse =
  paths["/users"]["post"]["responses"]["201"]["content"]["application/json"]

// Use with openapi-fetch for a fully typed fetch client
import createClient from "openapi-fetch"
import type { paths } from "./types/api"

const client = createClient<paths>({ baseUrl: "https://api.example.com" })

// TypeScript infers request body shape and response type automatically
const { data, error } = await client.POST("/users", {
  body: {
    email: "alice@example.com",  // TypeScript enforces CreateUserRequest shape
    name: "Alice",
  },
})
// data is typed as User, error is typed as ApiError

Pair openapi-typescript with openapi-fetch (from the same project) for a typed HTTP client that enforces the correct request body shape and narrows the response type at compile time — without any runtime overhead beyond a thin fetch wrapper.

Hosting Docs with Swagger UI and Redoc

Both Swagger UI and Redoc take your openapi.yaml or openapi.json file and render it to an interactive HTML page. The simplest deployment is a static HTML file that loads the library from a CDN.

<!-- Swagger UI via CDN -->
<!DOCTYPE html>
<html>
<head>
  <title>API Docs</title>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" type="text/css"
    href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" >
</head>
<body>
  <div id="swagger-ui"></div>
  <script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
  <script>
    SwaggerUIBundle({
      url: "/openapi.json",       // serve your spec file
      dom_id: '#swagger-ui',
      presets: [
        SwaggerUIBundle.presets.apis,
        SwaggerUIBundle.SwaggerUIStandalonePreset
      ],
      layout: "StandaloneLayout"
    })
  </script>
</body>
</html>
<!-- Redoc via CDN -->
<!DOCTYPE html>
<html>
<head>
  <title>API Reference</title>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
  <redoc spec-url='/openapi.json'></redoc>
  <script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body>
</html>

For Next.js projects, serve the spec file from the public directory (public/openapi.json) and host the HTML page as a static route. The next-swagger-doc package can also generate the spec automatically from JSDoc annotations in your route handlers.

# Express.js — serve Swagger UI as middleware
npm install swagger-ui-express

import swaggerUi from "swagger-ui-express"
import yaml from "js-yaml"
import fs from "fs"

const spec = yaml.load(fs.readFileSync("./openapi.yaml", "utf8"))

app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(spec))
// Visit http://localhost:3000/api-docs

Definitions

OpenAPI
A specification (formerly Swagger) for describing REST APIs in a machine-readable format (YAML or JSON). Defines endpoints, parameters, request/response bodies, authentication, and schemas. Version 3.1 fully adopts JSON Schema Draft 2020-12 as its schema language.
components/schemas
The schema library section of an OpenAPI document. A flat map of schema name to JSON Schema object. Schemas defined here are inert until referenced with $ref: "#/components/schemas/Name" from path items, request bodies, or response definitions.
$ref
A JSON Reference that substitutes a schema defined elsewhere. Within a file, uses a JSON Pointer fragment like #/components/schemas/User. Can point to external files with a relative path. In OpenAPI 3.1, $ref can coexist with sibling keywords like description.
openapi-typescript
A CLI tool and library that generates TypeScript type declarations from an OpenAPI 3.x spec. Produces a .d.ts file exporting paths and components types with no runtime dependency. Pairs with openapi-fetch for a fully typed HTTP client.
Swagger UI
An open-source web UI that renders an OpenAPI spec as interactive HTML documentation. Provides a "Try it out" feature for executing API requests from the browser. Distributed as a CDN bundle, npm package, or Docker image. Maintained by SmartBear.

FAQ

How does OpenAPI 3.1 differ from OpenAPI 3.0 in its use of JSON Schema?

OpenAPI 3.0 used a modified subset of JSON Schema Draft-04 with proprietary keywords like nullable: true. OpenAPI 3.1 adopts JSON Schema Draft 2020-12 in full, removing all proprietary extensions. The main migration changes: replace nullable: true with type: ["string", "null"], update exclusiveMinimum/exclusiveMaximum from booleans to numbers, and note that $ref siblings (like description) now work as expected. All JSON Schema composition keywords (allOf, oneOf, anyOf, if/then/else) behave identically to the JSON Schema spec with no modifications.

What goes in components/schemas and how do I reference those schemas?

Any schema you want to reuse: request bodies, response shapes, shared sub-objects like Address or Money, error envelopes, and pagination wrappers. Reference them from path items with $ref: "#/components/schemas/Name". External references to other files ($ref: "./schemas/user.yaml") are also valid and useful for splitting large specs. Bundlers like Redocly CLI inline external $ref values into a single file for tooling that requires a self-contained spec.

How do I define request body and response schemas in OpenAPI?

Request bodies go under requestBody > content > application/json > schema. Responses go under responses > {statusCode} > content > application/json > schema. Best practice: define the schema in components/schemas and use $ref in the path item. Always define separate request and response schemas — request schemas omit server-generated fields (id, createdAt) and PATCH schemas make all fields optional with minProperties: 1.

How does $ref work in OpenAPI schemas?

$ref is a JSON Pointer that resolves to a schema defined elsewhere. #/components/schemas/User means: start at the root of the current document (#), then navigate to components, then schemas, then User. In OpenAPI 3.1, sibling keywords like description next to a $ref are honored — they override or supplement the referenced schema's own description. Cross-file $ref values use relative file paths and optionally a fragment to point into a specific location within that file.

How do I represent nullable fields in OpenAPI 3.1?

Use the standard JSON Schema array type syntax: type: ["string", "null"]. For nullable reference types, use oneOf: [{ $ref: "..." }, { type: "null" }]. Avoid the OpenAPI 3.0 nullable: true extension in 3.1 specs — it is not part of the 3.1 specification. Remember the distinction: optional (property key absent, not in required) versus nullable (property key present but value is null, controlled by type). A field can be optional without being nullable and vice versa.

How do I generate TypeScript types from an OpenAPI spec?

Install openapi-typescript as a dev dependency and run npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts. The generated file exports paths (all endpoints) and components (all schemas). Extract schema types with type User = components["schemas"]["User"]. Pair with openapi-fetch for a typed HTTP client where TypeScript infers request body shape and response type from the spec automatically — no manual type annotation needed.

What is the difference between Swagger UI and Redoc for rendering OpenAPI docs?

Swagger UI uses an accordion layout and includes a "Try it out" button for executing API calls in the browser — ideal for internal developer portals and API testing. Redoc uses a three-column layout (navigation, documentation, code samples) optimized for reading and handles deeply nested schemas more gracefully — preferred for public-facing API reference docs. Both accept a URL to your openapi.yaml or openapi.json file. Redoc's "Try it out" feature is an Enterprise add-on; the open-source version is read-only.

How do I add authentication schemes to an OpenAPI spec?

Define schemes under components/securitySchemes: Bearer JWT uses type: http, scheme: bearer, bearerFormat: JWT; API key uses type: apiKey, in: header, name: X-API-Key; OAuth2 uses type: oauth2 with flows. Apply globally with a root-level security array or per-operation. Override with security: [] on individual operations (like a public health check endpoint) to opt out of the global requirement. Swagger UI renders a padlock icon and "Authorize" dialog that stores credentials for Try-it-out requests.

Further reading and primary sources