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
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'
import githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'

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

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'

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 githubLight from '@shikijs/themes/github-light'
import githubDark from '@shikijs/themes/github-dark'
import CustomAlert from './components/CustomAlert'

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

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

  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
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 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 BaseComark = defineComarkComponent({
  name: 'BaseComark',
  plugins: [highlight({ themes: { light: githubLight, dark: githubDark } })],
  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 — 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
treeComarkTreeRequired. 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 },
})

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?: 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>
}