OpenAPI-to-MDX Documentation Generator

Protocols & Interoperability intermediate Building with AI ~30 min read

Overview

API documentation is only as useful as it is accurate and up-to-date. When documentation is hand-written separately from the API implementation, it inevitably drifts — endpoints are added but not documented, response schemas change but the docs still show the old format, and developers learn to distrust the documentation entirely. The solution is to generate documentation directly from the API’s source of truth: its OpenAPI (formerly Swagger) specification.

An OpenAPI-to-MDX documentation generator is a build-time pipeline that reads OpenAPI YAML or JSON specification files, parses them into a structured representation, and produces MDX (Markdown with JSX) pages suitable for modern documentation frameworks like Docusaurus, Nextra, or custom React-based doc sites. Each endpoint becomes a page or section with method badges (GET, POST, PUT, DELETE), request/response schemas rendered as interactive code tabs, authentication requirements, and parameter tables — all generated from the spec without manual intervention.

In a typical API platform, custom Node.js build scripts use the js-yaml library to parse OpenAPI specs and produce MDX files that integrate directly into the documentation site’s build pipeline. This ensures that every deployment of the documentation site reflects the current state of the API specification, and that the documentation build fails if the spec is malformed — catching errors before they reach users.

Core Concepts

OpenAPI Specification as Single Source of Truth

The OpenAPI specification (OAS) is a language-agnostic standard for describing REST APIs. It defines endpoints, HTTP methods, parameters, request bodies, response schemas, authentication mechanisms, and metadata in a structured YAML or JSON format. By treating the OpenAPI spec as the authoritative description of the API, all downstream artifacts — client SDKs, server stubs, documentation, tests — can be generated rather than manually maintained. The spec is version-controlled alongside the code, reviewed in PRs, and validated by CI.

MDX: Markdown with Component Power

MDX extends Markdown with the ability to embed JSX components inline. This is critical for API documentation because static Markdown cannot express interactive elements like tabbed code examples (showing curl, Python, Go, and TypeScript in tabs), collapsible schema trees, or copyable request snippets. The generator produces MDX that uses the documentation site’s component library — <CodeTabs>, <MethodBadge>, <ResponseExample> — creating rich, interactive documentation from a declarative spec.

Build-Time Generation vs Runtime Rendering

The generator runs at build time, not at request time. This means documentation pages are static HTML after the build completes — fast to serve, cacheable, and searchable. If the OpenAPI spec has a validation error or a schema reference is broken, the build fails immediately rather than producing a broken page that users discover later. Build-time generation also means the generator’s dependencies (js-yaml, handlebars, custom transforms) are dev dependencies that never ship to production.

Schema Dereferencing and Composition

OpenAPI specs use $ref pointers extensively to avoid schema duplication. A response schema might reference #/components/schemas/User, which itself references #/components/schemas/Address. The generator must recursively resolve these references, handle circular references gracefully, and compose the final schema into a human-readable format. This dereferencing step transforms a DRY but hard-to-read spec into fully expanded, self-contained documentation for each endpoint.

Method Badges and Visual Hierarchy

API documentation readers scan for specific endpoints. Method badges — colored labels showing GET (green), POST (blue), PUT (orange), DELETE (red) — provide instant visual identification. The generator assigns badge styles based on the HTTP method, groups endpoints by tag or path prefix, and builds a navigable table of contents. This visual hierarchy transforms a flat list of endpoints into a scannable, organized reference that developers can navigate efficiently.

How It Works

The documentation generation pipeline has four stages: parsing, transformation, template rendering, and file output. Each stage is a pure function that takes structured data and produces structured output, making the pipeline testable and debuggable.

Stage 1: Parsing the OpenAPI Spec

// scripts/generate-api-docs.js
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');

