Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Publish API documentation from a TypeSpec definition

Compile TypeSpec to OpenAPI in the same project as your docs, turn doc comments, summaries, and examples into a page per operation, and fail CI when the committed spec goes stale.

By 16 min read

To publish documentation from a TypeSpec API definition, compile it to OpenAPI with the @typespec/openapi3 emitter, write the file beside your Blume config, and mount it with openapi(). TypeSpec and Blume both run on Node.js, so they can share one package.json, and one npm run build compiles the definition and builds the site from it.

By the end of this guide, you have a TypeSpec definition whose doc comments, summaries, and examples fill the reference, a page per operation grouped by tag, a quickstart written beside it, and a CI check that fails when the committed spec no longer matches the definition. The example starts from TypeSpec's own REST template, the Widget Service, and was tested with TypeSpec 1.16.0.

Blume never reads your .tsp files. Every page comes from the OpenAPI document the emitter writes, so the decorators you add in TypeSpec are where the docs get better. If you already have an OpenAPI file and no TypeSpec, start with Generate API docs from an OpenAPI spec instead.

How the pieces fit

The API definition and the docs live in one folder, as one Node.js project:

acme-widgets/
├── main.tsp          the API definition
├── tspconfig.yaml    where the OpenAPI emitter writes
├── openapi.json      written by tsp compile, read by Blume
├── blume.config.ts
├── docs/
│   ├── index.mdx
│   └── quickstart.mdx
└── package.json      TypeSpec and Blume, side by side

tsp compile . turns main.tsp into openapi.json. Blume reads that file at build time and renders a page for each operation under /api, next to the Markdown pages in docs/. Blume's content lives in docs/, so the TypeSpec files at the root never become pages.

Start the project

If you already have a TypeSpec project, run the last three commands from its root and skip the first two. Otherwise, create one from TypeSpec's REST template and add Blume to it:

mkdir acme-widgets && cd acme-widgets
npx --package=@typespec/compiler@1.16.0 tsp init --template rest --no-prompt
npm uninstall @typespec/rest
npm install --save-exact @typespec/compiler@1.16.0 @typespec/http@1.16.0 @typespec/openapi@1.16.0 @typespec/openapi3@1.16.0
npx blume init . --template docs --yes
npm install blume
  • tsp init writes main.tsp (the Widget Service), tspconfig.yaml, package.json, and a .gitignore, then installs the TypeSpec packages. npx --package runs it without a global install of the compiler.
  • The template lists @typespec/rest, which this API never imports, so the next line removes it.
  • The install pins the compiler and emitters to exact versions. The CI check later in this guide compares the spec byte for byte, and another emitter release can word or order its output differently. Commit package-lock.json too.
  • blume init . writes blume.config.ts and docs/index.mdx, and adds .blume/ and .env.local to the .gitignore, which already ignores dist/ and node_modules/. It leaves the existing package.json alone and doesn't install anything, which is why npm install blume follows.

Emit OpenAPI where Blume reads it

The template's tspconfig.yaml writes the spec to tsp-output/schema/openapi.yaml, a folder its .gitignore ignores. Replace it so the spec lands beside blume.config.ts. In a TypeSpec project of your own, keep its other emitters and change only the @typespec/openapi3 options:

kind: project
emit:
  - "@typespec/openapi3"
options:
  "@typespec/openapi3":
    emitter-output-dir: "{project-root}"
    output-file: "openapi.json"
    openapi-versions:
      - 3.1.0
    experimental-parameter-examples: data
OptionWhat it does here
emitter-output-dirWrites into the project root. The emitter's own default is tsp-output/@typespec/openapi3/.
output-fileNames the file openapi.json; the extension makes it JSON. Blume reads YAML just as well.
openapi-versionsWrites OpenAPI 3.1. Without this option, the emitter writes 3.0.
experimental-parameter-examplesWrites the parameter values from @opExample into the spec. Without it they're dropped, and the code samples call /widgets/string.

