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

API reference

Publish TypeScript API reference beside your tutorials

Turn your TSDoc comments into a page per export with TypeDoc, and publish them in a Reference tab beside hand-written tutorials, with links between the two checked on every pull request.

By 12 min read

To publish a TypeScript library's API reference beside hand-written tutorials, have TypeDoc write your doc comments out as Markdown with typedoc-plugin-markdown, into a folder of a Blume site, and give that folder its own tab. Every export gets a page, and the reference shares the site's navigation, search index, and link check with your tutorials, so a broken link from a guide to a function, or from a comment to a guide, fails the check.

The walkthrough uses True Myth, an MIT-licensed library with an entry point per type: true-myth/maybe, true-myth/result, true-myth/task, and a few more. By the end, its repository has a docs-site folder with tutorials in a Guides tab, a page per export in a Reference tab, scripts that regenerate the reference before every build, and a CI job that fails on a broken link. Swap in your own entry points as you go.

Lay out the repository

Keep the docs site in its own folder, beside the library's source:

true-myth/
├─ src/                  the library
├─ package.json
├─ tsconfig.json
└─ docs-site/
   ├─ package.json       blume, typedoc, and the plugins
   ├─ blume.config.ts
   ├─ typedoc.json
   ├─ typedoc-blume.mjs
   ├─ reference.meta.ts
   └─ docs/
      ├─ index.mdx
      ├─ getting-started.mdx
      ├─ guide/          hand-written tutorials
      └─ reference/      generated by TypeDoc, not committed

Blume writes its build to dist/ and its runtime to .blume/ in the folder that holds blume.config.ts, and most libraries already build to dist/. A folder of its own keeps the two apart, and keeps the docs' dependencies out of the package you publish.

Create the docs site

From the repository root, scaffold the site, then add TypeDoc, its Markdown plugin, and the frontmatter plugin:

npx blume init docs-site --template docs --yes
cd docs-site
npm install -D typedoc typedoc-plugin-markdown typedoc-plugin-frontmatter

npm also installs TypeScript, which TypeDoc takes as a peer dependency. TypeDoc supports TypeScript 5.0 through 6.0 from version 0.28.18. The commands here use npm; any package manager works.

TypeDoc type-checks your library with its own tsconfig.json before it writes anything, so the library's dependencies have to be installed at the repository root too. Without them, TypeDoc stops on errors like Cannot find type definition file for 'node'.

Configure TypeDoc

Add a typedoc.json beside blume.config.ts. Its paths are relative to the file:

{
  "$schema": "https://typedoc-plugin-markdown.org/schema.json",
  "entryPoints": [
    "../src/maybe.ts",
    "../src/result.ts",
    "../src/task.ts",
    "../src/task/delay.ts",
    "../src/toolbelt.ts",
    "../src/unit.ts",
    "../src/standard-schema.ts",
    "../src/test-support.ts"
  ],
  "tsconfig": "../tsconfig.json",
  "plugin": [
    "typedoc-plugin-markdown",
    "./typedoc-blume.mjs",
    "typedoc-plugin-frontmatter"
  ],
  "out": "docs/reference",
  "readme": "none",
  "entryFileName": "index",
  "hidePageHeader": true,
  "hideBreadcrumbs": true,
  "hidePageTitle": true,
  "useCodeBlocks": true,
  "parametersFormat": "table"
}

Most of these options fit the output to Blume:

OptionWhy
entryPointsOne file per public entry point, matching the package's exports. TypeDoc names each module after its path, like maybe or task/delay.
tsconfigThe library's own config, so TypeDoc resolves imports and types the way your build does.
outA folder inside Blume's content root. TypeDoc empties it on every run, so keep nothing else in it.
readme: "none"Without it, your README becomes the reference's first page.
entryFileName: "index"typedoc-plugin-markdown names a folder's page README.md by default. Blume makes index.md a folder's own page, and treats a README.md as one more page.
hidePageHeader, hideBreadcrumbsBlume's header, sidebar, and breadcrumbs already show where a page sits.
hidePageTitleBlume renders the frontmatter title as the page's h1, so TypeDoc's own # Function: map() would be a second one.
useCodeBlocksSignatures render as highlighted TypeScript instead of escaped Markdown.
parametersFormat: "table"A table per signature instead of a heading per parameter. A cell holds one line, so a parameter comment with a list, a code block, or a quote runs together. Leave the option out to keep those.

Leave out a barrel file

