Skip to main content
Technology & EngineeringDocument Generation Services298 lines

Markdoc

Markdoc: Stripe's Markdown-based authoring framework for structured documentation, custom tags, validation, and renderers

Quick Summary15 lines
You are an expert in using Markdoc for structured document generation and documentation authoring.

## Key Points

- Define strict schemas for all custom tags with `matches` constraints on attributes so that validation catches typos and invalid values at build time rather than producing broken output silently.
- Use Markdoc variables for product names, version numbers, and feature flags rather than hardcoding them — this keeps documentation accurate across releases by changing a single config value.

## Quick Example

```
{% /tabItem %}
{% tabItem label="yarn" %}
```
skilldb get document-generation-services-skills/MarkdocFull skill: 298 lines
Paste into your CLAUDE.md or agent config

Markdoc — Document Generation

You are an expert in using Markdoc for structured document generation and documentation authoring.

Core Philosophy

Overview

Markdoc is an open-source documentation authoring framework created by Stripe. It extends Markdown with a custom tag syntax, variable interpolation, conditional content, and a validation system that catches errors at build time. Unlike MDX which gives authors full JavaScript power, Markdoc constrains what authors can do through a declarative schema — making it safer for multi-author documentation sites where you want to enforce consistency without risking arbitrary code execution. Markdoc can render to React, HTML strings, or any custom target.

Setup & Configuration

npm install @markdoc/markdoc

# For React rendering
npm install @markdoc/markdoc react react-dom

# For Next.js integration
npm install @markdoc/next.js
// markdoc.config.ts — schema definition
import { Config, Schema, Tag } from "@markdoc/markdoc";

// Define custom tags that authors can use in Markdown
const callout: Schema = {
  render: "Callout",
  description: "Display a callout box with a type-based style",
  attributes: {
    type: {
      type: String,
      default: "info",
      matches: ["info", "warning", "error", "success"],
      description: "Controls the visual style of the callout",
    },
    title: {
      type: String,
      required: false,
      description: "Optional title displayed at the top",
    },
  },
  children: ["paragraph", "list", "code"],
};

const tabs: Schema = {
  render: "Tabs",
  attributes: {},
  children: ["tag"],  // Only allows nested tags (TabItem)
};

const tabItem: Schema = {
  render: "TabItem",
  attributes: {
    label: { type: String, required: true },
  },
  children: ["paragraph", "list", "code", "fence", "tag"],
};

// Define custom nodes to override default Markdown behavior
const heading: Schema = {
  render: "Heading",
  attributes: {
    level: { type: Number, required: true },
    id: { type: String },
  },
  transform(node, config) {
    const children = node.transformChildren(config);
    const text = children
      .filter((c): c is string => typeof c === "string")
      .join(" ");
    const id = text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "");
    return new Tag("Heading", { level: node.attributes.level, id }, children);
  },
};

// Variables available to all documents
const variables: Record<string, unknown> = {
  product: { name: "MyApp", version: "3.2.0" },
  flags: { betaFeatures: true },
};

// Functions available in Markdoc expressions
const functions: Config["functions"] = {
  upper: {
    transform(parameters: Record<string, unknown>) {
      const value = parameters[0];
      return typeof value === "string" ? value.toUpperCase() : value;
    },
  },
};

export const markdocConfig: Config = {
  tags: { callout, tabs, tabItem },
  nodes: { heading },
  variables,
  functions,
};

Core Patterns

Parsing, validating, and rendering documents

import Markdoc, { RenderableTreeNode } from "@markdoc/markdoc";
import { markdocConfig } from "./markdoc.config";

interface ProcessedDocument {
  content: RenderableTreeNode;
  errors: string[];
  frontmatter: Record<string, unknown>;
}

function processDocument(source: string): ProcessedDocument {
  // 1. Parse: Markdown string -> AST
  const ast = Markdoc.parse(source);

  // 2. Extract frontmatter (YAML between --- fences)
  const frontmatter = ast.attributes.frontmatter
    ? (Markdoc.parse(ast.attributes.frontmatter) as any) // Use a YAML parser in production
    : {};

  // 3. Validate: check the AST against the schema
  const errors = Markdoc.validate(ast, markdocConfig);
  const errorMessages = errors.map(
    (e) => `Line ${e.lines?.[0] ?? "?"}: ${e.error.message}`
  );

  if (errors.some((e) => e.error.level === "critical")) {
    return { content: "", errors: errorMessages, frontmatter };
  }

  // 4. Transform: AST -> renderable tree
  const content = Markdoc.transform(ast, markdocConfig);

  return { content, errors: errorMessages, frontmatter };
}

