Render Comark in React

Learn how to render Comark in a React application with custom components, plugins, and Next.js support.

The @comark/react package provides React components for rendering Comark content with full support for custom components, plugins, and streaming.

Installation

pnpm add @comark/react

<Markdown>

The <Markdown> component is the simplest way to render markdown in React. It handles parsing and rendering automatically.

<Markdown> is an async component. You can also use the <MarkdownDocument> component to handle parsing yourself.
App.tsx
import { Markdown } from '@comark/react'

const content = `# Hello World

This is **markdown** with Comark components.
`

export default function App() {
  return <Markdown>{content}</Markdown>
}

Usage

Pass markdown content via children or the value prop. value accepts a markdown string or a pre-parsed MarkdownDocument:

<Markdown>{content}</Markdown>
Passing a document to <Markdown> skips parsing at runtime, but the parser is still bundled because Markdown imports it. To keep the client bundle free of the parser, use <MarkdownDocument> instead.

Props

PropTypeDefaultDescription
childrenReact.ReactNode-Markdown content to parse and render
valuestring | MarkdownDocument''Markdown string or pre-parsed document (alternative to children)
optionsParserOptions{}Parser options (autoUnwrap, autoClose, etc.)
pluginsComarkPlugin[][]Array of plugins
unwrapboolean | string | string[]falseStrip wrapper tags (MDC unwrap) — true unwraps <p>; a comma/space-separated string or array peels tags sequentially, e.g. "ul li"
componentsRecord<string, ComponentType>{}Custom React component mappings
componentsManifest(name: string) => Promise<Component>undefinedDynamic component resolver
streamingbooleanfalseEnable streaming mode
caretboolean | { class: string }falseAppend caret to last text node
dataRecord<string, unknown>undefinedRuntime values referenced from markdown via :prop="data.path"
classNamestringundefinedCSS class for wrapper element

options

See ParserOptions for available options.

App.tsx
<Markdown options={{ autoUnwrap: true, autoClose: true }}>
  {content}
</Markdown>

plugins

See ComarkPlugin for available plugins.

App.tsx
import { Markdown } from '@comark/react'
import shiki from '@comark/react/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'

const plugins = [
  shiki({
    themes: { light: githubLight, dark: githubDark }
  })
]

export default function App() {
  return <Markdown plugins={plugins}>{content}</Markdown>
}

For math and mermaid plugins, also pass the companion components:

App.tsx
import { Markdown } from '@comark/react'
import math, { Math } from '@comark/react/plugins/math'
import mermaid, { Mermaid } from '@comark/react/plugins/mermaid'
import 'katex/dist/katex.min.css'

export default function App() {
  return (
    <Markdown
      value={markdown}
      components={{ Math, Mermaid }}
      plugins={[math(), mermaid()]}
    />
  )
}

components

Use this prop to map custom React components to Comark elements and use them in your markdown.

Create a component

components/Alert.tsx
interface AlertProps {
  type?: 'info' | 'warning' | 'error' | 'success'
  children: React.ReactNode
}

export default function Alert({ type = 'info', children }: AlertProps) {
  return (
    <div className={`alert alert-${type}`} role="alert">
      {children}
    </div>
  )
}

Map the tag to your component

App.tsx
import { Markdown } from '@comark/react'
import Alert from './components/Alert'
import Card from './components/Card'

const components = { alert: Alert, card: Card }

export default function App() {
  return <Markdown components={components}>{content}</Markdown>
}

Use it in your Markdown

::alert{type="warning"}
This is a warning message!
::
See Component Bindings for how props and slots map to your React component, or Component Syntax for the full Comark syntax API: nested components, inline syntax, and more.

componentsManifest

For lazy-loading components on demand. Components are resolved via React.lazy() and wrapped in <Suspense> automatically. Works with both <Markdown> and <MarkdownDocument>:

App.tsx
import { Markdown } from '@comark/react'

const manifest = (name: string) => {
  return import(`./components/prose/${name}.tsx`)
}

export default function App() {
  return <Markdown componentsManifest={manifest}>{content}</Markdown>
}

data

Expose runtime values to markdown authors. Any prop written with a : prefix is resolved against the render context { frontmatter, meta, data, props } when its value isn't valid JSON. See Data Binding for the full scope.