// Parse the OpenAPI YAML spec into a JavaScript object.
function loadSpec(specPath) {
  const raw = fs.readFileSync(specPath, 'utf-8');
  const spec = yaml.load(raw);

  // Validate required top-level fields.
  if (!spec.openapi || !spec.paths) {
    throw new Error(`Invalid OpenAPI spec: missing 'openapi' or 'paths' field`);
  }

  // Check version compatibility.
  const version = spec.openapi;
  if (!version.startsWith('3.0') && !version.startsWith('3.1')) {
    throw new Error(`Unsupported OpenAPI version: ${version}. Expected 3.0.x or 3.1.x`);
  }

  return spec;
}

const spec = loadSpec('./api/openapi.yaml');
console.log(`Loaded spec: ${spec.info.title} v${spec.info.version}`);
console.log(`Endpoints: ${Object.keys(spec.paths).length}`);

Stage 2: Resolving References and Transforming Data

// Recursively resolve $ref pointers in the spec.
function resolveRef(spec, ref) {
  // ref format: "#/components/schemas/User"
  const parts = ref.replace('#/', '').split('/');
  let current = spec;
  for (const part of parts) {
    current = current[part];
    if (!current) {
      throw new Error(`Unresolved $ref: ${ref}`);
    }
  }
  return current;
}

// Dereference all $ref pointers in an object, with cycle detection.
function dereference(spec, obj, visited = new Set()) {
  if (!obj || typeof obj !== 'object') return obj;

  if (obj.$ref) {
    if (visited.has(obj.$ref)) {
      return { type: 'object', description: '[Circular reference]' };
    }
    visited.add(obj.$ref);
    const resolved = resolveRef(spec, obj.$ref);
    return dereference(spec, resolved, new Set(visited));
  }

  const result = Array.isArray(obj) ? [] : {};
  for (const [key, value] of Object.entries(obj)) {
    result[key] = dereference(spec, value, new Set(visited));
  }
  return result;
}

// Transform an endpoint into a documentation-friendly structure.
function transformEndpoint(spec, pathStr, method, operation) {
  const resolved = dereference(spec, operation);
  return {
    path: pathStr,
    method: method.toUpperCase(),
    summary: resolved.summary || '',
    description: resolved.description || '',
    operationId: resolved.operationId || `${method}_${pathStr}`,
    tags: resolved.tags || ['Untagged'],
    parameters: (resolved.parameters || []).map(p => ({
      name: p.name,
      in: p.in,          // 'path', 'query', 'header'
      required: p.required || false,
      type: p.schema?.type || 'string',
      description: p.description || '',
    })),
    requestBody: resolved.requestBody
      ? dereference(spec, resolved.requestBody)
      : null,
    responses: Object.entries(resolved.responses || {}).map(
      ([code, resp]) => ({
        code,
        description: resp.description || '',
        schema: resp.content?.['application/json']?.schema || null,
      })
    ),
    security: resolved.security || spec.security || [],
  };
}

Stage 3: Rendering MDX Templates

// Generate MDX content for a single endpoint.
function renderEndpointMDX(endpoint) {
  const methodColors = {
    GET: 'green', POST: 'blue', PUT: 'orange',
    DELETE: 'red', PATCH: 'purple',
  };
  const color = methodColors[endpoint.method] || 'gray';

  let mdx = '';
  mdx += `---\n`;
  mdx += `title: "${endpoint.summary}"\n`;
  mdx += `sidebar_label: "${endpoint.method} ${endpoint.path}"\n`;
  mdx += `---\n\n`;
  mdx += `import { MethodBadge, CodeTabs, ResponseExample, ParamTable } from '@site/src/components';\n\n`;
  mdx += `# ${endpoint.summary}\n\n`;
  mdx += `\n\n`;
  mdx += `\`\`\`\n${endpoint.method} ${endpoint.path}\n\`\`\`\n\n`;
  mdx += `${endpoint.description}\n\n`;

  // Parameters table.
  if (endpoint.parameters.length > 0) {
    mdx += `## Parameters\n\n`;
    mdx += `\n\n`;
  }

  // Request body with code tabs.
  if (endpoint.requestBody) {
    mdx += `## Request Body\n\n`;
    const schema = endpoint.requestBody.content?.['application/json']?.schema;
    if (schema) {
      mdx += `\n`;
      mdx += `\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\`\n`;
      mdx += `\`\`\`json\n${JSON.stringify(generateExample(schema), null, 2)}\n\`\`\`\n`;
      mdx += `\n\n`;
    }
  }

  // Response examples.
  mdx += `## Responses\n\n`;
  for (const resp of endpoint.responses) {
    mdx += `\n`;
    if (resp.schema) {
      mdx += `\`\`\`json\n${JSON.stringify(generateExample(resp.schema), null, 2)}\n\`\`\`\n`;
    }
    mdx += `\n\n`;
  }

  return mdx;
}