True Myth's src/index.ts only re-exports the other modules, so it isn't listed. Listed with them, TypeDoc names it index, and its page, index/index.md, lands on the same route as the reference's own index.md, which Blume reports as BLUME_DUPLICATE_ROUTE. A library with a single entry point doesn't have this problem: list that one file, and its exports go straight under reference/.

Keep .md, not .mdx

typedoc-plugin-markdown writes .md files unless you set fileExtension. Keep it that way. MDX reads { and < in prose as code. The plugin escapes them in signatures, but copies comment text as written, so a comment that mentions { strict: true } or a < b stops blume build with an MDX error, while blume validate, blume check, and blume doctor all pass it. The plugin's sanitizeComments option escapes comment text too, but then HTML you meant, like <br>, shows up as text. In .md, HTML in a comment renders as HTML, and generated pages have no use for MDX components.

One page per export

The plugin's default router, member, gives every function, class, interface, type alias, enum, and variable a page of its own, in a folder per module and a subfolder per kind: /reference/maybe/functions/map. Each one gets its own URL, search result, and Markdown copy. "router": "module" puts a whole module on one page instead, which suits a small library: True Myth's maybe module comes out at nearly 7,000 lines that way. Older setups choose between the two with outputFileStrategy, which the plugin has deprecated in favor of router.

Give the pages Blume's frontmatter

typedoc-plugin-frontmatter writes a page's frontmatter object out as YAML, and a small local plugin fills that object in. Save it beside typedoc.json, which loads it as ./typedoc-blume.mjs:

import { copyFileSync } from "node:fs";
import { join } from "node:path";
import { ReflectionKind, RendererEvent } from "typedoc";
import { MarkdownPageEvent } from "typedoc-plugin-markdown";

// The first paragraph of a doc comment, as one line of plain text.
const summary = (comment) =>
  (comment?.summary ?? [])
    .map((part) => part.text)
    .join("")
    .split(/\n\s*\n/u)[0]
    .replaceAll(/\[([^\]]*)\]\([^)]*\)/gu, "$1")
    .replaceAll(/\*{1,2}([^*]+)\*{1,2}/gu, "$1")
    .replaceAll("`", "")
    .replaceAll(/\s+/gu, " ")
    .trim();

const ALERT = /^(\s*>\s*)\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/gimu;

/** @param {import("typedoc-plugin-markdown").MarkdownApplication} app */
export function load(app) {
  app.renderer.on(MarkdownPageEvent.BEGIN, (page) => {
    const model = page.model;
    const isRoot = model.isProject();
    const isModule = model.kindOf(ReflectionKind.SomeModule);
    const description = summary(model.comment ?? model.signatures?.[0]?.comment);
    page.frontmatter = {
      title: isRoot ? "API reference" : model.name,
      ...(description && { seo: { description } }),
      ...(isModule && { sidebar: { display: "page" } }),
      ...page.frontmatter,
    };
  });

  // Blume has no `> [!NOTE]` alerts in .md pages: keep the quote, label it.
  app.renderer.on(MarkdownPageEvent.END, (page) => {
    page.contents = page.contents?.replaceAll(
      ALERT,
      (_, quote, kind) => `${quote}**${kind[0]}${kind.slice(1).toLowerCase()}:**`
    );
  });

  // TypeDoc empties its output folder on every run, so copy the folder meta in.
  app.renderer.on(RendererEvent.END, (event) => {
    const meta = new URL("reference.meta.ts", import.meta.url);
    copyFileSync(meta, join(event.outputDirectory, "meta.ts"));
  });
}

It does five things:

  • A title. The symbol's name, like map, or API reference for the reference's own page. Blume uses it for the h1, the sidebar label, and the browser tab. Without one, Blume titles the page after its first heading, which with hidePageTitle is a section heading like Call Signature.
  • A meta description. The first paragraph of the symbol's comment, as seo.description. A top-level description would work too, but Blume shows it under the title, where it repeats the paragraph that opens the page.
  • A sidebar panel per module. sidebar.display: page on a module's page turns its folder into one sidebar row that opens a panel of its exports, instead of one sidebar listing every export of every module.
  • Readable alerts. TypeDoc's HTML theme renders GitHub alerts like > [!NOTE], so comments written for it use them, as True Myth's do. Blume doesn't, so the marker would show as text. The plugin turns it into a bold label.
  • Folder meta. A meta.ts written into docs/reference would be deleted by the next run, so the plugin copies reference.meta.ts in after each one.

A generated page then starts like this:

---
title: map
seo:
  description: "Map over a Maybe instance: apply the function to the wrapped value if the instance is Just, and return Nothing if the instance is Nothing."
---

## Call Signature

