Skip to content

Extensions

import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'


export const appMarkdown = defineMarkdownPreset({ extensions: [gfm()] })

A preset describes what Markdown means in your application. If you pass the same preset to the renderer, the editor and the variables plugin, all three parse the same dialect. To add a feature you add an extension to that list, instead of adding a prop to three different components.

The contract#

An extension is a plain object. There are no classes involved and nothing gets registered globally.

interface MarkdownExtension {
  readonly name: string
  readonly version?: string
  readonly contractVersion?: 1
  readonly capabilities?: {
    readonly syntax?: SyntaxAdapter
    readonly renderer?: RendererAdapter
    readonly editor?: EditorAdapter
    readonly variables?: VariablesAdapter
  }
}

name identifies the extension, and merging, replacement and diagnostics all key on it. contractVersion is the version of the adapter shapes (currently 1).

An extension only declares the capabilities it has, and each package only reads the ones it understands.

CapabilityFieldsWho reads it
syntaxmicromarkExtensions, fromMarkdownExtensions, toMarkdownExtensions, nodeTypes, transformRenderer, editor and variables plugin
rendererhandlers, components, rehypePluginsRenderer
editornodes, plugins, commandsEditor
variablesliteralNodeTypes, formatters, transformVariables plugin

The renderer ignores editor adapters. The variables plugin uses the syntax and variables adapters and never imports editor code, which means a headless Node service can load an editor-aware extension without installing the editor.

How gfm() is built#

The built-in dialect goes through the same mechanism a third-party extension would. It doesn't get any special treatment.

export function gfm(options: GfmOptions = {}): MarkdownExtension {
  return {
    name: 'gfm',
    version: '1',
    contractVersion: 1,
    capabilities: {
      syntax: {
        micromarkExtensions: [micromarkGfm({ singleTilde: options.singleTilde ?? true })],
        fromMarkdownExtensions: [gfmFromMarkdown()],
        toMarkdownExtensions: [gfmToMarkdown()],
        nodeTypes: ['table', 'tableRow', 'tableCell', 'delete', 'footnoteDefinition', 'footnoteReference'],
      },
      variables: { literalNodeTypes: [] },
    },
  }
}

The three syntax fields handle parsing, building the tree and serializing. With all three declared, the dialect round-trips, so text goes in, becomes a tree, and comes back out unchanged.

gfm() ships once, from @react-markdown-kit/renderer. The variables plugin and the editor read the same preset, so that one definition covers the dialect everywhere.

The same extension, rendering
Markdown
| Metric | Before | After |
| --- | ---: | ---: |
| Cold render | 12.4 ms | 9.1 ms |

- [x] Ship tables
- [ ] Ship footnotes

~~Deprecated~~ replaced.
Rendered
MetricBeforeAfter
Cold render12.4 ms9.1 ms
  • Ship tables
  • Ship footnotes

Deprecated replaced.

Writing one#

A renderer-only extension#

The smallest useful extension just contributes components.

import type { MarkdownExtension } from '@react-markdown-kit/renderer'
import { ExternalLink } from './external-link'


export function externalLinks(): MarkdownExtension {
  return {
    name: 'external-links',
    version: '1',
    contractVersion: 1,
    capabilities: {
      renderer: { components: { a: ExternalLink } },
    },
  }
}

A variables-only extension#

Formatters can ship inside an extension. That way a team can define its house date format once and every document picks it up.

import type { MarkdownExtension } from '@react-markdown-kit/variables'


export function houseFormats(): MarkdownExtension {
  return {
    name: 'house-formats',
    version: '1',
    contractVersion: 1,
    capabilities: {
      variables: {
        formatters: {
          shortDate: (value, { locale, timeZone }) =>
            new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeZone }).format(
              new Date(value as string),
            ),
        },
      },
    },
  }
}

literalNodeTypes is the other half of the variables adapter. If you list a node type there, the engine won't scan its text for placeholders (this is how code blocks stay literal).

A multi-capability extension#

variables({ data }) from @react-markdown-kit/variables is a syntax.transform that reads its siblings. Every other extension's variables.literalNodeTypes and variables.formatters apply, which is how mermaid() keeps placeholders in a payload literal without either package knowing about the other.

