Skip to main content

Create a copy as markdown button for MDX documentation site

Published:

11 min read

Using MDX for writing and managing content for a documentation site provides flexibility if you are deep into the “docs-as-code” workflow. This flexibility allows you to mix Markdown with React components, use dynamic data and stitch them together at runtime.

However, this flexibility also comes with challenges. MDX files often contain components that render content at runtime and these components are not included in the raw Markdown output:

Using raw .mdx files on their own to add a feature such as a “copy as markdown” button on each page of your documentation site won’t include the dynamic content stitched at runtime.

Note: This article is not a comprehensive guide to implementing the “copy as markdown” button. It is a high-level overview of the solution and implementation details for a Next.js based documentation site that I work on.

Solution overview

One way to solve this problem and implement the “copy as markdown” button is to identify the key tasks. In a typical MDX documentation site, the key tasks are:

At a high level, the flow looks like this:

┌───────────────┐     fetch raw .mdx     ┌──────────────────────┐
│ Copy Button   │ ─────────────────────▶ │ prepareMarkdown…     │
└─────┬─────────┘                        │ 1. strip imports     │
      │                                  │ 2. expand scenes     │
      │ write Markdown                   │ 3. render schemas    │
      ▼                                  │ 4. render API JSON   │
┌───────────────┐                        │ 5. final assembly    │
│ Clipboard /   │ ◀───────────────────── │ returns Markdown     │
│ AI tools      │                        └──────────────────────┘
└───────────────┘

Everything happens on the client-side using static resources shipped with the site. Implementing this flow requires using regular expressions to strip out the React imports and expand the dynamic components into Markdown.

The solution will be custom to each documentation site and the data sources it uses. However, the core idea of solving this problem is to identify the key tasks. From the next section onwards, I will share my implementation of the solution for a Next.js based documentation site.

Implementation overview

Here is an overview of how I implemented the above solution for a Next.js based documentation site.

Create a React component

Initially, your page will require a button to trigger the “copy as markdown” action. This button can be in a dropdown that renders other available actions such as “open in X AI tool”.

The component needs to know which MDX file corresponds to the current page. In a documentation site powered by Next.js, you can fetch the MDX file from the router:

export function MarkdownActionsDropdown() {
  const router = useRouter();
  const pathname = router?.pathname;
  const asPath = router?.asPath;

  // Build the raw GitHub URL for the current page's MDX file
  const rawMarkdownUrl = useMemo(() => {
    if (!pathname) {
      return null;
    }
    // Convert route pathname to file path (e.g., /sdk/abc → pages/sdk/abc.mdx)
    const filePath = getPageMdxFilePath(pathname);
    // Generate raw GitHub content URL
    return filePath ? githubRawUrl(pathname) : null;
  }, [pathname]);

  const handleCopyMarkdown = useCallback(async () => {
    if (!rawMarkdownUrl) return;

    // Fetch the raw MDX file from GitHub or your CDN
    const response = await fetch(rawMarkdownUrl);
    if (!response.ok) {
      throw new Error(`Failed to fetch markdown: ${response.status}`);
    }

    const mdx = await response.text();

    // Convert MDX to Markdown, passing the current path for context
    // This helps resolve versioned URLs (if any) like /versions/v1.0.0/sdk/abc
    const markdown = await prepareMarkdownForCopyAsync(mdx, {
      path: asPath ?? pathname ?? ''
    });

    await navigator.clipboard.writeText(markdown);
  }, [rawMarkdownUrl, asPath, pathname]);

  // Render dropdown items (Copy as Markdown, Open in ChatGPT, Open in Claude, etc.)
}

Some documentation sites might have versioned content such as /versions/v1.0.0/sdk/abc. In such cases, you can use the path parameter to resolve the versioned URL to the raw MDX file. Furthermore, these versioned URLs might also contain the TypeDoc/JSDoc output for API documentation. It is important to know which version to fetch for which page. The path parameter allows our converter to extract the version number and load the correct data files.

Another important function from the above code snippet is prepareMarkdownForCopyAsync(). It is the heart of the pipeline and orchestrates all the conversions.

Parsing frontmatter and normalizing Markdown

The prepareMarkdownForCopyAsync() function begins by processing the raw MDX files in stages. In each stage, it handles a specific type of content transformation:

export async function prepareMarkdownForCopyAsync(
  rawContent: string,
  context: { path?: string } = {}
) {
  if (!rawContent) return '';

  // Step 1: Extract YAML front matter (title, description, etc.)
  // This separates metadata from content so you can use it as headings
  let { content, title, description } = extractFrontMatter(rawContent);

  // Step 2: Find all schema imports before you remove them
  // Example: import abcSchema from '~/public/static/schemas/abc.ts'
  // You need to track these because schema components reference them by name
  const schemaImports = extractSchemaImports(content);

  // Step 3: Remove all import statements
  // MDX imports React components, but you're converting to plain Markdown
  content = content.replace(IMPORT_STATEMENT_PATTERN, '');

  // Step 4: Convert custom React components to Markdown equivalents
  // Each function handles a specific component type
  content = convertBoxLinksToMarkdown(content); // <BoxLink title="Guides" href="/guides" /> → [Guides](/guides)
  content = convertContentSpotlight(content); // <ContentSpotlight file="screenshot.png" /> → ![Screenshot](https://example.com/static/images/screenshot.png)
  content = convertTerminalsToCodeBlocks(content); // <Terminal cmd={['$ npm install']} /> → ```bash\n$ npm install\n```

  // Step 5: Expand dynamic components that load external content
  // These are async because they need to fetch additional files
  content = await replaceSceneComponentsAsync(content, schemaImports);
  content = await replaceSchemaComponentsAsync(content, schemaImports, context);
  content = await replaceApiSectionsAsync(content, context);

  // Step 6: Reassemble the complete document with front matter as headings
  return assembleDocument({ title, description, content });
}

Processing easier components like the frontmatter of the page, code blocks, links, embedded images, and so on, before processing complex ones takes care of most of the edge cases. This prevents intermediate transformations from interfering with pattern matching in later stages.

Let’s look at one of the simpler conversions to understand the pattern:

function convertTerminalsToCodeBlocks(content: string): string {
  // Match: <Terminal cmd={['$ npm install', '$ npm start']} />
  const terminalPattern = /<Terminal\s+cmd=\{(\[[^\]]+\])\}\s*\/>/g;

  return content.replace(terminalPattern, (match, cmdArray) => {
    // Parse the JavaScript array of commands
    // cmdArray is a string like "['$ npm install', '$ npm start']"
    const commands = JSON.parse(cmdArray.replace(/'/g, '"'));

    // Convert to a bash code block
    return '```bash\n' + commands.join('\n') + '\n```';
  });
}

This pattern repeats throughout the converter: find a component pattern with regex, extract its props, and return the Markdown equivalent.

Expanding scene and reusable components

A scene component lives inside a separate file. Its purpose is to provide consistency when creating documentation pages that follow a specific UI/content pattern. They can be either custom to a page or reused across multiple pages with different input values. MDX files often render entire wizards or multi-step flows through these custom components. Here’s an example:

<!-- Page: Integration guide -->
<Prerequisites />
<Configuration />
<Steps />

When rendered live, these custom components inject the content of the separate files into the current page. The raw MDX only contains the component tag, not the actual instructions. You need to load all the scene variations and include them in the output.

export async function generateEnvironmentInstructionsMarkdownAsync() {
  // List all the different instruction variants
  const sectionPaths = [
    'scenes/prerequisites.mdx',
    'scenes/configuration.mdx',
    'scenes/steps.mdx'
    // ... other combinations
  ];

  const sections = [];

  for (const relativePath of sectionPaths) {
    // Fetch the scene's MDX content from your static assets
    const raw = await fetchSceneMdx(relativePath);

    // Transform the scene MDX just like we transform the main page:
    // - Remove imports
    // - Convert <Prerequisites>, <Configuration>, <Steps> components to Markdown
    const markdown = transformSceneMdx(raw);

    // Create a descriptive heading from the file path
    // 'scenes/prerequisites.mdx' → 'Prerequisites'
    const heading = headingFor(relativePath);

    sections.push(`## ${heading}\n\n${markdown}`);
  }

  // Join all sections with blank lines
  return sections.join('\n\n');
}

The transformation function (transformSceneMdx()) for scene MDX reuses the existing patterns:

function transformSceneMdx(raw: string): string {
  let content = raw;

  // Remove imports and front matter from the scene file
  content = content.replace(IMPORT_STATEMENT_PATTERN, '');
  content = extractFrontMatter(content).content;

  // Convert scene-specific components
  // <Prerequisites /> becomes "## Prerequisites"
  content = convertPrerequisitesToMarkdown(content);

  // <Step> becomes numbered headings: "### Step 1: Install dependencies"
  content = convertStepComponents(content);

  // <Terminal> and other standard components use the same converters
  content = convertTerminalsToCodeBlocks(content);

  return content;
}

Now when prepareMarkdownForCopyAsync() encounters <Steps />, it replaces it with this pre-rendered Markdown. You can apply this same pattern to other scene/custom components—the key is identifying what external content they load and fetching it during conversion.

Converting TypeDoc/JSDoc API Sections

API documentation is often the most complex content to convert. A documentation site can use TypeDoc/JSDoc to generate JSON from TypeScript/JavaScript source code, then render that JSON. Your MDX might include a component that renders the API classes, methods, types, and properties:

<APISection packageName="api-name" />

This tells the site to load TypeDoc JSON (like /public/static/data/v1.0.0/api-name.json) and render all its classes, methods, types, and properties. The core challenge is to convert the JSON into Markdown since TypeDoc generates deeply nested JSON structures with classes, interfaces, methods, and other element types. Each element needs to be formatted appropriately for Markdown while preserving all the documentation details developers expect.

The conversion process involves identifying the key rendering patterns. For each TypeDoc element type, you can extract structured information and format it consistently. Here are the key rendering patterns:

This conversion approach looks like:

export async function generateApiSectionMarkdownAsync(
  options: {
    packageName?: string | string[];
    apiName?: string;
    forceVersion?: string;
  },
  context: { path?: string }
) {
  // 1. Resolve the correct version from the URL path
  const version = resolveVersion(options.forceVersion, context.path);

  // 2. Normalize package names to an array
  const packages = Array.isArray(options.packageName)
    ? options.packageName
    : options.packageName
      ? [options.packageName]
      : [];

  // 3. Fetch TypeDoc JSON for the specified packages
  const dataFiles = await Promise.all(
    packages.map(pkg => fetchPackageDataAsync(version, pkg))
  );

  // 4. Extract all exported items (classes, interfaces, functions, etc.)
  const entries = dataFiles.flatMap(file => file?.children ?? []);

  // 5. Convert each entry to Markdown based on its type
  const sections = entries.map(entry => renderEntryMarkdown(entry, version));

  const displayName = Array.isArray(options.packageName)
    ? options.packageName.join(', ')
    : options.packageName || 'API';

  return `## API: ${displayName}\n\n${sections.join('\n\n')}`;
}

Example output structure:

## API: api-name

### API Name

A class representing an API in your app.

#### Properties

- `uri?: string`
  The URI of the image.

- `width?: number`
  Width of the image in pixels.

#### Methods

- `loadAsync(): Promise<Image>`
  Loads the image asynchronously
  **Returns:** A promise that resolves to the loaded image
  **Example:**
  ```typescript
  const image = await Image.loadAsync(require('./image.png'));
  ```

The TypeDoc conversion follows the same pattern as other components: detect the component tag, fetch the necessary data (TypeDoc JSON), transform it systematically (classes → Markdown, methods → Markdown, types → strings), and replace the original tag with the generated content.

TypeDoc’s JSON is structured and predictable. Once you understand the schema (classes have children, methods have signatures, types have type fields), you can write straightforward transformation functions that handle each case.

The Pattern in Practice

At this point, you’ve seen concrete examples of the conversion pipeline in action:

The beauty of this approach is its extensibility. Every new component type you need to handle follows the same pattern:

async function replaceCustomComponentAsync(content: string, context: any) {
  // 1. Pattern matching - Find the component tags
  const componentPattern = /<CustomComponent\s+prop="([^"]+)"\s*\/>/g;

  // 2. Data fetching - Load any external data the component needs
  const data = await fetchComponentData(componentPattern);

  // 3. Transformation - Convert the data to Markdown
  const markdown = transformDataToMarkdown(data);

  // 4. Replacement - Swap the component tag with Markdown
  return content.replace(componentPattern, markdown);
}

This pipeline architecture means you’re not locked into handling only the components we’ve discussed. As your documentation evolves and you add new interactive elements, you can extend the converter by adding new transformation functions.

Wrap up

Implementing a “copy as markdown” button for MDX documentation involves identifying the key tasks and writing helper functions to transform the dynamic parts of the page into raw Markdown. This approach should allow you to handle new components and data sources as they are added to the documentation site. Treat MDX components as structured data sources rather than opaque black boxes.

Support me

Next Post
How to add a recent posts section to your Astro blog

Aman Mittal author

I'm a software developer and technical writer. On this blog, I share my learnings about both fields. Recently, I have begun exploring other topics, so don't be surprised if you find something new here.

Currently, working on documentation at Expo.

Tech writing blog webring | Static.Quest webring