If you have ever wired up an AI chat feature, you know the awkward moment: the model streams its answer one token at a time, and your Markdown renderer chokes on a code fence that has not been closed yet, or a bold marker with no partner. The text flickers, layout jumps, and the experience feels broken even though everything is technically fine. On top of that, plain Markdown can only ever produce plain HTML, so the rich, interactive responses you actually want, things like alerts, cards, charts, and math, stay out of reach.
comark is a high-performance Markdown parser and renderer designed around these exact problems. It parses standard CommonMark and GitHub Flavored Markdown, but it also understands a component syntax so your content can embed real React components. And it was built streaming-first: incomplete syntax is auto-closed as tokens arrive, so a partial message renders cleanly the entire way through. It comes from the team behind Nuxt Content, the project that popularized the Markdown Components (MDC) idea, now generalized into a standalone library that works with React, Vue, Svelte, Angular, plain HTML, and even ANSI terminal output.
Why Comark Exists
The pitch is simple: Markdown is human-readable, token-efficient, and increasingly the native language of AI output. Models love producing it, and it costs fewer tokens than verbose JSON or HTML. Comark leans into that by treating Markdown as a first-class UI format rather than a dead-end text blob.
Three ideas make it stand out:
- Components in Markdown. Your content can write
::alertor:badge[New]and have those map to actual React components that you control and whitelist. You decide what is renderable. - Streaming with auto-close. When content is still arriving, the parser closes dangling syntax so the UI never breaks mid-stream. It can even show a blinking caret to signal that more is coming.
- Multi-framework by design. The core
comarkpackage is framework-agnostic. Renderers like@comark/reactconsume the same parsed tree, so the same content behaves consistently across stacks.
It is also genuinely fast, built on the markdown-exit parser, ships full TypeScript types, is ESM-only, and is tree-shakeable. Heavy optional features like syntax highlighting, math, and diagrams come from optional peer dependencies, so you only pay for what you use.
Getting It Into Your Project
The React renderer lives in its own scoped package. Install it alongside any optional plugins you want, such as katex for math:
npm install @comark/react katex
yarn add @comark/react katex
One thing to note: @comark/react lists React 19 as a peer dependency, so make sure your app is on a modern React version. The package is pre-1.0 (currently 0.4.0), and it moves quickly, so it is worth pinning your version.
Rendering Your First Stream
The headline component is <Comark>. You hand it Markdown, either as children or via the markdown prop, and it parses and renders in one step.
import { Comark } from '@comark/react'
function Message({ content }: { content: string }) {
return <Comark>{content}</Comark>
}
That alone gives you CommonMark and GitHub Flavored Markdown rendering. The interesting part shows up when the content is still streaming in from a model. Flip on the streaming prop and add a caret, and Comark handles partial syntax gracefully:
import { Comark } from '@comark/react'
function StreamingMessage({
content,
isStreaming,
}: {
content: string
isStreaming: boolean
}) {
return (
<Comark streaming={isStreaming} caret>
{content}
</Comark>
)
}
While streaming is true, an unclosed code fence or a half-typed emphasis marker gets auto-closed so the output stays valid. The caret prop adds a blinking cursor at the end of the text, the small touch that makes a chat reply feel alive. You can pass caret as a boolean or as { class: string } to style it your way. When the stream finishes, set isStreaming to false and the final, fully-parsed content settles in.
Teaching Markdown to Speak React
Plain Markdown can only emit standard elements. Comark adds a component layer on top, borrowed from the MDC syntax. A block component sits on its own lines, opened and closed with :::
::alert{type="warning"}
Be careful with this operation.
::
Inline components use a single colon and can carry content and props:
Click the :button[Submit]{type="primary"} to continue.
Status: :badge[New]{color="blue"}
To turn those names into real components, pass a components map. Comark looks up each component name and renders your React component in its place:
import { Comark } from '@comark/react'
function Alert({
type,
children,
}: {
type?: 'info' | 'warning'
children: React.ReactNode
}) {
return <div className={`alert alert-${type ?? 'info'}`}>{children}</div>
}
function Reply({ content }: { content: string }) {
return <Comark components={{ alert: Alert }}>{content}</Comark>
}
Attribute coercion is thoughtful. A plain {type="warning"} arrives as the string "warning", while a colon-prefixed {:count="5"} is parsed into the number 5, and {:active="true"} becomes the boolean true. The familiar class and style attributes are mapped to React's className and a real style object. Named slots map to props too: a #footer slot becomes a slotFooter prop, and the default content is just children. Because you provide the component map, only components you explicitly allow can ever render, which is a far safer story for untrusted AI output than executing arbitrary JSX.
Plugins for the Heavy Lifting
Syntax highlighting, math, and diagrams arrive as plugins rather than being baked into the core. You import them from the @comark/react/plugins/* paths and pass them through the plugins prop, wiring up any companion components they expose:
import { Comark } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
import math, { Math } from '@comark/react/plugins/math'
import { githubLight, githubDark } from 'shiki/themes'
function RichContent({ content }: { content: string }) {
return (
<Comark
plugins={[
highlight({ themes: { light: githubLight, dark: githubDark } }),
math(),
]}
components={{ Math }}
>
{content}
</Comark>
)
}
The highlight plugin uses shiki under the hood for accurate, theme-aware code blocks, and math uses katex to render LaTeX. Both shiki and katex are optional peer dependencies, so they only land in your bundle when you actually opt into them. There are over a dozen built-in plugins covering things like diagrams and emoji, following the same import-and-pass pattern.
Configuring Once, Reusing Everywhere
Threading the same plugins and components through every <Comark> call gets repetitive fast. The defineComarkComponent helper lets you bake your configuration into a single preconfigured component that you import across the app:
import { defineComarkComponent } from '@comark/react'
import highlight from '@comark/react/plugins/highlight'
import math, { Math } from '@comark/react/plugins/math'
import { CustomAlert } from './components/CustomAlert'
import { githubLight, githubDark } from 'shiki/themes'
export const AppComark = defineComarkComponent({
name: 'AppComark',
plugins: [
math(),
highlight({ themes: { light: githubLight, dark: githubDark } }),
],
components: { Math, alert: CustomAlert },
autoUnwrap: true,
autoClose: true,
})
Now anywhere in your app you just render <AppComark>{content}</AppComark> and your plugins, components, and parsing options come along automatically. The config supports an extends option so one configured component can inherit from another, which is handy when different surfaces need slightly different setups built on a shared base.
Parsing on the Server, Rendering on the Client
For content that does not change per request, like docs or blog posts, shipping the parser to every browser is wasteful. Comark separates the two phases. You can parse Markdown into a tree ahead of time, then hand that tree to <ComarkRenderer>, which only knows how to render and leaves the parser out of the client bundle entirely:
import { ComarkRenderer } from '@comark/react'
import { createParse } from 'comark'
import { Alert } from './components/Alert'
const parse = createParse()
async function renderArticle(markdown: string) {
const tree = await parse(markdown)
return <ComarkRenderer tree={tree} components={{ alert: Alert }} />
}
<ComarkRenderer> mirrors the props of <Comark> but takes a tree instead of raw Markdown. There is a matching defineComarkRendererComponent helper if you want the same preconfigured convenience on the render-only side. This split is a clean way to keep your client bundle lean while still getting the full component-rich output.
The Honest Caveats
Comark is young. The repository was created in early 2026, and the package is still pre-1.0 at version 0.4.0, with a fast release cadence that occasionally reshapes the API, so pin your versions and read the changelog before upgrading. The React renderer requires React 19, and the whole thing is ESM-only, which is the right call for a modern library but worth confirming against your build setup. Its ecosystem is smaller than long-established renderers, though the backing from the Nuxt Content team and steady weekly download growth suggest it is not a weekend experiment.
Should You Reach for It?
If you are building anything where Markdown streams in live, especially AI chat, Comark is a compelling fit. The auto-close behavior alone solves a real headache, and the component-in-Markdown model lets your assistant return genuinely rich, interactive answers while you stay in control of what can render. Pair that with server-side parsing for static content and a single configured component for everything else, and you have a Markdown layer that scales from a docs page to a fully interactive AI interface, all from the same small, fast, type-safe library. For a project this new it is remarkably well thought out, and it is solving a problem that more and more apps are about to run into.