variableChips() from the same package declares four capabilities at once. The syntax adapter lifts {{...}} runs into a variable node and serializes them back byte-for-byte. The variables adapter marks that node literal so a chip never reaches the interpolator, and the renderer adapter shows an unresolved placeholder as a chip. The editor adapter is added by @react-markdown-kit/variables/editor under the same name and supplies the Lexical chip, the {{ trigger and the insert command.

If a renderer loads the editor's version, it draws the document and ignores the editor half. You don't need to configure anything for that.

A shipped example: mermaid()#

@react-markdown-kit/mermaid is built only on this contract. Its syntax adapter lifts a ```mermaid fence into a diagram node and serializes it back. Its renderer adapter draws that node as a static SVG, its variables adapter marks the payload literal, and @react-markdown-kit/mermaid/editor adds an editor adapter with a Lexical node, a canvas and a toolbar button. The root entry doesn't load React or Lexical, so the same extension can go into a Node service's preset unchanged. The package doesn't export anything besides the plugin.

import { mermaid } from '@react-markdown-kit/mermaid'


export const appMarkdown = defineMarkdownPreset({ extensions: [gfm(), mermaid()] })

A shipped example: slides()#

@react-markdown-kit/slides is a different kind of extension. It doesn't add a fence or any inline syntax, and it never introduces a container node. A deck is plain CommonMark with slides separated by ---. Its syntax.transform runs on the flat tree after parsing and re-types a ??? or -- paragraph to a slideMarker and a <!-- key: value --> comment to a slideDirective. Its renderer adapter contributes a handler for the mdast root, which groups the flat children into one <article data-rmk-deck> of <section>s. Every block still renders through the renderer's own handlers, so headings, lists and a GFM table inside a slide come out as the same elements they would outside one. The tree stays flat, the file still reads as a normal document on GitHub, and documentToMarkdown writes every construct back the way it was spelled. @react-markdown-kit/slides/present replaces the article component with a client one, and /editor adds the editor adapter, all under the same name.

import { slides } from '@react-markdown-kit/slides'


export const deckMarkdown = defineMarkdownPreset({ extensions: [gfm(), slides()] })

An extension that needs its own node in the editor uses @react-markdown-kit/editor/lexical: block adapters and inline adapters (mdast node in, editor node out and back), plugins that register commands, toolbar contributions, and useLexicalEditor() for a decorator to reach the engine.

Composition rules#

const base = defineMarkdownPreset({ extensions: [gfm()] })


const docs = defineMarkdownPreset({
  extends: [base],
  extensions: [externalLinks()],
})
  • Preset extensions come first, then extensions passed to the component.
  • An extension whose name matches an existing one replaces it in place, keeping the original position, so ordering stays stable.
  • mergeExtensions(base, next) applies the same rule if you need it yourself.
  • The profile recorded on a compiled document is gfm when a gfm extension is present, otherwise commonmark.
<Markdown preset={appMarkdown} extensions={[gfm({ singleTilde: true })]}>
  {source}
</Markdown>

That call replaces the preset's gfm instead of adding a second one.

remark and rehype#

The existing remark and rehype ecosystem is still supported. These props are mostly there for compatibility, but they're fully supported and fine to rely on.

<Markdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
  {source}
</Markdown>

remarkRehypeOptions reaches remark-rehype, including footnote labels and clobber prefixes. Plugin behaviour matches react-markdown on every case in the compatibility matrix.

There are two things worth knowing here.

A remark plugin that changes the dialect, such as remark-gfm, registers parser extensions. Parsing happens before the render pipeline exists, so those registrations get collected and handed to the parser. That works fine for a string, but it can't work on an already-compiled document, since the text was parsed before the plugin was around. Compile with the same extensions you render with, or pass the string.

Plugins are treated as trusted application code and aren't sandboxed. The content policy still runs after them, so it filters what they produce, but the kit doesn't audit what a plugin does.

I'd use a kit extension when you want the feature to work across the renderer, the editor and variables. remark or rehype makes more sense when the feature already exists there and only rendering needs it.

Versioning#

contractVersion is 1. It only gets bumped when the adapter shapes change incompatibly, so a host can detect an extension it can't load instead of failing at render time. Third-party authoring isn't frozen for v1 yet, so the adapter internals should be fairly stable but are still young. Pin your versions.

Last updated on