Stage 4: Orchestrating the Full Build

// Main build script: parse spec, generate all MDX files.
async function buildApiDocs(specPath, outputDir) {
  const spec = loadSpec(specPath);
  const endpoints = [];

  // Extract all endpoints from the spec.
  for (const [pathStr, pathItem] of Object.entries(spec.paths)) {
    for (const method of ['get', 'post', 'put', 'delete', 'patch']) {
      if (pathItem[method]) {
        endpoints.push(
          transformEndpoint(spec, pathStr, method, pathItem[method])
        );
      }
    }
  }

  // Group endpoints by tag for organized output.
  const byTag = {};
  for (const ep of endpoints) {
    const tag = ep.tags[0] || 'Untagged';
    (byTag[tag] ||= []).push(ep);
  }

  // Write MDX files: one per endpoint, plus a sidebar config.
  fs.mkdirSync(outputDir, { recursive: true });
  const sidebarItems = [];

  for (const [tag, tagEndpoints] of Object.entries(byTag)) {
    const tagDir = path.join(outputDir, slugify(tag));
    fs.mkdirSync(tagDir, { recursive: true });

    for (const ep of tagEndpoints) {
      const filename = `${slugify(ep.operationId)}.mdx`;
      const filepath = path.join(tagDir, filename);
      fs.writeFileSync(filepath, renderEndpointMDX(ep));
      console.log(`  Generated: ${filepath}`);
    }

    sidebarItems.push({
      type: 'category',
      label: tag,
      items: tagEndpoints.map(ep => ({
        type: 'doc',
        id: `api/${slugify(tag)}/${slugify(ep.operationId)}`,
      })),
    });
  }

  // Write sidebar configuration for Docusaurus.
  const sidebarPath = path.join(outputDir, '_sidebar.json');
  fs.writeFileSync(sidebarPath, JSON.stringify(sidebarItems, null, 2));
  console.log(`\nGenerated ${endpoints.length} endpoint docs in ${outputDir}`);
}

buildApiDocs('./api/openapi.yaml', './docs/api');

Practical Applications

In a documentation site pipeline, the OpenAPI-to-MDX generator runs as a prebuild step in CI. The OpenAPI spec lives in the main API repository and is pulled as a build dependency. When a developer adds a new API endpoint, they update the OpenAPI spec in their PR, and the documentation site’s CI rebuilds the MDX pages. The generated pages appear in the staging preview automatically — no separate documentation PR required. This ensures that API documentation ships simultaneously with the API itself.

A public-facing developer documentation site can use the same generator pattern. Each API version (v1, v2) has its own OpenAPI spec, and the generator produces versioned documentation trees. A custom post-processing step adds authentication flow diagrams and rate limit tables that are referenced from the spec’s x- extension fields. This approach extends the OpenAPI specification with project-specific metadata without breaking spec compliance.

The generator also produces a machine-readable endpoint index that the developer dashboard consumes. This index powers features like the API explorer (try-it-in-browser), the SDK generator’s method naming, and the changelog diff tool that highlights which endpoints changed between releases. By generating multiple artifacts from a single spec, the OpenAPI file becomes the hub of the API’s entire tooling ecosystem.

Implementation Patterns

Incremental Generation with File Hashing