```ts
function map<T, U>(mapFn): (maybe) => Maybe<U>;
```

The folder meta sets the order of the modules, by folder name. Without it, they sort alphabetically:

import { defineMeta } from "blume";

export default defineMeta({
  pages: [
    "maybe",
    "result",
    "task",
    "toolbelt",
    "unit",
    "standard-schema",
    "test-support",
  ],
});

Add the Reference tab

Point a tab at the folder TypeDoc writes to:

import { defineConfig } from "blume";

export default defineConfig({
  title: "True Myth",
  description: "Safe, idiomatic null, error, and async code handling in TypeScript.",
  navigation: {
    tabs: [
      { label: "Guides", path: "/" },
      { label: "Reference", path: "/reference" },
    ],
  },
});

Under /reference, the sidebar shows only the reference: its own page first, then a row per module in the folder meta's order, labeled from the folder name (standard-schema reads Standard Schema). Every other page stays in the Guides tab.

Tutorials are ordinary pages in docs/. Link to a reference page by its route, or by a relative path to its .md file, which Blume rewrites to the route. Methods on a class page are headings, so each has an anchor:

---
title: Getting started
description: Install True Myth, import each type from its own module, and replace a null check with a Maybe.
---

## Install

```bash
npm add true-myth
```

## Replace a null check

`maybe.of` wraps a value that might be `null` or `undefined`. Transform it with [`map`](/reference/maybe/functions/map), and get a plain value back with [`unwrapOr`](./reference/maybe/functions/unwrapOr.md):

```ts
import * as maybe from "true-myth/maybe";

const length = (s: string) => s.length;

const name = maybe.of(document.querySelector("input")?.value);
const nameLength = maybe.unwrapOr(0, maybe.map(length, name));
```