3.0, 3.1, and 3.2 all work. Blume upgrades a 3.0 spec to 3.1 as it reads it and reads 3.1 and 3.2 as written, and this guide's definition built the same pages from each. Choose one, though: with more than one in openapi-versions, the emitter writes each into a folder named after it, like 3.1.0/openapi.json, and the path Blume reads moves. TypeSpec marks experimental-parameter-examples as experimental, so check its entry in the emitter's README when you upgrade.

Compile the template once to see what it gives you:

npx tsp compile .

Write the definition for readers

The template compiles, but it makes a poor reference. Its operations have doc comments and nothing else, and once you mount the reference in the next section, Blume builds these pages from its spec:

OperationPageTitleSidebar label
Widgets.list/api/widgets/widgets-listGET /widgetsGET /widgets
Widgets.create/api/widgets/widgets-createPOST /widgetsPOST /widgets
Widgets.read/api/widgets/widgets-readGET /widgets/{id}GET /widgets/{id}

Three things cause that, and each has a fix in TypeSpec:

  • No summaries. TypeSpec writes a doc comment to the operation's description, never its summary. Blume titles a page and labels it in the sidebar with the summary, and falls back to the method and path, so the sidebar reads like a list of endpoints. Add @summary to every operation.
  • Generated operation IDs. By default the emitter names an operation after its interface and itself, like Widgets_list. Blume builds the page URL from the tag and the operation ID, so the tag appears twice. Set the ID with @operationId from @typespec/openapi. If an SDK generator reads the same spec, the ID is its method name too, so change it on purpose. Operations declared directly in the service namespace, outside an interface, get their bare name.
  • No examples. Without them, the code samples and the Try it panel fill in placeholder values like "string" and 0.

Here's the whole definition with those fixed:

import "@typespec/http";
import "@typespec/openapi";

using Http;
using OpenAPI;

/**
 * Create, update, and analyze the widgets in your Acme account.
 * Every request needs an API key, sent as a bearer token.
 */
@service(#{ title: "Widget Service" })
@info(#{ version: "1.0.0" })
@server("https://api.widgets.example", "Production")
@useAuth(BearerAuth)
@tagMetadata(#[
  #{
    name: "Widgets",
    description: "Create, read, update, and delete widgets.",
  },
  #{
    name: "Analysis",
    description: "Run checks on a widget you already created.",
  }
])
namespace WidgetService;

/** A widget in your account. */
model Widget {
  /** The widget's ID, set by the API when you create it. */
  @visibility(Lifecycle.Read)
  @example("wdg_8f2k")
  id: string;

  /** The widget's weight, in grams. */
  @minValue(1)
  @example(120)
  weight: int32;

  /** The widget's color. */
  @example("red")
  color: "red" | "blue";
}

/** A page of widgets. */
model WidgetList {
  /** The widgets on this page. */
  items: Widget[];
}

/** The error every operation returns when a request fails. */
@error
model Error {
  /** A numeric code for the error. */
  @example(404)
  code: int32;

  /** What went wrong, in a sentence. */
  @example("No widget has that ID.")
  message: string;
}

/** The result of analyzing a widget. */
model AnalyzeResult {
  /** The ID of the widget that was analyzed. */
  id: string;

  /** What the analysis found. */
  analysis: string;
}

@route("/widgets")
@tag("Widgets")
interface Widgets {
  /** Returns every widget in your account, newest first. */
  @summary("List widgets")
  @operationId("listWidgets")
  @get
  list(): WidgetList | Error;

