Rendering

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

<Comark>

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

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

const content = `# Hello World

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

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

Usage

Pass markdown content via children or the markdown prop:

<Comark>{content}</Comark>

Props

PropTypeDefaultDescription
childrenReact.ReactNode-Markdown content to parse and render
markdownstring''Alternative to children
optionsParseOptions{}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 ParseOptions for available options.

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

plugins

See ComarkPlugin for available plugins.

App.tsx
import { Comark } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'

const plugins = [
  highlight({
    themes: { light: 'github-light', dark: 'github-dark' }
  })
]

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

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

App.tsx
import { Comark } 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 (
    <Comark
      markdown={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 { Comark } from '@comark/react'
import Alert from './components/Alert'
import Card from './components/Card'

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

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

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 <Comark> and <ComarkRenderer>:

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

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

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

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 { Comark } from '@comark/react'

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

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

defineComarkComponent

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

Usage

Expose your configured component

comark.ts
import { defineComarkComponent } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
import math, { Math } from '@comark/react/plugins/math'
import mermaid, { Mermaid } from '@comark/react/plugins/mermaid'
import CustomAlert from './components/CustomAlert'

export const AppComark = defineComarkComponent({
  name: 'AppComark',

  plugins: [
    math(),
    mermaid(),
    highlight({
      themes: {
        light: 'github-light',
        dark: 'github-dark'
      },
    }),
  ],

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

Use it in your templates

App.tsx
import { AppComark } from './comark'

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

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

Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineComarkComponent>undefinedInherit plugins and components from another component
namestringundefinedComponent name for debugging
autoUnwrapbooleantrueAutomatically unwrap single block elements
autoClosebooleantrueAuto-close incomplete markdown syntax
htmlbooleantrueParse embedded HTML tags into AST nodes
linkifybooleantrueAuto-convert URL-like text into links
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:

comark/index.ts
import { defineComarkComponent } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
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 BaseComark = defineComarkComponent({
  name: 'BaseComark',
  plugins: [highlight({ themes: { light: 'github-light', dark: 'github-dark' } })],
  components: { pre: CodeBlock },
})

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

// Comment: extends Base only, no TOC, no math
export const CommentComark = defineComarkComponent({
  name: 'CommentComark',
  extends: BaseComark,
})

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

comark.ts
'use client'

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

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

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

<ComarkRenderer>

Renders a pre-parsed ComarkTree 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 tree directly in a React Server Component:

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

const parse = createParse()

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

  return <ComarkRenderer tree={tree} components={{ alert: Alert }} />
}

Renderer Props

PropTypeDefaultDescription
treeComarkTree—Required. The parsed tree returned by parse()
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>

defineComarkRendererComponent

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

Setup

Expose your configured renderer

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

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

Page integration

app/docs/[slug]/page.tsx
import { parse } from 'comark'
import { ArticleRenderer } from '@/comark'

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

  return <ArticleRenderer tree={tree} />
}

Renderer Options

OptionTypeDefaultDescription
extendsReturnType<typeof defineComarkRendererComponent>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:

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

const BaseRenderer = defineComarkRendererComponent({
  name: 'BaseRenderer',
  components: { pre: CodeBlock, a: ProseA },
})

export const ArticleRenderer = defineComarkRendererComponent({
  name: 'ArticleRenderer',
  extends: BaseRenderer,
  components: { alert: Alert },
})

export const CommentRenderer = defineComarkRendererComponent({
  name: 'CommentRenderer',
  extends: BaseRenderer,
  components: { alert: CommentAlert },
})

Live Documents

ComarkRenderer 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 comarkKey, 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 tree's own meta.key when a plugin sets it. With no context, it's a no-op at zero cost.

<ComarkRenderer comarkKey="page" tree={tree} />

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

import { createComarkContext, parse } from 'comark'

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

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

A path is a node-index path into tree.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 slot → children
  • Named slots → slot{Name} (e.g., #footer → slotFooter)
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?: ComarkElement
  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
<Comark components={{ h1: Heading, h2: Heading, h3: Heading }}>
  {content}
</Comark>

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 { Comark } 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 (
    <Comark streaming={isStreaming} caret>
      {content}
    </Comark>
  )
}
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 */}
<Comark streaming={isStreaming} caret>{content}</Comark>

{/* Custom caret class */}
<Comark streaming={isStreaming} caret={{ class: 'my-caret' }}>{content}</Comark>
.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 ComarkElement to type the __node prop in components that override HTML elements:

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

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

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

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

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