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 Hayden Bleasel12 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 committedBlume 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-frontmatternpm 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:
| Option | Why |
|---|---|
entryPoints | One file per public entry point, matching the package's exports. TypeDoc names each module after its path, like maybe or task/delay. |
tsconfig | The library's own config, so TypeDoc resolves imports and types the way your build does. |
out | A 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, hideBreadcrumbs | Blume's header, sidebar, and breadcrumbs already show where a page sits. |
hidePageTitle | Blume renders the frontmatter title as the page's h1, so TypeDoc's own # Function: map() would be a second one. |
useCodeBlocks | Signatures 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, orAPI referencefor 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 withhidePageTitleis a section heading likeCall Signature. - A meta description. The first paragraph of the symbol's comment, as
seo.description. A top-leveldescriptionwould 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: pageon 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.tswritten intodocs/referencewould be deleted by the next run, so the plugin copiesreference.meta.tsin 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.
Link tutorials and reference
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.mdlink to the page, but leaves a root-relative one as written, so/guide/understanding/maybe.mdopens the page's raw Markdown copy. - No trailing slash. Blume's page URLs end without one, and
blume devandblume previewredirect/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 previewOn 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.mdreturns 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 --watchEach 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-siteThe 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.packageOptionsapplies options to every package, here the entry point inside each one.excludeScopesInPathsdrops the scope from the folders. Without it,@tanstack/storelands inreference/@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, likeArray<string>, is read as HTML, so<string>drops out of the text. - Shared names share a title.
mapexists inmaybe,result, andtask, and all three pages are titledmap. Their URLs and search results tell them apart, butblume auditreports duplicate titles. - No edit links on reference pages. Blume leaves the Edit on GitHub link off pages whose file git ignores, and
docs/referenceis 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 auditnotes, 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 --yesA step here not working for you? Report a broken step.