Designing clear, well-documented web APIs is a big part of building software. The most widely adopted standard for describing RESTful APIs is OpenAPI, a machine-readable format (usually JSON or YAML) that defines an API’s structure, endpoints, and request and response shapes. That schema becomes the single source of truth behind your interactive docs, client libraries, and server stubs.

However, writing and maintaining OpenAPI specifications by hand can quickly become a chore. The syntax is verbose, and small mistakes in the structure can lead to frustrating bugs or inconsistencies across different parts of the system.

This is where TypeSpec comes in.

TypeSpec is a language from Microsoft for describing APIs in a more developer-friendly way. Instead of wrestling with raw OpenAPI YAML, you write a TypeSpec file that reads more like TypeScript: concise, expressive, and modular. From that one file you generate a complete OpenAPI schema. It sits as an abstraction layer over the schema, and it also drives code generation on both the backend and the frontend.

This post walks through a schema-first approach with TypeSpec and OpenAPI: how it tightens your workflow, keeps systems consistent, and cuts boilerplate. It also covers generating backend server code and frontend client libraries straight from the schema, so every part of the stack speaks the same language.

Project Overview

To demonstrate a schema-first approach to building web APIs, we’ll walk through a small example app structured around a clear separation of concerns. The project is organized into four main folders:

.
├── backend
├── docs
├── frontend
└── schema

At the heart of the project is the schema directory, where we define the API in TypeSpec. Its modular syntax is what keeps the contract manageable as the API grows.

From this source, a script generates a compliant OpenAPI specification. That OpenAPI file is the foundation for the rest of the stack:

  • Backend (backend/): A Go application that uses oapi-codegen to generate server interfaces and type-safe request/response models directly from the OpenAPI schema. This ensures the implementation is always aligned with the contract.
  • Frontend (frontend/): A TypeScript app that uses openapi-typescript to generate a typed client for making API calls. This eliminates the need to hand-write HTTP calls or manually keep types in sync.
  • Documentation (docs/): An interactive HTML file generated using @redocly/cli, offering a polished developer-facing interface to explore and test the API.

Every one of these pieces is tied back to the schema, which is what keeps things consistent and cuts down the boilerplate. The next sections go through each part, starting with the schema definition in TypeSpec.

Defining the Schema with TypeSpec

The TypeSpec schema describes the whole API surface, split across a few files so no single one gets unwieldy. Here’s how that’s laid out.

Schema Structure

The schema is organized into three main parts:

.
├── main.tsp
├── models
│   ├── default.tsp
│   ├── forms.tsp
│   ├── jobs.tsp
│   ├── search.tsp
│   ├── subscriptions.tsp
│   └── users.tsp
└── routes
    ├── forms.tsp
    ├── jobs.tsp
    ├── search.tsp
    ├── subscriptions.tsp
    └── users.tsp

The Entry Point: main.tsp

The main.tsp file acts as the root of the schema. It imports all route definitions and binds them to specific base paths using the @route decorator. It also defines the service metadata, such as the title and server URL.

import "@typespec/http";
import "./routes/jobs.tsp";
import "./routes/users.tsp";
import "./routes/search.tsp";
import "./routes/subscriptions.tsp";
import "./routes/forms.tsp";

using TypeSpec.Http;

@service({
    title: "JobSearch",
})
@server("http://localhost:8080", "API endpoint")
namespace JobSearch;

@route("/jobs")
interface Jobs extends global.Jobs.Routes {}

@route("/users")
interface Users extends global.Users.Routes {}

@route("/search")
interface Search extends global.Search.Routes {}

@route("/subscriptions")
interface Subscriptions extends global.Subscriptions.Routes {}

@route("/forms")
interface Forms extends global.Forms.Routes {}

This setup gives you a clear overview of all the top-level resources your API exposes.

Defining Routes

Each route file in the routes/ folder defines a logical group of endpoints, one per resource. For example, search.tsp describes all the endpoints for user search:

import "@typespec/http";
import "../models/default.tsp";
import "../models/search.tsp";

using TypeSpec.Http;
using DefaultResponse;

namespace Search;

