---
title: "Mintlify to Slack Canvas Migration: No API, All Filesystem"
slug: mintlify-to-slack-canvas-migration-no-api-all-filesystem
date: 2026-09-29
author: Abdul Wahab
categories: [Mintlify, Slack Canvas, Migration Guide]
excerpt: "Mintlify has no content export API. Migration means cloning the Git repo, downgrading MDX components to Canvas markdown, and rethinking navigation."
tldr: "Mintlify docs live as MDX files in Git — clone the repo, resolve snippets, downgrade components to Canvas-compatible markdown, and link API reference pages instead of migrating them."
canonical: https://clonepartner.com/blog/mintlify-to-slack-canvas-migration-no-api-all-filesystem
---

# Mintlify to Slack Canvas Migration: No API, All Filesystem


# Mintlify to Slack Canvas Migration: No API, All Filesystem

Mintlify has no bulk content export API. There is no endpoint that returns all your pages, no migration wizard targeting Slack Canvas. Mintlify documentation lives as **MDX files in a Git repository** — GitHub or GitLab — with navigation defined declaratively in a `docs.json` configuration file (older repositories may still use `mint.json`). The migration starts by cloning that repo and reading the filesystem, not by calling endpoints.

This guide covers what that architecture makes easy, what it makes hard, the explicit downgrade decisions you face converting MDX components into Canvas-compatible markdown, how to handle frontmatter and snippets, and whether public developer docs belong in an internal Slack surface at all.

> **No documented content export path exists.** Mintlify does offer a REST API, but it is designed for AI assistant embedding, search, analytics, and single-page content retrieval — not bulk migration. There is no "list all pages" or "export workspace" call. The reliable migration source is the repository itself.

## Why There Is No Mintlify Content API to Migrate From

**Mintlify's source of truth is the Git repository, not a database behind an API.** Pages are `.mdx` files, and each page contains content and YAML frontmatter metadata. The `docs.json` file is the required configuration file that controls navigation, appearance, integrations, API settings, and other site-wide behavior.

Your migration script starts with `git clone`, not `curl`. That is actually an advantage — you get the raw MDX source, the full navigation tree, every image, and the OpenAPI specs, all in one operation. No pagination, no rate limiting on the read side, no risk of missing draft content behind access controls.

A typical Mintlify repository looks like this:

```text
docs/
  docs.json
  introduction.mdx
  guides/
    auth.mdx
  api/
    openapi.yaml
  snippets/
    shared-callout.mdx
  images/
    auth-flow.png
```

## What the Filesystem Source Makes Easy

Cloning the repo gives you several advantages that API-based migrations rarely offer:

- **Complete content in one operation.** Every page, image, and config file. No pagination, no token-scoped access, no risk of missing unpublished drafts.
- **Navigation is declarative and machine-readable.** The `docs.json` file defines the entire site structure — tabs, groups, and pages — as a recursive JSON tree. You can parse it in a few lines of code and know exactly which pages exist and how they are organized.
- **Content is already Markdown-adjacent.** MDX extends Markdown with JSX components, so prose — headings, paragraphs, lists, links, code blocks, bold, italic — is standard Markdown that Slack Canvas accepts directly.
- **Version history is free.** Because the source is a Git repo, you have the full commit history for auditing changes or rolling back.

Here is how the navigation structure looks in `docs.json`:

```json
{
  "navigation": {
    "tabs": [
      {
        "tab": "Guides",
        "groups": [
          {
            "group": "Getting Started",
            "pages": ["quickstart", "installation"]
          }
        ]
      },
      {
        "tab": "API Reference",
        "openapi": "openapi.json"
      }
    ]
  }
}
```

Each string in `pages` maps directly to an `.mdx` file path. No slug resolution, no ID lookups. Your migration script walks this tree to determine what to convert and where to put it. Pages that exist in the repository but are absent from the navigation config are orphaned drafts — your script should flag or skip them.

Mintlify also supports `$ref` in `docs.json` to split the navigation config across multiple files. Resolve these references before parsing the navigation tree, or your script will miss entire sections.

