Skip to content

Automating Documentation Generation with TypeDoc and MDX

Core Concept LearningAugust 3, 20265 min read

Hand-written API docs go stale the moment someone changes a function signature and forgets the markdown file that describes it. The signature and the description live in two different files, edited by two different habits, and only one of them is enforced by the compiler. Six months later the docs describe a parameter that no longer exists, and a new engineer trusts the docs over the source — because why wouldn't they.

TypeDoc closes that gap by extracting documentation directly from TSDoc comments next to the code, so the signature and its description can never drift — they're the same AST node. Rendering that extracted model as MDX puts it inside your existing Next.js site instead of a separate static-docs tool, which means the same layout, search, and navigation your blog already has. This guide builds one concrete pipeline: a public fetchOrder() function documented with TSDoc, extracted by TypeDoc, rendered as an MDX page, and a CI check that fails the build if a public export ships without a description. For the broader pattern of building the site around this content, see Knowledge base with Next.js and MDX.

Source comments to a published docs site
Source comments to a published docs site

Why generated docs and not hand-written

The core argument for generated documentation is not "less typing" — it's single source of truth. A TSDoc comment lives directly above the function it documents, in the same file, reviewed in the same pull request as the signature change. There is no second file to remember to update, which means there is no drift between what the docs say and what the code actually does.

The trade-off is real: TSDoc comments are more constrained than free-form markdown, and generated docs are only as good as the comments developers actually write. A @param tag left blank generates a blank parameter description on the live docs page — TypeDoc extracts faithfully, it does not invent missing prose. That constraint is a feature once you add a CI gate (covered later) that fails the build on missing tags, turning "someone should document this" into "the build fails until you do."

Quick reference

  • TSDoc is a subset of JSDoc with a stricter, tool-parseable tag syntax — @param, @returns, @throws, @example.
  • TypeDoc walks the compiled TypeScript AST, so it sees the real exported type, not a hand-typed guess of it.
  • Generated docs remove drift risk but do not remove the discipline requirement — an empty @param tag still generates an empty description.
  • Keep prose examples (@example) short and runnable; TypeDoc renders them as code blocks verbatim, untested by default.

Remember this

Generated documentation eliminates drift between signature and description by making them the same source node — it does not eliminate the need for developers to actually write the comment, which is exactly what a CI gate should enforce.

From TSDoc comment to a JSON model

TypeDoc's first job is turning a directory of TypeScript source into a structured JSON model — a tree of "reflections" describing every exported module, class, function, and its parameters, return type, and doc comment. This JSON model is the seam in the pipeline: it decouples "how TypeDoc parses TypeScript" from "how we render that into MDX," which means the render step can change (a new theme, a new site) without touching the extraction step.

Run this as a --json output rather than TypeDoc's default HTML theme — the JSON model is what the MDX generator in the next section consumes, and it is a stable, versioned format independent of TypeDoc's built-in visual theme.

Docs build pipeline on every merge
Docs build pipeline on every merge

Quick reference

  • Point entryPoints at your public API surface only — internal modules shouldn't leak into generated docs.
  • Commit reflections.json or regenerate it in CI; treat it as a build artifact, not source of truth for anything else.
  • TypeDoc respects @internal and @hidden tags to exclude symbols that are exported for tooling reasons but not public API.
  • A reflection with an empty comment is not an error by default — that's why the CI gate in a later section exists.
