Render Streaming Markdown from an LLM
Pass the accumulated text to a Comark renderer component, and set its streaming prop while tokens arrive. Comark closes unfinished syntax on every parse, so each frame renders as valid Markdown.
The problem
An LLM sends Markdown in small chunks. Most chunks end in the middle of a construct, for example **bold or a code fence with no closing line. A standard Markdown renderer shows these half-written parts as raw characters. When the closing marker arrives, the block changes shape, and the layout jumps. Comark handles this with two features:
autoCloseis on by default in every renderer. It closes unterminated bold, italic, inline code, links, tables, and components before each parse. An open code fence renders as a code block that grows until the fence closes. An incomplete link gets the placeholder URLcomark:incomplete-link.streamingturns on incremental parsing. Comark reuses the output of completed blocks, drops a half-typed opener at the end, and keeps the previous output if a parse fails.
Set streaming back to false when the stream ends. Comark then runs a final full parse. The caret prop adds a blinking cursor to the last text node while streaming is true.
Render a streaming message
Each renderer takes the same three inputs: the text, a streaming flag, and an optional caret:
<script setup lang="ts">
import { Markdown } from '@comark/vue'
defineProps<{ text: string, isStreaming: boolean }>()
</script>
<template>
<Suspense>
<Markdown :value="text" :streaming="isStreaming" caret />
</Suspense>
</template>'use client'
import { MarkdownClient } from '@comark/react'
export function Message({ text, isStreaming }: { text: string, isStreaming: boolean }) {
return <MarkdownClient value={text} streaming={isStreaming} caret />
}<script lang="ts">
import { Markdown } from '@comark/svelte'
let { text, isStreaming }: { text: string, isStreaming: boolean } = $props()
</script>
<Markdown value={text} streaming={isStreaming} caret />import { Component, Input } from '@angular/core'
import { Markdown } from '@comark/angular'
@Component({
selector: 'app-message',
standalone: true,
imports: [Markdown],
template: `<comark-markdown [value]="text" [streaming]="isStreaming" [caret]="true" />`,
})
export class MessageComponent {
@Input() text = ''
@Input() isStreaming = false
}In Vue, <Markdown> is async, so wrap it in <Suspense>. In React, <Markdown> is an async component. It hands off to MarkdownClient when streaming is true. In a client component, such as a chat that uses useChat, use MarkdownClient directly.
Set streaming from the AI SDK
The AI SDK splits each message into parts. A text part has state === 'streaming' while it receives tokens. Pass that value to the streaming prop, so each part stops streaming on its own:
'use client'
import { useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { MarkdownClient } from '@comark/react'
export default function Chat() {
const { messages, sendMessage } = useChat()
const [input, setInput] = useState('')
return (
<div>
{messages.map(message => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text'
? <MarkdownClient key={index} value={part.text} streaming={part.state === 'streaming'} caret />
: null
)}
</div>
))}
<form onSubmit={(event) => { event.preventDefault(); sendMessage({ text: input }); setInput('') }}>
<input value={input} onChange={event => setInput(event.target.value)} />
</form>
</div>
)
}For complete chat setups, see the Vue and Nuxt, Svelte, and Angular guides.
Sanitize model output
Treat model output as untrusted input. The default html plugin parses raw HTML into nodes. Without more configuration, a raw <script> element, an onerror attribute, or a javascript: link stays in the document. A <script> element then appears in server-rendered HTML.
Add the security plugin. It always strips on* attributes and unsafe protocols such as javascript:. Its blockedTags option removes tags from raw HTML and from component syntax, because both produce the same nodes:
import security from '@comark/react/plugins/security'
const plugins = [security({ blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style', 'object'] })]
// <MarkdownClient value={part.text} streaming={part.state === 'streaming'} plugins={plugins} caret />Define plugins outside the component, so the reference stays stable between renders. Import the same plugin from @comark/vue/plugins/security, @comark/svelte/plugins/security, or @comark/angular/plugins/security. To turn off raw HTML parsing, set registerDefaultPlugins: false and register only the plugins you need, without html.
FAQ
autoClose is on by default in parseMarkdown() and in every renderer. Call autoCloseMarkdown() only when you parse with another tool.streaming to false when the stream ends, for example from part.state.autoClose also closes an open ::component block, so the component renders before its closing :: arrives. See generative UI.Next steps
Intelligent UI
Let a model answer with your own interactive components, rendered progressively as it streams, in Vue, React, Svelte, or Angular. The same pattern as ChatGPT Intelligent UI.
Vue and Nuxt chat
Build a Nuxt chat that streams AI SDK responses and renders each message as Markdown with Comark while it arrives.