  /**
   * Returns one widget by its ID, with its current weight and color.
   * @param id The widget's ID, from the response to Create a widget.
   */
  @summary("Get a widget")
  @operationId("getWidget")
  @returnsDoc("The widget.")
  @opExample(#{
    parameters: #{ id: "wdg_8f2k" },
    returnType: #{ id: "wdg_8f2k", weight: 120, color: "red" },
  })
  @get
  read(@path id: string): Widget | Error;

  /** Creates a widget and returns it with the ID the API assigned. */
  @summary("Create a widget")
  @operationId("createWidget")
  @post
  create(@body body: Widget): Widget | Error;

  /**
   * Changes the fields you send and leaves the rest as they are.
   * @param id The ID of the widget to update.
   */
  @summary("Update a widget")
  @operationId("updateWidget")
  @opExample(#{ parameters: #{ id: "wdg_8f2k", body: #{ weight: 150 } } })
  @patch
  update(@path id: string, @body body: MergePatchUpdate<Widget>):
    | Widget
    | Error;

  /**
   * Deletes a widget. This can't be undone.
   * @param id The ID of the widget to delete.
   */
  @summary("Delete a widget")
  @operationId("deleteWidget")
  @returnsDoc("The widget is deleted.")
  @opExample(#{ parameters: #{ id: "wdg_8f2k" } })
  @delete
  delete(@path id: string): void | Error;
}

@route("/widgets/{id}/analyze")
@tag("Analysis")
interface Analysis {
  /**
   * Checks a widget's weight and color against your account's rules.
   * @param id The ID of the widget to analyze.
   */
  @summary("Analyze a widget")
  @operationId("analyzeWidget")
  @opExample(#{
    parameters: #{ id: "wdg_8f2k" },
    returnType: #{
      id: "wdg_8f2k",
      analysis: "Within your account's weight limit.",
    },
  })
  @post
  analyze(@path id: string): AnalyzeResult | Error;
}

Where each decorator shows up

In TypeSpecIn the specOn the docs site
@service(#{ title })info.titleThe overview page's title
Doc comment on the namespaceinfo.descriptionThe overview page's introduction
@info(#{ version })info.version"Version 1.0.0" on the overview page
@serverserversThe base URL, where Try it sends requests, and the code samples' host
@useAuth(BearerAuth)components.securitySchemes and the root securityAn Authorization section on every operation, and Authorization: Bearer YOUR_TOKEN in its samples
@tagThe operation's tagsIts sidebar group, and the URL segment /api/widgets/
@tagMetadataThe top-level tags listThe groups' order, and each overview section's description
@operationIdoperationIdThe page URL: getWidget becomes get-widget
@summarysummaryThe page title and sidebar label
Doc comment on the operation, or @docdescriptionThe paragraph under the title, and the start of the page's meta description
@param in that doc commentThe parameter's descriptionThe parameter's row
@returnsDocThe success response's descriptionThe text beside the response's status code
Doc comments on model propertiesProperty descriptionsThe schema tables
@example on a propertyThe property's examplesRequest samples, Try it's starting values, and response examples
@opExampleAn example on each parameter and on the response bodyThe path in the code samples, and the Response panel
@visibility(Lifecycle.Read)readOnly: trueLeft out of the request body table and the request sample

Compile again and look at one operation. Everything the Get a widget page shows is in it:

npx tsp compile .
jq '.paths["/widgets/{id}"].get' openapi.json
{
  "operationId": "getWidget",
  "summary": "Get a widget",
  "description": "Returns one widget by its ID, with its current weight and color.",
  "parameters": [
    {
      "name": "id",
      "in": "path",
      "required": true,
      "description": "The widget's ID, from the response to Create a widget.",
      "schema": {
        "type": "string"
      },
      "example": "wdg_8f2k"
    }
  ],
  "responses": {
    "200": {
      "description": "The widget.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Widget"
          },
          "example": {
            "id": "wdg_8f2k",
            "weight": 120,
            "color": "red"
          }
        }
      }
    },
    "default": {
      "description": "An unexpected error response.",
      "content": {
        "application/json": {
          "schema": {
            "$ref": "#/components/schemas/Error"
          }
        }
      }
    }
  },
  "tags": [
    "Widgets"
  ]
}

Examples

Put @example on each property rather than on the whole model, so every model and operation that uses a property shares its example. Blume builds the request from them and leaves the read-only id out, while response examples keep it. It does the same with a model-level example, which becomes one example object for the schema, id included.

@opExample is type-checked against the whole operation. That's why Create a widget has none: its body is a Widget, so the example would need an id too. Update a widget's example needs a body beside the id for the same reason, but the emitter writes no request example for a merge-patch body, so that page builds its body sample from the property examples. The id is what matters there: with it, the samples call /widgets/wdg_8f2k.