Documented export
1/**2 * Fetch the current status of an order.3 *4 * @param orderId - The order identifier, e.g. "A1092".5 * @returns The order's current status and last-updated timestamp.6 * @throws {OrderNotFoundError} If no order matches `orderId`.7 * @example8 * ```ts9 * const status = await fetchOrder("A1092");10 * console.log(status.state); // "shipped"11 * ```12 */13export async function fetchOrder(orderId: string): Promise<OrderStatus> {14  // ...15}
Extraction command + resulting model shape
1# package.json script2# "docs:extract": "typedoc --json docs/reflections.json --entryPoints src/index.ts"3 4npm run docs:extract5 6# Resulting docs/reflections.json (abridged):7# {8#   "name": "fetchOrder",9#   "kindString": "Function",10#   "comment": { "summary": [{ "text": "Fetch the current status of an order." }] },11#   "signatures": [{12#     "parameters": [{ "name": "orderId", "comment": { "summary": [...] } }],13#     "type": { "name": "Promise", "typeArguments": [{ "name": "OrderStatus" }] }14#   }]15# }16# Expected: every exported symbol in src/index.ts appears as a reflection.17# Break it: remove the @param tag and re-run — the parameter's comment18# field becomes empty, but the reflection still generates.

Remember this

TypeDoc's JSON model is the seam that lets the render step change independently of the extraction step — treat it as a versioned build artifact, not a place to hand-edit documentation.

Rendering reflections as MDX pages

With a stable JSON model, generating MDX is a straightforward transform: one MDX file per exported symbol (or one per module, for a smaller API), each frontmatter block carrying the symbol's name and category for your site's navigation, and the body rendering the signature, parameters, and any @example blocks as fenced code.

Because the output is plain MDX, it slots into an existing Next.js content pipeline exactly like a hand-written blog post — same layout, same search index, same dark-mode styles. The generator only needs to run once per build (or on-demand in development) and commit or regenerate the MDX files as a build step, not check them in as hand-edited source.

One exported function: from JSDoc to rendered page
One exported function: from JSDoc to rendered page

Quick reference

  • Generate one MDX file per public export for granular linking; group into modules only if the API surface is very large.
  • Include the @example code block verbatim as a fenced code block — BlogText/MDX renderers already handle syntax highlighting.
  • Run the undocumented-export check as a required CI job, not an advisory warning — advisory checks get ignored within a quarter.
  • Version the generated docs alongside releases so a reader on an older package version sees the matching API, not the latest one.
  • If the same public functions are also exposed as a versioned HTTP contract, apply the same required-check discipline described in CI/CD with GitHub Actions and Vercel.
Reflection → MDX generator (simplified)
1import { writeFileSync } from "node:fs";2 3type Reflection = {4  name: string;5  comment?: { summary: { text: string }[] };6  signatures?: Array<{7    parameters?: Array<{ name: string; comment?: { summary: { text: string }[] } }>;8  }>;9};10 11function toMdx(ref: Reflection): string {12  const summary = ref.comment?.summary.map((s) => s.text).join("") ?? "";13  const params = ref.signatures?.[0]?.parameters ?? [];14  const paramLines = params15    .map((p) => `- \`${p.name}\` — ${p.comment?.summary.map((s) => s.text).join("") ?? "(undocumented)"}`)16    .join("\n");17 18  return `---19title: "${ref.name}"20---21 22# ${ref.name}23 24${summary}25 26## Parameters27 28${paramLines}29`;30}
Fail the build on an undocumented public export
1import reflections from "./docs/reflections.json";2 3const undocumented = (reflections as Reflection[]).filter(4  (r) => !r.comment?.summary?.length5);6 7if (undocumented.length > 0) {8  console.error(9    `docs:check failed — ${undocumented.length} public export(s) missing a doc comment:`,10    undocumented.map((r) => r.name).join(", ")11  );12  process.exit(1);13}14// Expected: CI job "docs:check" passes when every public export has a comment.15// Break it: export a new public function with no /** */ comment —16// expected: CI fails with that export's name listed.

Remember this

A required CI job that fails on any undocumented public export turns "please document your code" from a code-review nag into a merge blocker — that's the only version of this rule that survives past the first busy sprint.

Key takeaway

Build the pipeline against a small real module: write fetchOrder() with a full TSDoc comment (summary, @param, @returns, one @example), run the extraction command, and confirm reflections.json contains a populated comment for it. Then run the MDX generator and confirm the rendered page shows the signature, parameter description, and example exactly as written.

Break it on purpose: add a second exported function with no doc comment at all, re-run the docs:check script, and confirm it exits non-zero with that function's name in the failure output. Pass criterion: the documented function renders a complete MDX page and the undocumented one fails CI by name — not silently, and not as a warning that a busy engineer can ignore.

Share:

Related Articles

A knowledge base is a different problem from a marketing site's blog, even though both start as "pages made of Markdown.

Read

Next.js 16 continues the evolution of web application architecture, refining React Server Components (RSC), introducing

Read

While built-in tools (file editing, shell execution, web search) handle standard development workflows, engineering team

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.