Full regeneration of all MDX files on every build is wasteful when only one endpoint changed. An incremental generation strategy computes a content hash of each endpoint’s relevant spec data (path, method, parameters, schemas) and compares it against a manifest of previously generated hashes. Only endpoints whose hash changed are regenerated. This reduces build times from seconds to milliseconds for large APIs with hundreds of endpoints.

const crypto = require('crypto');

function endpointHash(endpoint) {
  const data = JSON.stringify({
    path: endpoint.path,
    method: endpoint.method,
    parameters: endpoint.parameters,
    requestBody: endpoint.requestBody,
    responses: endpoint.responses,
  });
  return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
}

function generateIncremental(endpoints, outputDir, manifestPath) {
  // Load previous manifest: { operationId: hash }
  let manifest = {};
  if (fs.existsSync(manifestPath)) {
    manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
  }

  let generated = 0;
  let skipped = 0;
  const newManifest = {};

  for (const ep of endpoints) {
    const hash = endpointHash(ep);
    newManifest[ep.operationId] = hash;

    if (manifest[ep.operationId] === hash) {
      skipped++;
      continue;
    }

    const filepath = path.join(outputDir, `${slugify(ep.operationId)}.mdx`);
    fs.writeFileSync(filepath, renderEndpointMDX(ep));
    generated++;
  }

  fs.writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
  console.log(`Generated: ${generated}, Skipped (unchanged): ${skipped}`);
}

Example Generation from JSON Schema

Documentation is most useful when it includes realistic request and response examples. Rather than requiring developers to manually write examples, the generator can synthesize them from JSON Schema definitions. It walks the schema tree, generates values appropriate for each type and format (e.g., "format": "email" becomes "user@example.com"), and respects constraints like enum, minLength, and pattern.

function generateExample(schema) {
  if (!schema) return null;
  if (schema.example !== undefined) return schema.example;
  if (schema.enum) return schema.enum[0];

  switch (schema.type) {
    case 'string':
      if (schema.format === 'email') return 'user@example.com';
      if (schema.format === 'date-time') return '2025-01-15T09:30:00Z';
      if (schema.format === 'uuid') return '550e8400-e29b-41d4-a716-446655440000';
      if (schema.format === 'uri') return 'https://api.example.com/resource';
      return schema.description?.slice(0, 20) || 'string';
    case 'integer':
      return schema.minimum ?? 1;
    case 'number':
      return schema.minimum ?? 1.0;
    case 'boolean':
      return true;
    case 'array':
      return [generateExample(schema.items)];
    case 'object': {
      const obj = {};
      for (const [key, propSchema] of Object.entries(schema.properties || {})) {
        obj[key] = generateExample(propSchema);
      }
      return obj;
    }
    default:
      return null;
  }
}

Common Pitfalls

Unresolved $ref Pointers Breaking the Build

OpenAPI specs frequently use $ref to reference shared schemas. If a referenced component is renamed or deleted without updating all references, the generator crashes with an opaque “cannot resolve” error. Add a validation pass before generation that checks every $ref in the spec against the components list, producing a clear error listing all broken references and their locations.

Circular Schema References Causing Infinite Loops

Schemas that reference themselves (e.g., a TreeNode with a children array of TreeNode) create infinite recursion during dereferencing and example generation. The generator must track visited references and break cycles — either by returning a placeholder like [Circular] or by limiting recursion depth to a configurable maximum (typically 3-5 levels).

Losing MDX Formatting in Generated Code Blocks

MDX parsers interpret certain characters as JSX expressions. If a generated code example contains curly braces {} or angle brackets <>, the MDX compiler may attempt to parse them as JSX, producing cryptic build errors. All generated code blocks must be wrapped in proper fenced code blocks with language annotations, and any inline code in descriptions must be properly escaped.

Stale Generated Files After Endpoint Removal

When an endpoint is removed from the spec, the previously generated MDX file remains on disk. If the build doesn’t clean old files, the documentation site continues to show the removed endpoint. Implement a cleanup step that compares generated files against current endpoints and deletes orphaned files, or use a clean output directory on each full build.

Further Reading

Further Reading