Tag order

Blume orders the sidebar groups and the overview's sections by the spec's top-level tags list, then any tag it doesn't declare. Use the array form of @tagMetadata, which keeps the order you write. One @tagMetadata("Widgets", …) per tag also works, but the spec then lists the tags in the reverse order of the decorators, and Analysis comes before Widgets.

Mount the reference

Replace the config that blume init wrote:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Widget Docs",
  description: "Guides and API reference for the Widget Service.",
  deployment: { site: "https://docs.widgets.example" },
  navigation: {
    tabs: [
      { label: "Guides", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});

The reference doesn't add a header tab on its own, so the tab at /api makes it reachable and scopes its sidebar. deployment.site gives the sitemap and canonical links their origin on hosts that don't tell the build its URL, like GitHub Pages.

Write guides beside the reference

The reference says what each operation takes and returns. A reader also needs to know how to authenticate and which call comes first. Replace docs/index.mdx and add a quickstart:

---
title: Introduction
description: Store widgets, update them, and run analysis on them with the Widget Service API, using an API key and any HTTP client.
---

The Widget Service API stores the widgets in your Acme account and checks them
against your account's rules.

- [Quickstart](/quickstart) takes you from an API key to your first analyzed widget.
- The [API reference](/api) lists every operation, generated from the API's TypeSpec definition.
---
title: Quickstart
description: Authenticate with an API key, create your first widget with the Widget Service API, and run an analysis on it.
---

Every request needs an API key, sent in the `Authorization` header as a bearer
token. Set it in your shell first:

```bash
export WIDGETS_API_KEY="your-api-key"
```

## Create a widget

Send its weight and color to [Create a widget](/api/widgets/create-widget):

```bash
curl https://api.widgets.example/widgets \
  -H "Authorization: Bearer $WIDGETS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"weight":120,"color":"red"}'
```

The response is the widget, with the ID the API assigned:

```json
{ "id": "wdg_8f2k", "weight": 120, "color": "red" }
```

## Analyze it

Pass the ID to [Analyze a widget](/api/analysis/analyze-widget):

```bash
curl -X POST https://api.widgets.example/widgets/wdg_8f2k/analyze \
  -H "Authorization: Bearer $WIDGETS_API_KEY"
```

## Handle errors

A failed request returns an error object with a numeric `code` and a
`message`. Every operation's page lists it under its responses.

Link to operation pages by their URL, which comes from the tag and the operation ID. Those links are how a definition change that moves a page gets caught: blume validate checks every one of them against the reference.

Inside a tag's group, the sidebar and the overview page list operations in the spec's order, which is path by path, so Create a widget comes before Get a widget. Neither follows the order of your interface. To set the sidebar order yourself, add a meta.ts in the folder that matches the tag's route:

import { defineMeta } from "blume";

export default defineMeta({
  title: "Widgets",
  order: 0,
  pages: [
    "list-widgets",
    "get-widget",
    "create-widget",
    "update-widget",
    "delete-widget",
  ],
});

A meta.ts you write changes only the fields it sets, and Blume fills in the rest from the spec, so the tag's title and its position in the tag list could be left out. It reorders only the sidebar: the overview keeps the spec's order.

Build and preview

tsp init wrote a package.json with no scripts. Add scripts that compile the definition before every build:

"scripts": {
  "spec": "tsp compile .",
  "dev": "blume dev",
  "build": "tsp compile . && blume build"
},

A TypeSpec error stops npm run build before Blume runs, so a definition that doesn't compile never publishes. Build, check the links, and preview the result:

npm run build
npx blume validate --strict
npx blume preview

--strict fails on warnings as well as broken links. Open http://localhost:4321/api. The overview lists both tags with their descriptions, and each operation has its own page:

OperationPage
GET /widgets/api/widgets/list-widgets
GET /widgets/{id}/api/widgets/get-widget
POST /widgets/api/widgets/create-widget
PATCH /widgets/{id}/api/widgets/update-widget
DELETE /widgets/{id}/api/widgets/delete-widget
POST /widgets/{id}/analyze/api/analysis/analyze-widget

Each page has its summary as the title, the doc comment under it, an Authorization section for the bearer token, schema tables with the property descriptions, and code samples built from the examples, such as curl -X GET 'https://api.widgets.example/widgets/wdg_8f2k' on Get a widget. That host doesn't exist, so Send in Try it can't succeed here. With your own API, the panel calls the server in @server, and your API has to allow the docs origin; see Fix CORS errors in an API documentation playground.

While you write, run the compiler in watch mode in one terminal and Blume in another. blume dev watches openapi.json, so each save in main.tsp updates the open page without a restart:

npx tsp compile . --watch
npm run dev

Commit the spec and check it in CI

Commit openapi.json even though it's generated. A change to one decorator can change dozens of lines of spec, and the pull request shows the API change the way clients will see it. Other tools, like an SDK generator or a breaking-change check, can read the file straight from Git. And because npm run build compiles first, the docs host rebuilds the spec from main.tsp anyway, so a stale commit never reaches the site.

What can go stale is the committed copy. Add a workflow that compiles the definition and fails when that changes the file:

name: Docs

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Check the committed spec is up to date
        run: |
          npx tsp compile .
          git diff --exit-code -- openapi.json
      - run: npx blume validate --strict
      - run: npm run build

The compiler writes the same bytes for the same definition, and the pinned versions keep CI on the compiler you ran locally. Say a pull request adds a label property to Widget but nobody compiled. The check fails and prints what the commit is missing:

             "examples": [
               "red"
             ]
+          },
+          "label": {
+            "type": "string",
+            "description": "A name for the widget, shown in your dashboard.",
+            "examples": [
+              "Front door sensor"
+            ]
           }
         },
         "description": "A widget in your account."