App.tsx
import { Markdown } from '@comark/react'

const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`

export default function App() {
  return <Markdown value={content} data={{ user }} />
}

defineMarkdownComponent

Creates a pre-configured <Markdown> component with default options, plugins, and components baked in.

Usage

Expose your configured component

markdown.ts
import { defineMarkdownComponent } from '@comark/react'
import shiki from '@comark/react/plugins/shiki'
import math, { Math } from '@comark/react/plugins/math'
import mermaid, { Mermaid } from '@comark/react/plugins/mermaid'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import CustomAlert from './components/CustomAlert'

export const AppMarkdown = defineMarkdownComponent({
  name: 'AppMarkdown',

  plugins: [
    math(),
    mermaid(),
    shiki({
      themes: {
        light: githubLight,
        dark: githubDark
      },
    }),
  ],

  components: {
    Math,
    Mermaid,
    alert: CustomAlert,
  },
})

Use it in your templates

App.tsx
import { AppMarkdown } from './markdown'

export default function App() {
  return (
    <>
      {/* All configuration is already included */}
      <AppMarkdown>{content}</AppMarkdown>

      {/* Can still override per-instance */}
      <AppMarkdown components={{ alert: DifferentAlert }}>
        {content}
      </AppMarkdown>
    </>
  )
}

Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineMarkdownComponent>undefinedInherit plugins and components from another component
namestringundefinedComponent name for debugging
autoUnwrapbooleantrueAutomatically unwrap single block elements
autoClosebooleantrueAuto-close incomplete markdown syntax
linkifybooleantrueAuto-convert URL-like text into links
registerDefaultPluginsbooleantrueRegister default plugins (frontmatter, html, alert, task-list, components, attributes)
pluginsComarkPlugin[][]Array of plugins
componentsRecord<string, ComponentType>{}Custom React component mappings
classNamestringundefinedAdditional CSS classes for the wrapper div

extends

Inherit plugins and components from another component, then layer your own on top:

markdown/index.ts
import { defineMarkdownComponent } from '@comark/react'
import shiki from '@comark/react/plugins/shiki'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import toc from '@comark/react/plugins/toc'
import math, { Math } from '@comark/react/plugins/math'
import CodeBlock from './components/CodeBlock'

// Base: highlight + shared prose overrides, used everywhere
const BaseMarkdown = defineMarkdownComponent({
  name: 'BaseMarkdown',
  plugins: [shiki({ themes: { light: githubLight, dark: githubDark } })],
  components: { pre: CodeBlock },
})

// Article: extends Base, adds TOC and math
export const ArticleMarkdown = defineMarkdownComponent({
  name: 'ArticleMarkdown',
  extends: BaseMarkdown,
  plugins: [toc({ depth: 3 }), math()],
  components: { Math },
})

// Comment: extends Base only, no TOC, no math
export const CommentMarkdown = defineMarkdownComponent({
  name: 'CommentMarkdown',
  extends: BaseMarkdown,
})

Merging behavior

  • plugins: Arrays are concatenated (config plugins + prop plugins)
  • components: Props override config (prop components take precedence)
  • Other options: Props override config

Usage with Next.js App Router

markdown.ts
'use client'

import { defineMarkdownComponent } from '@comark/react'
import math, { Math } from '@comark/react/plugins/math'

export const DocsMarkdown = defineMarkdownComponent({
  name: 'DocsMarkdown',
  plugins: [math()],
  components: { Math },
})
app/docs/[slug]/page.tsx
import { DocsMarkdown } from '@/components/markdown'

export default async function Page({ params }: { params: { slug: string } }) {
  const content = await getDocContent(params.slug)
  return <DocsMarkdown>{content}</DocsMarkdown>
}

<MarkdownDocument>

Renders a pre-parsed MarkdownDocument without any parsing. Use it when you parse on the server, in a build step, or via an API, so no parser or plugin code is shipped to the browser.

Parsing

Parse your markdown content and pass the document directly in a React Server Component:

app/docs/[slug]/page.tsx
import { createMarkdownParser } from 'comark'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { MarkdownDocument } from '@comark/react'
import Alert from '@/components/Alert'

const parse = createMarkdownParser()

export default async function DocsPage({ params }: { params: { slug: string } }) {
  const markdown = await readFile(join('content', `${params.slug}.md`), 'utf-8')
  const document = await parseMarkdown(markdown)

  return <MarkdownDocument value={document} components={{ alert: Alert }} />
}

Renderer Props

PropTypeDefaultDescription
valueMarkdownDocumentRequired. The parsed document returned by parseMarkdown()
componentsRecord<string, ComponentType>{}Custom React component mappings
componentsManifestComponentManifestundefinedDynamic component resolver for lazy-loaded components
streamingbooleanfalseEnable streaming mode
caretboolean | { class: string }falseAppend a blinking caret to the last text node
dataRecord<string, unknown>undefinedRuntime values referenced from markdown via :prop="data.path"
classNamestringundefinedCSS class for the wrapper <div>

defineMarkdownDocumentComponent

Creates a pre-configured <MarkdownDocument> with baked-in component mappings.

Setup

Expose your configured renderer

markdown/index.ts
import { defineMarkdownDocumentComponent } from '@comark/react'
import Alert from './components/Alert'
import CodeBlock from './components/CodeBlock'

export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'ArticleMarkdownDocument',
  components: {
    alert: Alert,
    pre: CodeBlock,
  },
})

Page integration

app/docs/[slug]/page.tsx
import { parseMarkdown } from 'comark'
import { ArticleMarkdownDocument } from '@/markdown'

export default async function Page({ params }: { params: { slug: string } }) {
  const markdown = await getContent(params.slug)
  const document = await parseMarkdown(markdown)

  return <ArticleMarkdownDocument value={document} />
}

Renderer Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineMarkdownDocumentComponent>undefinedInherit component mappings from another renderer
namestringundefinedComponent name for debugging
componentsRecord<string, ComponentType>{}Custom React component mappings
classNamestringundefinedAdditional CSS classes for the wrapper div

Inheritance

Inherit component mappings from another renderer, then layer your own on top:

markdown/index.ts
import { defineMarkdownDocumentComponent } from '@comark/react'
import CodeBlock from './components/CodeBlock'
import ProseA from './components/ProseA'
import Alert from './components/Alert'
import CommentAlert from './components/CommentAlert'

const BaseMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'BaseMarkdownDocument',
  components: { pre: CodeBlock, a: ProseA },
})

export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'ArticleMarkdownDocument',
  extends: BaseMarkdownDocument,
  components: { alert: Alert },
})

export const CommentMarkdownDocument = defineMarkdownDocumentComponent({
  name: 'CommentMarkdownDocument',
  extends: BaseMarkdownDocument,
  components: { alert: CommentAlert },
})

Live Documents

MarkdownDocument can subscribe to an ambient context so external sources can drive a mounted renderer: an HMR signal, a collaboration socket, an agent editing the document while you chat with it, or devtools. Pass a documentKey, and if globalThis.comarkContext exists the renderer listens for updates on that key and re-renders; on unmount it cleans up. The key falls back to the document's own meta.key when a plugin sets it. With no context, it's a no-op at zero cost.

<MarkdownDocument documentKey="page" value={document} />

A driver installs the context once and pushes updates by key with set() (replace the whole document) or patch() (surgical node edits, with structural sharing so only the changed branch re-renders):

import { createComarkContext, parseMarkdown } from 'comark'

const ctx = createComarkContext() // installs globalThis.comarkContext
const doc = ctx.get('page', await parseMarkdown('# Hello')) // seed on first access

doc.set(await parseMarkdown('# Replaced'))
doc.patch({ op: 'insert', path: [1], node: ['p', {}, 'inserted'] })

A path is a node-index path into document.nodes: the first segment indexes the top-level nodes, each later segment indexes into that element's children. Patch operations are replace, insert, remove (each takes a path), plus meta, frontmatter, and data merges. The same context API powers @comark/vue, @comark/svelte, and @comark/angular: websocket handlers, agents, devtools, and HMR all drive it the same way.


Component Bindings

Comark automatically bridges the gap between Comark syntax and your component's interface.

Prop Binding

Attributes in Comark syntax are passed as props to your component. Use the : prefix to pass typed values. HTML attribute names are automatically converted to their React equivalents:

MarkdownReact prop
{type="warning"}type="warning" (string)
{:count="5"}count={5} (number)
{:active="true"}active={true} (boolean)
{:config='{"key":"val"}'}config={{ key: 'val' }} (object)
{class="foo"}className="foo"
{tabindex="0"}tabIndex={0}
{style="color: red"}style={{ color: 'red' }}

Named Slots

Named slots in Comark (#slotname) map to slot{Name} props in React:

  • Default slotchildren
  • Named slotsslot{Name} (e.g., #footerslotFooter)
interface CardProps {
  title?: string
  children?: React.ReactNode
  slotFooter?: React.ReactNode
}

export default function Card({ title, children, slotFooter }: CardProps) {
  return (
    <div className="card">
      {title && <h3>{title}</h3>}
      <div className="card-body">{children}</div>
      {slotFooter && <div className="card-footer">{slotFooter}</div>}
    </div>
  )
}

Overriding HTML Elements

Override how native HTML elements render by mapping a component to their tag name via the components prop.

Create overridden version

components/Heading.tsx
interface HeadingProps {
  __node?: ElementNode
  id?: string
  children: React.ReactNode
}

export default function Heading({ __node, id, children }: HeadingProps) {
  const Tag = __node?.[0] || 'h2'
  return (
    <Tag id={id} className="heading">
      {id && <a href={`#${id}`} className="anchor">#</a>}
      {children}
    </Tag>
  )
}