interface Routes {
    @tag("Search")
    @route("")
    @get
    @useAuth(BearerAuth)
    getSearch(): DefaultResponse.Response<SearchModel.Search[]>;

    @tag("Search")
    @route("/subscribed")
    @get
    @useAuth(BearerAuth)
    getSearchSubscribed(): DefaultResponse.Response<SearchModel.Search[]>;

    @tag("Search")
    @route("/byName/{name}")
    @get
    @useAuth(BearerAuth)
    getSearchByName(
        @path name: string,
    ): DefaultResponse.Response<SearchModel.Search>;

    @tag("Search")
    @route("")
    @post
    @useAuth(BearerAuth)
    saveSearch(
        @body saveSearch: SearchModel.SearchRequest,
    ): DefaultResponse.Response<boolean>;

    @tag("Search")
    @route("")
    @delete
    @useAuth(BearerAuth)
    deleteSearch(
        @body name: {
            name: string;
        },
    ): DefaultResponse.Response<boolean>;
}

These route definitions are strongly typed, decorated with HTTP verbs, and linked to shared response types.

Reusable Models

The models/ directory holds all the data structures used throughout the API. These models are imported into the route files to define request and response types.

namespace SearchModel;

model Search {
    name: string;
    companies?: string[];
    countries?: string[];
    states?: string[];
    cities?: string[];
    title?: string;
    subscribed: boolean;
    createDate: string;
}

model SearchRequest {
    name: string;
    companies?: string[];
    countries?: string[];
    states?: string[];
    cities?: string[];
    title?: string;
    subscribed: boolean;
}

By defining models in one place and reusing them across routes, you ensure consistency across the entire schema and reduce duplication.

Why This Structure Works

Splitting things this way pays off as the API grows. New routes and models go in their own files instead of swelling one giant schema. Models get shared across routes, and common response shapes like wrapped responses or error formats can be factored out once. And when you need to change something, you can find the relevant slice of the API without reading through everything around it.

Once the schema is defined, generating the OpenAPI document is straightforward. After installing TypeSpec following the official documentation, you can compile the schema with a single command:

tsp compile .

This produces an OpenAPI-compliant JSON file, which is the source of truth for everything else in the application: the backend logic, the frontend clients, and the documentation.

Backend Implementation

The backend is written in Go, and it builds directly on the generated OpenAPI schema for its types and its request validation.

Generating Go Code with oapi-codegen

Once the OpenAPI schema is generated from the TypeSpec source, we use oapi-codegen to generate Go server boilerplate. This tool creates:

  • Type-safe request and response models
  • Interface definitions for handlers
  • Validation logic for incoming requests based on the schema

This means there’s no need to manually parse JSON, check required fields, or write boilerplate validation code.

The following code is used to generate the server:

./oapi-codegen -package generated  -generate "std-http-server, models, strict-server" schema/tsp-output/\@typespec/openapi3/openapi.yaml > backend/generated/server.go

Compiler-Enforced Contracts

Once the implementation is driven by the schema, the compiler does a lot of the work you’d otherwise do by hand. Inputs are validated and unmarshaled before they reach your logic. The request and response types are all generated. And because only the responses defined in the schema will compile, you can’t accidentally return something the contract doesn’t allow.

That removes a whole class of runtime bugs and keeps the backend from drifting away from the schema.

Focus on Business Logic

With all the plumbing generated automatically, developers can focus on what really matters: the business logic. Here is one example of a handler using the generated request and response types:

func (s *Server) SearchGetSearch(ctx context.Context, request generated.SearchGetSearchRequestObject) (generated.SearchGetSearchResponseObject, error) {
    userContext := helpers.UserContextFromContext(ctx)
    searches, err := s.models.SearchModel.GetSearch(userContext.Email)
    if err != nil || searches == nil {
        return generated.SearchGetSearch200JSONResponse{
            Error:      helpers.SearchNotFoundErrror,
            StatusCode: 404,
            Data:       []generated.SearchModelSearch{},
        }, nil
    }
    searchesResponse := make([]generated.SearchModelSearch, len(searches))
    for i, search := range searches {
        searchesResponse[i] = generated.SearchModelSearch{
            Name:       search.Name,
            Companies:  helpers.ArrayStringFromPossibleNullString(search.Companies),
            Countries:  helpers.ArrayStringFromPossibleNullString(search.Countries),
            States:     helpers.ArrayStringFromPossibleNullString(search.States),
            Cities:     helpers.ArrayStringFromPossibleNullString(search.Cities),
            Title:      search.Title,
            Subscribed: search.Subscribed,
            CreateDate: search.CreateDate,
        }
    }
    return generated.SearchGetSearch200JSONResponse{
        StatusCode: 200,
        Data:       searchesResponse,
        Error:      generated.DefaultResponseError{},
    }, nil
}

Only the methods defined in the generated interface need an implementation. The rest, including validation, decoding, and even documentation, is already handled.

Frontend Integration

The frontend is a TypeScript application that consumes the API through a fully typed, auto-generated client. We use openapi-typescript to generate this client directly from the OpenAPI schema.

Schema-Driven Client Generation

After generating the OpenAPI schema, we run:

npx openapi-typescript --path-params-as-types -t true schema/tsp-output/\@typespec/openapi3/openapi.yaml -o frontend/src/generated/schema.ts

This produces a full set of typed interfaces and client scaffolding for calling the API. There’s no need to hand-write fetch requests or keep a duplicate copy of the types. The whole contract between frontend and backend is enforced by the compiler.

What this buys you:

  • Every request and response is fully typed, which catches mistakes early.
  • The frontend and backend can’t drift apart, because both come from the same schema.
  • You don’t write custom fetch logic per endpoint. The generated client handles paths, query params, and bodies for you.

Example Usage

Once the client is generated, using it is as simple as calling a method:

const { data: requestData, error } = await apiClient.GET("/jobs/apply", {
  headers: { Authorization: `Bearer ${user.token}` },
});

The client exposes all defined routes as methods, with strict typings for parameters and return values. If the schema changes, TypeScript will let you know exactly what to update.

API Documentation

The last piece is developer-facing documentation. From the same OpenAPI schema, we can generate interactive docs with almost no extra effort, using @redocly/cli, the command-line tool from the people behind Redoc.

Generating the Docs

Once the OpenAPI schema is available, generating a static HTML documentation page is as simple as:

npx @redocly/cli build-docs schema/tsp-output/\@typespec/openapi3/openapi.yaml
mv redoc-static.html docs/index.html

This produces a standalone HTML file with a fully interactive interface. It lists every endpoint grouped by tag, with the parameters, request bodies, and response types all pulled straight from the schema.

Why this is worth it:

  • The docs never go stale, because they’re built from the same schema the backend and frontend use.
  • Developers can browse endpoints and view example requests and responses without reaching for another tool.
  • It’s a single HTML file, so you can host it on GitHub Pages, a CDN, or alongside your app’s static assets.

The result is an API that’s well-typed and maintainable and also easy for your team or an outside consumer to pick up. All of it traces back to one source of truth: the schema.

Wrapping Up

Starting from a TypeSpec schema and generating everything else from it gives you a stack with a few real properties worth naming.

It’s typed end to end. The frontend request payloads and the backend response handlers are both backed by the same types, so a mismatch that used to be a runtime bug now fails at compile time in both TypeScript and Go.

The schema is the only source of truth. There’s no second copy of the types or the docs to keep in sync, because the API surface, the contracts, and the documentation all come from one TypeSpec definition. Backend, frontend, and outside consumers are all reading the same thing.

That’s also what keeps everything in sync. The backend and frontend are generated from the same OpenAPI file, so when the schema changes the compiler tells you exactly what to fix. There’s no quiet drift between the two.

And it cuts the boilerplate. oapi-codegen handles validation and unmarshalling on the server, openapi-typescript handles typing and routing on the client, and Redoc gives you an interactive reference that’s always current and ships as a single static HTML file. What’s left over is the part you actually wanted to spend time on: the features.

I’ve found this pays off whether the API is small or large. Once the schema is the thing you maintain, the rest of the lifecycle gets a lot less tedious.