A second hunk adds the same property, nullable, to the merge-patch schema TypeSpec derives for Update a widget. Run npm run spec and commit the result. git diff only compares files Git tracks, so commit openapi.json before you add the check. If nothing but the docs reads the spec, you can skip all this: add openapi.json to .gitignore, let the build script write it, and run npm run spec before npm run dev in a fresh clone. You lose the reviewable diff. To also fail a pull request that breaks existing clients, add oasdiff as shown in Keep API documentation in sync with backend changes in CI.

Document every version of a versioned API

With @typespec/versioning, one definition describes several API versions, and the emitter writes one spec per version. Add the package, then mark the namespace as versioned and say when things were added:

npm install --save-exact @typespec/versioning@0.86.0
 import "@typespec/http";
 import "@typespec/openapi";
+import "@typespec/versioning";

 using Http;
 using OpenAPI;
+using Versioning;

 /**
  * Create, update, and analyze the widgets in your Acme account.
  * Every request needs an API key, sent as a bearer token.
  */
 @service(#{ title: "Widget Service" })
-@info(#{ version: "1.0.0" })
+@versioned(Versions)
 @server("https://api.widgets.example", "Production")
 ...
 namespace WidgetService;

+/** The versions of the Widget Service API. */
+enum Versions {
+  v1: "1.0",
+  v2: "2.0",
+}
+
 ...
 @route("/widgets/{id}/analyze")
 @tag("Analysis")
+@added(Versions.v2)
 interface Analysis {

@versioned sets each file's info.version to that version's value and overrides @info's, so drop @info. Then put {version} in the file name, where the emitter fills in the same value:

    output-file: "openapi.{version}.json"

That writes openapi.1.0.json and openapi.2.0.json, and only the 2.0 file has the Analysis operation. The old openapi.json stays behind, so delete it with git rm openapi.json, and point the CI check at the new files with git diff --exit-code -- 'openapi.*.json'. Then give each file its own source and route, and put both under a dropdown tab:

export default defineConfig({
  // ...title, description, and deployment as before
  navigation: {
    tabs: [
      { label: "Guides", path: "/" },
      {
        label: "API reference",
        path: "/api",
        items: [
          { label: "Version 2.0", path: "/api/v2" },
          { label: "Version 1.0", path: "/api/v1" },
        ],
      },
    ],
  },
  reference: [
    openapi({
      sources: [
        { label: "Version 2.0", route: "/api/v2", spec: "./openapi.2.0.json" },
        {
          label: "Version 1.0",
          route: "/api/v1",
          spec: "./openapi.1.0.json",
          noindex: true,
          includeInSearch: false,
          includeInLlms: false,
        },
      ],
    }),
  ],
});

The two versions share most of their operations, so without the last three options blume audit warns about a dozen pages with duplicate titles and descriptions, and about operation pages that are identical in both versions with no canonical. noindex keeps the 1.0 pages out of search engines and the sitemap, which clears those warnings. The other two keep them out of site search and llms.txt, so readers and agents land on the current version; leave them out if 1.0 readers should still search their own pages.

Every operation now lives under its version's route, like /api/v2/widgets/get-widget. Update the links in docs/ (blume validate lists each one you miss, including the old /api, which no longer has a page), and move docs/api/widgets/meta.ts to docs/api/v2/widgets/, with a copy in docs/api/v1/widgets/. The tab's sidebar holds both versions, sorted by name, which puts 1.0 first. A meta.ts in each version's folder sets the order and shows one version at a time:

import { defineMeta } from "blume";

export default defineMeta({
  title: "Version 2.0",
  display: "page",
  order: 1,
});

Add the same file in docs/api/v1/ with its own title and order: 2. Combine multiple OpenAPI specifications in one docs site covers routes, search, and overlapping names when several specs share a site. Blume's own versioning doesn't apply here: it snapshots the pages in docs/, and generated references always stay current.

Deploy

The site builds to static files in dist/. On your host, use the build script rather than blume build, so every deploy compiles the definition first:

SettingValue
Build commandnpm run build
Output directorydist
Node.js version22.19 or later

TypeSpec 1.16 needs Node.js 22 and Blume needs 22.19, so the second is the floor. The deployment docs cover each host, and Deploy Markdown docs to GitHub Pages adds a deploy job to a workflow like the one above.

Limitations

  • Only what the emitter writes reaches the docs. Blume reads the OpenAPI file, not TypeSpec, so a decorator the openapi3 emitter doesn't translate, like an @example on a path parameter, changes nothing on the site.
  • Parameter examples are experimental. They need experimental-parameter-examples, which TypeSpec may change. Without it, path values in the samples fall back to placeholders.
  • Merge-patch bodies get no request example. The emitter drops the body of an @opExample on a MergePatchUpdate operation, so property examples are the only way to shape that sample.
  • Agents get the body and responses, not every table. The Markdown copy of each operation page, which llms-full.txt and the .md URLs serve, carries its summary, doc comment, and endpoint, the request body's top-level properties and example, and each response with its example, but not the parameters or nested schemas. Write the doc comments so they stand on their own.

Troubleshooting

URLs read widgets-list instead of list-widgets

The operation has no @operationId, so the emitter named it Widgets_list. Add one, and if the old URLs are already public, add a redirect for each in blume.config.ts.

The code samples call /widgets/string

The path parameter has no example. Add an @opExample with the parameter's value, and check that tspconfig.yaml sets experimental-parameter-examples: data. An @example on the parameter itself isn't written to the spec.

tsp compile fails with "Property 'id' is missing"

An @opExample body has to be a complete instance of the body's type, read-only properties included. Drop the @opExample from that operation and rely on property examples, as Create a widget does, or give the operation a body type without the property, like Create<Widget>.

The Analysis group comes before Widgets

The tags are declared with one @tagMetadata each, which writes them in reverse. Use the array form.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't find openapi.json. blume build on its own doesn't compile TypeSpec, so run npm run build, or npm run spec first. In blume dev a missing spec is only a warning and the reference is skipped, so a working dev server can hide it.

Next step

Add docs to your TypeSpec project

Run it at the root of your TypeSpec project, point openapi() at the file tsp compile writes, and give every operation a @summary.

npx blume init . --template docs --yes
Add breaking-change checks in CI

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init