Map

App.tsx
<Markdown components={{ h1: Heading, h2: Heading, h3: Heading }}>
  {content}
</Markdown>

Resolution Order

Components are resolved in this order:

  1. Prose{PascalTag}: e.g., ProseH1 for h1
  2. {PascalTag}: e.g., Alert for alert
  3. {tag}: e.g., alert

If no custom component matches, the tag renders as a native HTML element.


Streaming

Enable real-time rendering as content arrives, ideal for AI chat interfaces and live previews.

Setup

Set streaming to true while content is being received, then false when done:

components/AiChat.tsx
import { useState } from 'react'
import { Markdown } from '@comark/react'

export default function AiChat() {
  const [content, setContent] = useState('')
  const [isStreaming, setIsStreaming] = useState(false)

  async function askAI(prompt: string) {
    setContent('')
    setIsStreaming(true)

    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ prompt }),
    })

    const reader = response.body!.getReader()
    const decoder = new TextDecoder()
    let accumulated = ''

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      accumulated += decoder.decode(value, { stream: true })
      setContent(accumulated)
    }

    setIsStreaming(false)
  }

  return (
    <Markdown streaming={isStreaming} caret>
      {content}
    </Markdown>
  )
}
autoClose is enabled by default: incomplete syntax like **bold text is automatically closed on every parse. Disable with options={{ autoClose: false }}.

