n4nAI

Markdown rendering mid-stream in Vue chat components

Learn to build a vue markdown streaming chat component that renders Markdown incrementally as LLM tokens stream, with safe parsing and Vue patterns.

n4n Team3 min read642 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming LLM responses into a chat UI is straightforward until you need a vue markdown streaming chat component that renders formatted text before the stream ends. Incremental Markdown parsing introduces edge cases: partial syntax, broken code fences, and reactive overhead. This guide walks through a concrete implementation that paints tokens as they arrive without flicker or XSS.

Step 1: Scaffold the project and install dependencies

Start with a Vue 3 + TypeScript base. Vite is the fastest path:

npm create vite@latest chat-stream -- --template vue-ts
cd chat-stream
npm install
npm install markdown-it dompurify

markdown-it parses CommonMark with sensible defaults. dompurify strips malicious HTML because we will use v-html to inject rendered Markdown. Do not skip sanitization—LLM output is untrusted input.

Step 2: Build a streaming composable

A reusable composable keeps the component clean. It opens a fetch call, reads the response body as a stream, and appends delta tokens to a reactive string. OpenAI-compatible APIs send Server-Sent Events with data: lines containing JSON deltas.

// useStream.ts
import { ref } from 'vue'

export function useStream() {
  const output = ref('')
  const loading = ref(false)

  async function streamChat(url: string, payload: any) {
    loading.value = true
    output.value = ''
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    })
    if (!res.body) throw new Error('No response body')
    const reader = res.body.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const chunk = decoder.decode(value, { stream: true })
      for (const line of chunk.split('\n')) {
        if (!line.startsWith('data:')) continue
        const data = line.slice(5).trim()
        if (data === '[DONE]') continue
        try {
          const json = JSON.parse(data)
          const token = json.choices?.[0]?.delta?.content ?? ''
          output.value += token
        } catch {
          // ignore keep-alive or partial JSON
        }
      }
    }
    loading.value = false
  }

  return { output, loading, streamChat }
}

Use ref for simplicity. For very long transcripts, swap to shallowRef and bump .value manually to avoid deep reactivity overhead, but for a single streaming message ref is fine.

Step 3: Render Markdown safely mid-stream

The core problem: Markdown parsed before the author finishes a construct looks broken. A trailing ``` without a closing fence makes markdown-it treat everything after as code. The fix is to detect an odd number of fence markers and temporarily close the block before rendering.

// renderMarkdown.ts
import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify'

const md = new MarkdownIt({ html: false, linkify: true, breaks: true })

function countOpenFences(src: string): number {
  const fences = src.match(/```/g)
  return fences ? fences.length % 2 : 0
}

export function renderMarkdown(src: string): string {
  let processed = src
  if (countOpenFences(processed) === 1) {
    processed += '\n```'
  }
  const raw = md.render(processed)
  return DOMPurify.sanitize(raw)
}

This approach stabilizes the view: an in-progress code block shows as code instead of leaking into the rest of the document. Other partial syntax (e.g., an unclosed `italic) renders as plain text until completed, which is acceptable. If you need stricter behavior, buffer the last line and only render completed lines, but that adds latency to visual updates.

Why not render per-token with a virtual DOM diff?

Vue already diffs the v-html string. Re-running markdown-it on the full buffer each token is O(n) per token, which is O(n²) overall. For chat messages under a few thousand tokens this is invisible. If you stream novels, debounce the render with requestAnimationFrame and cache the last rendered prefix.

Step 4: Assemble the vue markdown streaming chat component

Wire the composable and renderer into a single-file component. The vue markdown streaming chat component pattern here keeps the raw text in output and derives HTML via a computed property.

<!-- ChatStream.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useStream } from './useStream'
import { renderMarkdown } from './renderMarkdown'

const { output, loading, streamChat } = useStream()
const html = computed(() => renderMarkdown(output.value))

function send() {
  streamChat('https://api.example.com/v1/chat/completions', {
    model: 'gpt-4o-mini',
    messages: [
      { role: 'user', content: 'Explain async iteration in JS with a code sample.' }
    ],
    stream: true,
  })
}
</script>

<template>
  <div class="chat">
    <button :disabled="loading" @click="send">
      {{ loading ? 'Streaming…' : 'Send' }}
    </button>
    <div class="message" v-html="html"></div>
  </div>
</template>

<style scoped>
.message {
  white-space: normal;
  font-family: system-ui, sans-serif;
}
.message :deep(pre) {
  background: #1e1e1e;
  color: #d4d4d4;
  padding: 0.75rem;
  border-radius: 6px;
  overflow-x: auto;
}
</style>

The :deep(pre) selector styles code blocks that appear mid-stream. Because we close open fences in renderMarkdown, the pre element exists from the first fence token, preventing layout jumps.

Step 5: Point at a streaming LLM endpoint

The composable expects an OpenAI-compatible SSE endpoint. You can target any backend that speaks that protocol. For example, n4n.ai provides one OpenAI-compatible endpoint covering 240+ models with automatic fallback when a provider is rate-limited, so the same streamChat call works across model swaps without client changes.

A minimal curl test confirms the shape:

curl -N https://api.n4n.ai/v1/chat/completions \
  -H "Authorization: Bearer $N4N_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-3-5-sonnet",
    "messages": [{"role":"user","content":"Say hi in markdown"}],
    "stream": true
  }'

In the browser, replace the URL in streamChat with your gateway base URL and add an Authorization header. If you self-host, proxy the request to avoid CORS and to keep keys server-side.

Handling client routing directives

Some gateways honor routing hints. If you need a specific provider, pass route: { provider: 'anthropic' } in the payload (when supported). The streaming parser above ignores unknown fields, so it stays compatible.

Step 6: Verify the implementation

Run the dev server and exercise the component:

npm run dev

Open the app and click Send. Confirm the following:

  1. Tokens appear progressively. The Network tab shows a single open request; the DOM updates every few hundred milliseconds.
  2. Code blocks stabilize. When the model emits ```js, the text immediately enters a dark pre block. When the closing fence arrives, syntax stays formatted rather than collapsing.
  3. No XSS. Temporarily force a malicious string (e.g., <img src=x onerror=alert(1)>) into the mock stream; DOMPurify strips it, and no alert fires.
  4. No console errors. Partial JSON lines in SSE are caught by the try/catch and ignored.
  5. Fallback works. Kill the primary provider or exceed rate limits; if using a gateway with automatic fallback, the stream continues from a secondary provider without client changes.

If all five hold, you have a production-ready vue markdown streaming chat component. For scale, extract the render call into a worker and post the sanitized HTML back to the main thread, but for most SaaS chat features the main-thread path is sufficient.

Caveats and next steps

Markdown-it’s default renderer does not highlight code. Add highlight.js inside the highlight option if you want colored tokens mid-stream. Be aware that highlight runs on partial code and may throw on incomplete syntax—wrap it in try/catch and return the raw text.

If you render multiple messages, key each by message ID and keep a separate output ref per message. Never share one streaming string across turns; the composable above is per-call, so instantiate it inside a v-for loop or create a small wrapper component.

The pattern here—buffer, sanitize, close open constructs, derive HTML—extends to React or Svelte with the same logic. Vue’s reactivity just makes the derived computed trivial.

Tagsvuemarkdownstreamingchat-ui

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vue.js & nuxt llm streaming chat posts →