The same operations are methods on the [`Maybe` class](/reference/maybe/classes/Maybe#map), so `name.map(length).unwrapOr(0)` works too. For the ideas behind the type, read [Understanding Maybe](/guide/understanding/maybe).

Reference URLs keep the symbol's case, as in /reference/maybe/classes/Maybe and /reference/maybe/functions/unwrapOr.

Links run the other way too. True Myth's comments link to guide pages on its own site, and TypeDoc copies those links into the reference as written, so they resolve against your Blume site. Give the tutorials those routes, here /guide/understanding/maybe and its siblings, and write the links in your comments as routes:

-For a deep dive on the type, see [the guide](/guide/understanding/maybe.md).
+For a deep dive on the type, see [the guide](/guide/understanding/maybe).
  • No .md. Blume rewrites a relative .md link to the page, but leaves a root-relative one as written, so /guide/understanding/maybe.md opens the page's raw Markdown copy.
  • No trailing slash. Blume's page URLs end without one, and blume dev and blume preview redirect /guide/understanding/task/ to /guide/understanding/task. Link to the page's own URL so readers skip the redirect.
  • Symbols by name. Link to another export with {@link map} rather than a Markdown link, and TypeDoc writes the URL of wherever its page ends up.

blume validate doesn't flag the first two: it reads both forms as the page.

Build and check

TypeDoc has to run before every Blume command that reads the content. Replace the scripts blume init wrote:

"scripts": {
  "reference": "typedoc",
  "dev": "typedoc && blume dev",
  "build": "typedoc && blume build",
  "validate": "typedoc && blume validate --strict",
  "doctor": "blume doctor"
}

Keep the generated pages out of git by adding their folder to the .gitignore that blume init wrote. They're rebuilt from the source on every run, and committed copies would drift from it:

docs/reference/

Then check the links, build, and serve the result:

npm run validate
npm run build
npx blume preview

On a first run, npm run validate may stop on links in your doc comments, as it does on True Myth's. Fix what validate finds covers those, and the build and preview still run. Open the URL blume preview prints and check three things:

  • The Reference tab. It lists the modules in your order, and each opens a panel of its exports.
  • Search. Searching for an export, like withRetries, finds its page, with Reference, Task, and Functions in the result's breadcrumb. Reference and tutorials share one index.
  • The Markdown copy. /reference/maybe/functions/map.md returns the page as Markdown, with its links rewritten to routes, for agents to read.

Fix what validate finds

blume validate checks every link in the generated pages, so it finds problems in your comments. On True Myth, the first run reports seven anchor warnings, and --strict fails on them:

BLUME_BROKEN_ANCHOR No anchor target on /reference/maybe/functions/and matches #map.
  at docs/reference/maybe/functions/and.md:23:32
  docs: https://useblume.dev/docs/cli/validate
…
BLUME_BROKEN_ANCHOR No anchor target on /reference/result/functions/mapOr matches #map.
  at docs/reference/result/functions/mapOr.md:110:68
  docs: https://useblume.dev/docs/cli/validate

7 warning(s)

The comments link to #map, an anchor that exists when a whole module shares one page. With a page per export, map has a page of its own. Link to the symbol, and TypeDoc writes the right URL:

-  Notice that, unlike in [`map`](#map) or its variants, the original `maybe` is
+  Notice that, unlike in {@linkcode map} or its variants, the original `maybe` is
   not involved in constructing the new `Maybe`.

Fix these in the library's source, since that's what TypeDoc reads. An edit in docs/reference is gone after the next run.

TypeDoc prints warnings of its own: @param names that don't match a parameter, links it couldn't resolve, and types the reference refers to but doesn't include. They don't stop a run. Once you've fixed them, set "treatWarningsAsErrors": true in typedoc.json so new ones do.

Preview while you edit comments

npm run dev generates the reference once, then starts Blume's dev server. To see comment edits as you make them, run TypeDoc in watch mode in a second terminal:

npx typedoc --watch

Each change to the library regenerates the reference, and the dev server reloads the pages.

Check it in CI

Run the same checks on every pull request, so a renamed export or a rewritten comment can't leave a broken link behind:

name: Docs

on:
  push:
    branches: [main]
  pull_request:

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
      - run: npm ci
      - run: npm ci
        working-directory: docs-site
      - run: npm run reference
        working-directory: docs-site
      - run: npx blume validate --strict
        working-directory: docs-site
      - run: npx blume build
        working-directory: docs-site

The first npm ci installs the library's own dependencies, which TypeDoc needs to type-check it. If the library uses another package manager, as True Myth does with pnpm, set that one up and use its frozen install there instead, like pnpm install --frozen-lockfile. The job fails on a broken link or anchor, and on a page that doesn't build.

Deploy

npm run build leaves a static site in docs-site/dist, so any static host works, and Deploy Markdown docs to GitHub Pages has a workflow that publishes it. If your host runs the build itself, it needs more than the docs-site folder: TypeDoc reads ../src and ../tsconfig.json, and the library's dependencies have to be installed. On Vercel, with docs-site as the Root Directory, keep Include source files outside of the Root Directory in the Build Step on, which is the default, and make the install command cover the library's dependencies as well.

Document a monorepo

For a workspace of packages, point TypeDoc at the package folders instead of files. Here are the keys that change, for two of TanStack Store's packages. Drop tsconfig too: TypeDoc reads each package's own.

{
  "entryPointStrategy": "packages",
  "entryPoints": ["../packages/store", "../packages/react-store"],
  "packageOptions": { "entryPoints": ["src/index.ts"] },
  "excludeScopesInPaths": true
}
  • entryPointStrategy: "packages" converts each package as a project of its own and merges them into one reference, with a folder per package.
  • packageOptions applies options to every package, here the entry point inside each one.
  • excludeScopesInPaths drops the scope from the folders. Without it, @tanstack/store lands in reference/@tanstack/store, under a sidebar group named @tanstack.

reference.meta.ts then lists package folders, like pages: ["store", "react-store"]. Install the workspace's dependencies first: TypeDoc type-checks every package.

Limitations

  • Comments are Markdown, not MDX. Blume's callouts and components need .mdx, which generated pages can't use safely. A generic in comment prose outside backticks, like Array<string>, is read as HTML, so <string> drops out of the text.
  • Shared names share a title. map exists in maybe, result, and task, and all three pages are titled map. Their URLs and search results tell them apart, but blume audit reports duplicate titles.
  • No edit links on reference pages. Blume leaves the Edit on GitHub link off pages whose file git ignores, and docs/reference is in .gitignore. The Defined in link on each page points at the declaration in your source, where its comment lives.
  • URLs follow your symbols. They keep the symbol's case, which blume audit notes, and renaming or moving an export moves its page. Add a redirect when you rename a public export.
  • Every run rewrites every page. In watch mode, one comment edit reloads the whole reference in the dev server.

Next step

Add a docs site to your library

Run it at the root of your library's repository, then add TypeDoc, the two plugins, and the config from this guide.

npx blume init docs-site --template docs --yes
Read the navigation docs

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

Keep going.More guides.

  • Document Kafka events with AsyncAPI

    Describe your Kafka topics in AsyncAPI, publish event-driven API documentation with a page per operation, and pair it with producer and consumer code you've run.

Upgrade your docs with Blume.

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

npx blume init