---
title: "Render Streaming Markdown from an LLM"
description: "Render LLM output as Markdown while it streams, with no raw asterisks or open code fences, in Vue, React, Svelte, and Angular."
canonical_url: "https://comark.dev/use-cases/ai-chat-streaming"
---
# Render Streaming Markdown from an LLM

> Render LLM output as Markdown while it streams, with no raw asterisks or open code fences, in Vue, React, Svelte, and Angular.

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:

- **`autoClose`** is 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 URL `comark:incomplete-link`.
- **`streaming`** turns 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`:

::code-group
```vue [Vue]
<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>
```

```tsx [React]
'use client'
import { MarkdownClient } from '@comark/react'

export function Message({ text, isStreaming }: { text: string, isStreaming: boolean }) {
  return <MarkdownClient value={text} streaming={isStreaming} caret />
}
```

```svelte [Svelte]
<script lang="ts">
  import { Markdown } from '@comark/svelte'

  let { text, isStreaming }: { text: string, isStreaming: boolean } = $props()
</script>

<Markdown value={text} streaming={isStreaming} caret />
```

```typescript [Angular]
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](https://ai-sdk.dev/docs) 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:

```tsx [app/chat.tsx]
'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](https://comark.dev/use-cases/vue-ai-chat), [Svelte](https://comark.dev/use-cases/svelte-ai-chat), and [Angular](https://comark.dev/use-cases/angular-ai-chat) 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](https://comark.dev/plugins/built-in/security). 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:

```tsx [app/chat.tsx]
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

::accordion
  :::accordion-item{label="Do I have to call autoCloseMarkdown myself?"}
  No. `autoClose` is on by default in `parseMarkdown()` and in every renderer. Call [`autoCloseMarkdown()`](https://comark.dev/reference/auto-close) only when you parse with another tool.
  :::

  :::accordion-item{label="What happens if I leave streaming set to true?"}
  Comark stays in incremental mode and doesn't run the final full parse. Set `streaming` to `false` when the stream ends, for example from `part.state`.
  :::

  :::accordion-item{label="Can the model use components while it streams?"}
  Yes. `autoClose` also closes an open `::component` block, so the component renders before its closing `::` arrives. See [generative UI](https://comark.dev/use-cases/generative-ui).
  :::
::

## Next steps

- [Streaming Markdown in Vue and Nuxt with the AI SDK](https://comark.dev/use-cases/vue-ai-chat)
- [Let an LLM render UI components](https://comark.dev/use-cases/generative-ui)
- [Auto-close API reference](https://comark.dev/reference/auto-close)
- [Markdown libraries for AI chat](https://comark.dev/compare/markdown-libraries-for-ai-chat)

---

- [Auto-close API](https://comark.dev/reference/auto-close)
- [Streaming demo](https://comark.dev/stream)


## Sitemap

See the full [sitemap](https://comark.dev/sitemap.md) for all pages.