One useful extraction shortcut: public Mintlify pages can be viewed as plain Markdown by appending `.md` to the URL (e.g., `https://docs.example.com/quickstart.md`). This works only for public, unauthenticated Mintlify sites — auth-gated or private Mintlify deployments will return 401. The `.md` output also includes snippet-resolved content, which makes it useful for spot-checking whether your local snippet resolution matches Mintlify's build output. It is not a substitute for pulling the full repo when you need structure, assets, and import relationships.

## `mint.json` vs `docs.json`: Which Schema Does Your Repo Use?

Older Mintlify repositories use `mint.json` as the configuration file. Mintlify migrated the standard to `docs.json` in 2024. The two schemas are **not identical**:

- `mint.json` uses a flat `navigation` array of group objects, each containing a `pages` array. There is no `tabs` wrapper.
- `docs.json` uses a nested `navigation.tabs` structure, where each tab contains `groups`, and each group contains `pages`. Tabs are a `docs.json`-only construct.
- Some `mint.json` fields (`"primaryTab"`, `"topbarLinks"`, `"topbarCtaButton"`) have no direct equivalent in `docs.json`.

Your migration script should detect which file is present and parse accordingly. A safe detection approach:

```python
import json, os

def load_nav_config(repo_root):
    for filename in ['docs.json', 'mint.json']:
        path = os.path.join(repo_root, filename)
        if os.path.exists(path):
            with open(path) as f:
                config = json.load(f)
            return config, filename
    raise FileNotFoundError("No docs.json or mint.json found in repo root")

config, schema_type = load_nav_config('/path/to/repo')

if schema_type == 'mint.json':
    # Flat: config['navigation'] is a list of {group, pages} objects
    pages = [p for group in config['navigation'] for p in group['pages']]
else:
    # Nested: config['navigation']['tabs'] contains groups
    tabs = config['navigation']['tabs']
    pages = [p for tab in tabs for g in tab.get('groups', []) for p in g.get('pages', [])]
```

## Handling MDX Frontmatter

Every Mintlify MDX file contains YAML frontmatter between `---` delimiters at the top of the file. This is not standard Markdown — Canvas will render it as literal text if you do not strip it before conversion.

Common frontmatter fields and their Canvas equivalents:

| Frontmatter Field | Canvas Treatment |
|---|---|
| `title` | Convert to the first H1 heading in the canvas body. If the page already has an H1, drop the frontmatter title. |
| `description` | Convert to an italicized paragraph immediately below the H1, or drop if redundant with the opening sentence. |
| `sidebarTitle` | Use as the canvas title in `canvases.create` if it is shorter and more descriptive than `title`. Otherwise use `title`. |
| `og:image`, `og:description` | Drop. These are SEO/social metadata with no Canvas equivalent. |
| `icon` | Drop. Canvas has no per-page icon concept. |
| `mode` | Drop. Controls Mintlify page styling (e.g., `"wide"`). No Canvas equivalent. |

Strip the frontmatter block before passing MDX content through your component converter. Store the extracted fields in a Python dict or JS object so you can use `title` and `sidebarTitle` when calling `canvases.create`.

```python
import re

def extract_frontmatter(mdx_content):
    """Extract YAML frontmatter and return (metadata_dict, body_without_frontmatter)."""
    pattern = r'^---\s*\n(.*?)\n---\s*\n'
    match = re.match(pattern, mdx_content, re.DOTALL)
    if not match:
        return {}, mdx_content
    import yaml
    metadata = yaml.safe_load(match.group(1))
    body = mdx_content[match.end():]
    return metadata, body
```

## Should Public Developer Docs Live in Slack Canvas?