// Render to HTML string (for server-side or static generation)
function renderToHtml(source: string): string {
  const { content, errors } = processDocument(source);
  if (errors.length > 0) {
    console.warn("Markdoc validation warnings:", errors);
  }
  return Markdoc.renderers.html(content);
}

React rendering with custom components

// components/Callout.tsx
import React from "react";

interface CalloutProps {
  type: "info" | "warning" | "error" | "success";
  title?: string;
  children: React.ReactNode;
}

const styles: Record<string, { bg: string; border: string; icon: string }> = {
  info:    { bg: "#eff6ff", border: "#3b82f6", icon: "i" },
  warning: { bg: "#fffbeb", border: "#f59e0b", icon: "!" },
  error:   { bg: "#fef2f2", border: "#ef4444", icon: "x" },
  success: { bg: "#f0fdf4", border: "#22c55e", icon: "+" },
};

export function Callout({ type, title, children }: CalloutProps) {
  const s = styles[type];
  return (
    <div style={{ background: s.bg, borderLeft: `4px solid ${s.border}`, padding: "12px 16px", margin: "16px 0", borderRadius: 4 }}>
      {title && <strong style={{ display: "block", marginBottom: 4 }}>{title}</strong>}
      {children}
    </div>
  );
}
// render.tsx — React renderer
import React from "react";
import Markdoc from "@markdoc/markdoc";
import { Callout } from "./components/Callout";
import { Tabs, TabItem } from "./components/Tabs";
import { Heading } from "./components/Heading";

const components = { Callout, Tabs, TabItem, Heading };

interface DocPageProps {
  source: string;
}

export function DocPage({ source }: DocPageProps) {
  const { content, errors } = processDocument(source);

  return (
    <article>
      {errors.length > 0 && (
        <pre style={{ color: "red" }}>{errors.join("\n")}</pre>
      )}
      {Markdoc.renderers.react(content, React, { components })}
    </article>
  );
}

Markdoc content with custom tags and variables

---
title: Getting Started
---

# Welcome to {% $product.name %}

Current version: **{% $product.version %}**

{% callout type="info" title="Prerequisites" %}
Make sure you have Node.js 18+ installed before proceeding.
{% /callout %}

{% tabs %}
{% tabItem label="npm" %}
```bash
npm install myapp

{% /tabItem %} {% tabItem label="yarn" %}

yarn add myapp

{% /tabItem %} {% /tabs %}

{% if $flags.betaFeatures %}

Beta Features

These features are currently in preview. {% /if %}


### Next.js integration

```typescript
// next.config.mjs
import withMarkdoc from "@markdoc/next.js";

export default withMarkdoc({ schemaPath: "./markdoc" })({
  pageExtensions: ["md", "mdoc", "tsx", "ts"],
});
# File structure for Next.js + Markdoc
pages/
  docs/
    getting-started.md    # Markdoc files become pages
    configuration.md
markdoc/
  tags/                   # Tag schemas auto-discovered
    callout.markdoc.ts
    tabs.markdoc.ts
  nodes/                  # Node overrides auto-discovered
    heading.markdoc.ts
  functions/              # Custom functions
    upper.markdoc.ts

Best Practices

  • Define strict schemas for all custom tags with matches constraints on attributes so that validation catches typos and invalid values at build time rather than producing broken output silently.
  • Use Markdoc variables for product names, version numbers, and feature flags rather than hardcoding them — this keeps documentation accurate across releases by changing a single config value.
  • Prefer Markdoc over MDX when multiple non-developer authors contribute documentation, because the constrained tag syntax prevents introducing arbitrary JavaScript that could break the build or create security issues.

Common Pitfalls

  • Forgetting the closing tag syntax ({% /callout %}) — Markdoc tags are not self-closing by default, and an unclosed tag will swallow all subsequent content into the tag's children without a clear error.
  • Not running Markdoc.validate() before transform() — skipping validation means malformed documents render with missing content or wrong structure instead of surfacing actionable error messages during the build.

Anti-Patterns

Using the service without understanding its pricing model. Cloud services bill differently — per request, per GB, per seat. Deploying without modeling expected costs leads to surprise invoices.

Hardcoding configuration instead of using environment variables. API keys, endpoints, and feature flags change between environments. Hardcoded values break deployments and leak secrets.

Ignoring the service's rate limits and quotas. Every external API has throughput limits. Failing to implement backoff, queuing, or caching results in dropped requests under load.

Treating the service as always available. External services go down. Without circuit breakers, fallbacks, or graceful degradation, a third-party outage becomes your outage.

Coupling your architecture to a single provider's API. Building directly against provider-specific interfaces makes migration painful. Wrap external services in thin adapter layers.

Install this skill directly: skilldb add document-generation-services-skills

Get CLI access →