Caret

The caret prop appends a blinking cursor to the last text node while streaming is true:

App.tsx
{/* Default caret */}
<Markdown streaming={isStreaming} caret>{content}</Markdown>

{/* Custom caret class */}
<Markdown streaming={isStreaming} caret={{ class: 'my-caret' }}>{content}</Markdown>
.my-caret {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: currentColor;
  animation: blink 1s step-end infinite;
  vertical-align: text-bottom;
}

@keyframes blink {
  50% { opacity: 0; }
}

TypeScript Support

Use ComarkPlugin from comark to type plugin arrays, and ElementNode to type the __node prop in components that override HTML elements:

ComarkWrapper.tsx
import type { ComponentType } from 'react'
import type { ComarkPlugin } from 'comark'
import { Markdown } from '@comark/react'

interface Props {
  content: string
  components?: Record<string, ComponentType<any>>
  plugins?: ComarkPlugin[]
}

export default function ComarkWrapper({ content, components, plugins }: Props) {
  return <Markdown components={components} plugins={plugins}>{content}</Markdown>
}
components/Heading.tsx
import type { ElementNode } from 'comark'

interface HeadingProps {
  __node?: ElementNode
  id?: string
  children: React.ReactNode
}

export default function Heading({ __node, id, children }: HeadingProps) {
  const Tag = __node?.[0] || 'h2'
  return <Tag id={id}>{children}</Tag>
}