For most teams: **no.** Evaluate the destination and your [source of truth](https://clonepartner.com/blog/blog/gitbook-to-slack-canvas-migration-source-of-truth-guide) before engineering the pipeline.

Mintlify is designed for public-facing developer documentation — SEO, external developer experience, API exploration. Slack Canvas is an internal collaboration surface designed for team alignment, runbooks, and meeting notes. Moving public developer docs into an internal Slack surface is almost always an architectural error. The specific losses:

- **No public access.** Canvases live inside a Slack workspace. External developers cannot see them without a Slack account in your workspace.
- **No search engine indexing.** Canvas content is invisible to Google and documentation aggregators.
- **No versioning.** Canvases have no version history, branching, PR review workflow, or rollback.
- **No structured navigation.** No sidebar, no breadcrumbs, no documentation-scoped search. Just a flat list of canvases findable through Slack's general search.
- **No analytics.** You cannot measure page views, popular search queries, or feedback on individual docs.

The cases where migrating Mintlify content to Canvas does make sense:

- **Internal runbooks and onboarding guides** that were hosted on a private Mintlify instance but are consumed only by your team. Moving these to Canvas puts them where engineers already communicate.
- **Archiving a deprecated project's docs** for internal reference. The content is frozen; you are moving it to where people can still find it without maintaining a separate site.
- **Supplementing Slack-first support workflows.** If your support team lives in Slack, having quick-reference docs as canvases in support channels reduces context-switching.

If your real requirement is "make docs easier to find in Slack," migration may be the wrong answer. Mintlify has a Slack integration that can surface answers from docs inside Slack and help draft updates from Slack conversations. In many teams, the better pattern is hybrid: keep canonical public docs in Mintlify, create internal canvases for onboarding and runbooks, and link back to the docs when detail matters.

## What Makes It Hard: MDX Is Not Markdown

**MDX compiles JSX components into JavaScript at build time, and Slack Canvas only accepts a subset of standard Markdown.** The prose survives unchanged, but every Mintlify component — every `<Card>`, `<Tabs>`, `<Accordion>`, `<CodeGroup>`, `<Note>`, `<ParamField>` — needs an explicit downgrade decision.

### What Slack Canvas Supports

The `document_content` markdown object supports: **headings (h1–h3), bold, italic, strikethrough, bulleted lists, ordered lists, checklists, code blocks, code spans, blockquotes, callouts, dividers, column layout, inline links, markdown tables, emojis, file unfurls, website unfurls, and @mentions for users and channels.**

Key constraints:

- Each canvas is limited to **1 MiB** (1,048,576 characters) of markdown per `document_content` object.
- Canvas tables are capped at **300 cells** (rows × columns).
- **Block Kit is not supported inside canvases.** You cannot migrate interactive buttons, forms, or dynamic tabs.
- No headings beyond h3. No collapsible sections. No tabbed content. No embedded API playgrounds.

This is the core tension. Mintlify's component library is designed for rich, interactive developer documentation. Slack Canvas is designed for internal team documents. Every component without a Canvas equivalent needs a conscious decision: flatten, drop, or restructure.

## Component-by-Component Downgrade Map

This table covers the main Mintlify components and what they become in Canvas. The right conversion depends on how the component is used and whether the information it carries is still valuable in a flat format.

| Mintlify Component | Canvas Equivalent | Conversion Strategy |
|---|---|---|
| `<Note>`, `<Warning>`, `<Info>`, `<Tip>` | Callout | Canvas supports callouts natively. Map directly — one of the few 1:1 translations. |
| `<Tabs>` | Sequential H3 sections | Flatten each tab into its own headed section. Label each with the tab name. Tabbed switching is lost. |
| `<Accordion>` / `<AccordionGroup>` | Heading + content (always expanded) | No collapsible sections in Canvas. Use an H3 for the accordion title and render the body below it. Optionally wrap in a blockquote for visual distinction. |
| `<Card>` / `<CardGroup>` | Bulleted list with bold titles, or column layout | Cards with `href` become bold-linked list items. Two-card groups can use Canvas column layout. More than three cards should downgrade to a bulleted list to prevent rendering issues. Icons are dropped. |
| `<CodeGroup>` | Separate code blocks with language labels | Render each code block sequentially, prefixed with a bold label (e.g., **JavaScript**, **Python**). |
| `<Steps>` | Ordered list | Map directly. If steps contain complex nested content, flatten into sub-bullets. |
| `<ParamField>` / `<ResponseField>` | Table row or bold definition | Render as a table if there are many fields, or as **`paramName`** *(type)* — Description for inline reference. Stay under the 300-cell table limit. |
| `<Columns>` | Column layout | Canvas supports column layout. One of the better mappings, though nested components inside columns still need flattening. |
| `<Frame>` / images | File unfurl (after upload) | Images must be uploaded to Slack, then referenced as file unfurls. |
| `<Expandable>` | H4 heading + content (always expanded) | No collapsible equivalent in Canvas. Render the trigger label as a bold heading and the content below it. Loss of collapse behavior is significant for long reference pages — consider whether the content should be summarized or split into a separate canvas. |
| `<ResponseExample>` / `<RequestExample>` | Fenced code block with language label | Strip the wrapper component and render the inner code block directly. Preserve the language identifier (e.g., ` ```json`). |
| `<Update>` | Blockquote with bold date prefix | Render as `> **YYYY-MM-DD** — update text`. If multiple updates appear in sequence, separate with a divider. |
| `<Tooltip>` | Inline parenthetical | Render as `term (tooltip text)`. The hover interaction is lost; the definition becomes inline. |
| `<Icon>` | Drop or replace with emoji | Canvas has no SVG icon rendering. Replace common icons with a semantically equivalent emoji (⚠️, ✅, ℹ️) or drop entirely. |
| Mermaid diagrams | Pre-rendered PNG or dropped | Canvas cannot render Mermaid. Render to PNG before migration and upload as an image, or drop the diagram. |

> **Every downgrade is lossy.** There is no conversion that preserves the interactive behavior of Mintlify components in Canvas. The goal is to preserve the *information* each component carries, not its presentation. Review each component type with the doc owner before automating — some content may be better summarized or restructured than mechanically flattened.

Here is a concrete example. This Mintlify source:

```mdx
<Tabs>
  <Tab title='Node'>
    ```js
    client.users.create(...)
    ```
  </Tab>
  <Tab title='Python'>
    ```py
    client.users.create(...)
    ```
  </Tab>
</Tabs>

<ParamField query='limit' type='integer' required>
  Max number of results.
</ParamField>
```

Becomes this Canvas markdown:

```markdown
### Node

```js
client.users.create(...)
```

### Python

```py
client.users.create(...)
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | Yes | Max number of results. |
```

A generic Markdown converter can strip tags, but it cannot decide whether tabs should become headings, duplicate canvases, or separate channel pages. That decision is information architecture, not syntax.

## How to Handle API Reference Pages Generated from OpenAPI

Mintlify auto-generates interactive API reference pages from OpenAPI (3.0 and 3.1) specification files at render time. You configure this by adding an `openapi` property to a navigation element in `docs.json`, and Mintlify generates endpoint pages with parameter descriptions, response schemas, type annotations, and a live API playground.

These pages have **no corresponding MDX files on disk** when using the auto-generation approach. The content is computed from the spec at build time. You cannot clone the repo and find a `get-users.mdx` file — it does not exist.

This makes OpenAPI-generated pages effectively impossible to migrate to Canvas in any useful form:

- **Structured endpoint data does not flatten well.** A single endpoint page includes the HTTP method, path, parameter table with types and constraints, request body schema, response schemas for multiple status codes, and code examples in multiple languages. Flattening this into Canvas markdown produces a wall of text harder to use than the original spec.
- **The API playground is interactive.** Developers test endpoints directly in the docs. Canvas has no equivalent.
- **The spec changes.** API reference pages stay in sync with the OpenAPI spec because Mintlify regenerates them on every build. A Canvas snapshot is frozen the moment you create it.

**Our recommendation:** Do not migrate API reference pages to Canvas. If internal teams need API reference access, link to the live Mintlify docs from a Canvas, or host the OpenAPI spec in a shared location (Git repo, Confluence, Backstage catalog) where it can be consumed by tools designed for it.

If you need a static fallback, tools like `widdershins` or `openapi-to-md` can convert an OpenAPI spec into flat Markdown. Be warned: large API references will exceed Slack's 1 MiB Canvas size limit, forcing you to split endpoints across dozens of separate canvases.

If you have used Mintlify's scraper CLI (`npx @mintlify/scraping@latest openapi-file`) to generate MDX files from your spec, those files do exist on disk and can be converted. But they contain `<ParamField>` and `<ResponseField>` components that still need the downgrade treatment — and the result will be a static snapshot of something designed to be dynamic.

## Resolving Reusable Snippets Before Conversion

**A Mintlify snippet is an MDX file in the `/snippets/` directory that is imported into pages using JSX import syntax and resolved at build time.** Snippets do not render as standalone pages — they only appear where they are imported.

A typical snippet import looks like this:

```mdx
import Prerequisites from '/snippets/prerequisites.mdx';

<Prerequisites />
```

Your migration script must resolve these imports before converting to Canvas markdown. If you skip this step, the Canvas will contain raw JSX import statements and unresolved component tags — which render as literal text in Slack, not as content.

The resolution steps:

1. **Parse each MDX file for import statements** that reference paths under `/snippets/` or any relative snippet path.
2. **Read the snippet file** and substitute it inline where the component tag appears.
3. **Handle parameterized snippets.** Snippets can accept props — `<MySnippet word="deploy" />` — which fill in template variables. Your resolver needs to substitute these values.
4. **Handle nested snippets.** Snippets can import other snippets. Resolve recursively until no imports remain.
5. **Handle exported variables.** Snippets can export constants (`export const sdkVersion = '3.2.0'`) that are referenced inline in the importing page. Replace `{sdkVersion}` with the literal value.

For production-grade processing, use a toolchain like `unified` with `remark-mdx` to parse MDX files into an Abstract Syntax Tree (AST). Walk the AST, locate snippet nodes, read the referenced files, parse them into their own ASTs, and replace the import/component nodes with the inlined content. Regex-based approaches break on nested components and props.

## Moving Images from the Repo to Slack

Mintlify docs typically store images in an `images/` directory in the repo, referenced with standard Markdown image syntax (`! [alt text](https://clonepartner.com/blog/images/screenshot.png)`) or HTML `<img>` tags and `<Frame>` components.

Slack Canvas does not support direct image URLs in markdown. Images appear as **file unfurls** after being uploaded to Slack. A file unfurl in Canvas markdown looks like this:

```markdown
Before upload:
![Auth flow diagram](https://clonepartner.com/blog/images/auth-flow.png)

After upload and replacement:
<@U12345678> uploaded: https://files.slack.com/files-pri/T.../auth-flow.png
```

In practice, the Canvas API renders uploaded Slack files inline when you reference them using a `files:` unfurl block in the `document_content` payload. The exact syntax depends on the canvas `document_content` format — Slack renders the file as an inline image when the file is shared to the same channel or workspace as the canvas.

The upload requires a specific multi-step API sequence:

1. **Request an upload URL** by calling `files.getUploadURLExternal` with the file's name and byte size. Slack returns an `upload_url` and a `file_id`.
2. **Upload the bytes** by performing an HTTP POST to the provided `upload_url` with the raw binary data as the request body (`Content-Type: application/octet-stream`).
3. **Complete the upload** by calling `files.completeUploadExternal` with the `file_id`. Slack returns a permanent `permalink` URL and a `url_private` URL.
4. **Replace the original Markdown image reference** in the converted canvas content with a Slack file reference. Store the mapping of original path → Slack `file_id` in a dict for re-runs.
5. **Handle external images.** If the MDX references images hosted on a CDN rather than in the repo, download them first, then upload to Slack.

> **Do not build new upload logic on the deprecated `files.upload` method.** Slack pushed its retirement to November 12, 2025. The supported path is `files.getUploadURLExternal` plus `files.completeUploadExternal`.

Uploaded files count against workspace storage limits, which vary by Slack plan. Ensure your bot token has `files:write` scope and that the resulting canvases are shared with the appropriate channels so users can see the embedded images.

## Creating Canvases: API Payload Structure

Two methods create canvases. The payload structure differs by type.

**Standalone canvas** (`canvases.create`):

```json
POST https://slack.com/api/canvases.create
Authorization: Bearer xoxb-your-bot-token
Content-Type: application/json

{
  "title": "Authentication Guide",
  "document_content": {
    "type": "markdown",
    "markdown": "# Authentication Guide\n\nThis guide covers...\n\n## Prerequisites\n\n..."
  }
}
```

Response includes a `canvas_id` (e.g., `"F0XXXXXXXXX"`) that you store for subsequent access control and index linking.

**Channel canvas** (`conversations.canvases.create`):

```json
POST https://slack.com/api/conversations.canvases.create
Authorization: Bearer xoxb-your-bot-token
Content-Type: application/json

{
  "channel_id": "C0XXXXXXXXX",
  "document_content": {
    "type": "markdown",
    "markdown": "# Guides Index\n\n- [Authentication Guide](https://app.slack.com/canvas/F0XXXXXXXXX)\n- [Installation](https://app.slack.com/canvas/F0YYYYYYYYY)\n"
  }
}
```

Only one channel canvas can exist per channel. Attempting to create a second will return an error. To update a channel canvas, use `canvases.edit`.

**Editing an existing canvas** (`canvases.edit`) for incremental updates:

```json
POST https://slack.com/api/canvases.edit
Authorization: Bearer xoxb-your-bot-token
Content-Type: application/json

{
  "canvas_id": "F0XXXXXXXXX",
  "changes": [
    {
      "operation": "replace",
      "document_content": {
        "type": "markdown",
        "markdown": "# Authentication Guide\n\n(Updated content here)"
      }
    }
  ]
}
```

The `replace` operation in `canvases.edit` replaces the entire canvas content. For section-level edits, use `canvases.sections.lookup` to find a section ID, then apply targeted operations. For most migration re-runs, full `replace` is simpler and sufficient.

## Mapping Navigation to Channels and Index Canvases

Mintlify's navigation hierarchy follows a **tabs → groups → pages** structure. Slack has no native equivalent to a doc site's table of contents. The most common mapping:

| Mintlify Level | Slack Equivalent |
|---|---|
| Tab (e.g., "Guides", "API Reference") | Slack channel (e.g., `#docs-guides`) |
| Group (e.g., "Getting Started") | A heading section within a channel canvas, or a standalone canvas titled with the group name |
| Page | Individual standalone canvas linked from the index |

For each channel, create a **channel canvas** (using `conversations.canvases.create`) that serves as an index — a table of contents with links to the standalone canvases that hold individual page content. As your script creates individual canvases, capture the returned `canvas_id` and update the index with links to each one. Pin the index canvas to the top of the channel.

Standalone canvases are created with `canvases.create`. Share permissions are set via `canvases.access.set`, which accepts up to **20 user IDs or 20 channel IDs per call**. Channel canvases are simpler because access follows channel membership. To create a channel canvas in a private channel, the app or user must already be invited.

The main limitation: **discoverability degrades fast.** A doc site has search, a sidebar, breadcrumbs, and prev/next links. A set of Slack canvases has none of that. For more than 30–40 pages, the index canvas becomes unwieldy and Slack's general search becomes the only realistic way to find content.

If your Mintlify docs use versions or languages, decide early whether Slack gets every version, only the latest stable docs, or an internal operator view that collapses versions entirely. Mirroring a public multi-version docs site inside Slack usually creates noise.

## Rate Limits and API Constraints on the Slack Side

The [Slack Canvas API](https://clonepartner.com/blog/blog/how-to-import-data-into-slack-canvas-api-limits-guide) has different rate tiers depending on the method. Slack publishes floor figures — actual limits are higher but undisclosed, so these numbers represent guaranteed minimums, not the real ceiling:

| Method | Rate Tier | Floor (requests/min) | Notes |
|---|---|---|---|
| `canvases.create` | Tier 2 | 20+ | Creates standalone canvases |
| `conversations.canvases.create` | Tier 2 | 20+ | Creates channel canvases (one per channel) |
| `canvases.edit` | Tier 3 | 50+ | Edits existing canvases |
| `canvases.access.set` | Tier 3 | 50+ | Sets canvas permissions |
| `canvases.sections.lookup` | Tier 3 | 50+ | Finds section IDs for targeted edits |
| `canvases.delete` | Tier 2 | 20+ | Deletes canvases; use during cleanup/re-runs |
| `files.getUploadURLExternal` | Tier 4 | 100+ | Request upload URL for images |
| `files.completeUploadExternal` | Tier 3 | 50+ | Finalize image upload |

For a typical Mintlify site with 50–150 pages, the Tier 2 creation rate floor of 20+ per minute means you can create all canvases in under 10 minutes without throttling. Larger doc sets (500+ pages) require proper throttling with exponential backoff. Parse the `Retry-After` header from HTTP 429 responses, pause execution for the requested number of seconds, and retry with jitter to avoid synchronized retry storms.

Each canvas is capped at **1 MiB of markdown content**. Most documentation pages fall under this limit, but long-form reference pages or pages with many inline code examples can approach it. If a page exceeds 1 MiB after snippet resolution and component flattening, split it into multiple canvases with a clear naming convention (e.g., "Authentication Guide (1 of 2)").

> **Plan limits matter.** Channel and DM canvases are available on all Slack plans, but standalone canvases require a paid plan. The Web API also blocks free-tier workspaces from creating standalone canvases programmatically. Verify your workspace plan before building a migration pipeline.

## Handling Re-Runs: Upsert, Not Recreate

A migration pipeline that only creates canvases will produce duplicates on every re-run. Production migrations require an upsert strategy.

The approach:

1. **Persist a source-to-target ID map** in a local file or database. Key: the MDX file path (e.g., `guides/auth.mdx`). Value: the Slack `canvas_id` returned by `canvases.create`.

```json
{
  "guides/auth.mdx": "F0XXXXXXXXX",
  "guides/installation.mdx": "F0YYYYYYYYY"
}
```

2. **On each run**, check whether a `canvas_id` exists for the source page. If yes, call `canvases.edit` with `operation: "replace"` to overwrite the content. If no, call `canvases.create` and store the returned ID.

3. **Detect deleted source pages** by comparing the current navigation tree against the ID map. If a key exists in the map but no longer appears in `docs.json`, call `canvases.delete` to clean up the orphaned canvas.

4. **Use `canvases.getContent`** to retrieve the current canvas markdown before overwriting. Diff the current content against the newly converted MDX to skip unchanged pages — this reduces API calls and avoids unnecessary edit timestamps on pages users may have bookmarked.

```python
def upsert_canvas(canvas_id_map, page_path, title, markdown, slack_client):
    """Create or update a canvas based on persisted ID map."""
    existing_id = canvas_id_map.get(page_path)
    
    if existing_id:
        # Check if content actually changed before editing
        current = slack_client.canvases_get_content(canvas_id=existing_id)
        if current['content'] == markdown:
            return existing_id  # No change, skip API call
        slack_client.canvases_edit(
            canvas_id=existing_id,
            changes=[{"operation": "replace", "document_content": {"type": "markdown", "markdown": markdown}}]
        )
        return existing_id
    else:
        result = slack_client.canvases_create(
            title=title,
            document_content={"type": "markdown", "markdown": markdown}
        )
        new_id = result['canvas_id']
        canvas_id_map[page_path] = new_id
        return new_id
```

## The Migration Pipeline

A complete Mintlify-to-Canvas migration follows this sequence:

1. **Clone the Mintlify repo.** Resolve all `$ref` entries in `docs.json` (or `mint.json`) into a single navigation tree. Detect which config schema is present and parse accordingly.
2. **Separate guides from generated reference.** Pages with an `openapi` key in the navigation tab are auto-generated — do not send these through the conversion path. Link to live docs instead.
3. **Extract and strip frontmatter** from each MDX file. Store `title`, `sidebarTitle`, and `description` for use in canvas creation.
4. **Resolve all snippets** by inlining imported snippet content into each page's MDX source, recursively. Substitute parameterized snippet props and exported variables.
5. **Strip MDX components** from each page, applying the downgrade map. Use an AST-level MDX parser (`unified` with `remark-mdx`) — not regex — for reliability with nested components and props.
6. **Extract and upload images** to Slack via `files.getUploadURLExternal` / `files.completeUploadExternal`. Build a lookup map of old paths to Slack `file_id` values.
7. **Replace image references** in the converted markdown with Slack file references.
8. **Create Slack channels** for each top-level navigation tab if they do not already exist.
9. **Upsert standalone canvases** for each page using the ID map: create new canvases or update existing ones. Skip unchanged pages.
10. **Create or update channel canvases** (index pages) for each channel, listing links to all page canvases in that section.
11. **Set access permissions** on each new canvas using `canvases.access.set` (max 20 IDs per call).
12. **Persist the updated ID map** to disk for future re-runs.
13. **QA with humans, not just parsers.** Compare side by side for missing callouts, broken code blocks, flattened tabs, over-wide tables, and access mistakes.

Here is the AST-based component conversion using `unified` and `remark-mdx`, which handles nested components correctly:

```javascript
// AST-based MDX conversion (Node.js)
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import remarkStringify from 'remark-stringify';
import { visit } from 'unist-util-visit';

function convertMdxComponents() {
  return (tree) => {
    visit(tree, 'mdxJsxFlowElement', (node, index, parent) => {
      const name = node.name;

      if (['Note', 'Info', 'Tip', 'Warning'].includes(name)) {
        // Convert callout components to blockquotes
        const emoji = { Note: 'ℹ️', Info: 'ℹ️', Tip: '💡', Warning: '⚠️' };
        parent.children.splice(index, 1, {
          type: 'blockquote',
          children: [{ type: 'paragraph', children: [
            { type: 'text', value: `${emoji[name]} ` },
            ...node.children.flatMap(c => c.children || [])
          ]}]
        });
      }

      if (name === 'Tabs') {
        // Convert each Tab to an H3 section
        const sections = node.children
          .filter(c => c.name === 'Tab')
          .flatMap(tab => {
            const tabTitle = tab.attributes.find(a => a.name === 'title')?.value || 'Tab';
            return [
              { type: 'heading', depth: 3, children: [{ type: 'text', value: tabTitle }] },
              ...tab.children
            ];
          });
        parent.children.splice(index, 1, ...sections);
      }
      // Additional component handlers follow the same pattern
    });
  };
}

async function convertMdxToCanvas(mdxContent) {
  const result = await unified()
    .use(remarkParse)
    .use(remarkMdx)
    .use(convertMdxComponents)
    .use(remarkStringify)
    .process(mdxContent);
  return String(result);
}
```

This AST approach handles nested components, props extraction, and recursive structure in ways that regex cannot. The `visit` function from `unist-util-visit` traverses every node type, and mutations to `parent.children` are applied in-place.

## Making It Work

Mintlify to Slack Canvas is a downgrade migration by design. You are moving from a purpose-built developer documentation platform to an internal collaboration surface. The information survives; the presentation, interactivity, and discoverability do not.

The filesystem source makes extraction straightforward. The MDX-to-Canvas conversion is where every hour gets spent — parsing frontmatter, resolving snippets, making downgrade decisions for each component type, and handling the edge cases that regex cannot catch reliably. API reference pages generated from OpenAPI specs should be linked, not migrated. The navigation structure needs rethinking for a platform with no concept of a doc tree. Re-runs require an upsert strategy backed by a persisted ID map, not blind recreation.

The success criterion is not "all text moved" — it is "operators can still find, trust, and maintain the result." For most public developer doc sites, that criterion points back to keeping canonical reference in Mintlify and using Canvas only for the internal, team-facing layer.

For migration planning across any knowledge base platform, our [knowledge base migration checklist](https://clonepartner.com/blog/blog/the-ultimate-knowledge-base-migration-checklist-a-zero-downtime-plan) covers the zero-downtime planning process regardless of source or target.

> ClonePartner builds custom migration scripts for doc platforms with non-standard export paths. We handle the MDX parsing, component downgrading, image uploads, and Canvas creation — so you get clean internal docs without the engineering detour. [Book a 30-minute call](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click) to scope your migration.

## Frequently asked questions

### Does Mintlify have an API to export documentation content?

Mintlify has a REST API with a per-page content retrieval endpoint, but no bulk export. The source of truth is the Git repository. To migrate, clone the repo and read the MDX files, docs.json config, snippets, and assets directly from the filesystem.

### What formatting does Slack Canvas support?

Slack Canvas supports headings h1–h3, bold, italic, strikethrough, bulleted and ordered lists, checklists, code blocks, code spans, blockquotes, callouts, dividers, column layout, inline links, markdown tables, emojis, file unfurls, and @mentions. Block Kit is not supported inside canvases. Each canvas is limited to 1 MiB of markdown and 300 table cells.

### Can you migrate Mintlify API reference pages to Slack Canvas?

Not in any useful form. Mintlify auto-generates API reference pages from OpenAPI specs at render time — these pages have no MDX files on disk. The structured endpoint data, interactive playground, and dynamic sync with the spec do not translate to static Canvas markdown. Link to the live docs instead.

### How do Mintlify reusable snippets work in a migration?

Snippets are MDX files in a /snippets/ directory imported via JSX import statements and resolved at build time. Your migration script must inline snippet content — including parameterized values, nested snippets, and exported variables — before converting to Canvas markdown. Otherwise the Canvas will contain raw unresolved component tags.

### What are the Slack Canvas API rate limits?

canvases.create and conversations.canvases.create are Tier 2 (20+ requests per minute). canvases.edit and canvases.access.set are Tier 3 (50+ per minute). For a typical 50–150 page Mintlify site, creation completes in under 10 minutes. Larger sites need throttling with Retry-After header parsing.
