Most chat UIs block until the full response arrives, which feels sluggish for LLM apps. This tutorial builds a vue 3 composition api streaming chat interface that renders tokens as they stream from an OpenAI-compatible endpoint, using the Composition API and native fetch.
Prerequisites
- Node 18+ and the Vue 3.4+ toolchain (Vite).
- TypeScript familiarity; we use
<script setup lang="ts">. - An OpenAI-compatible
/v1/chat/completionsendpoint withstream: true. - An API key exposed via
VITE_API_KEYin a.envfile.
No UI libraries are required. We render raw tokens and scroll manually.
Project Setup
Scaffold a minimal Vite app:
npm create vite@latest chat-ui -- --template vue-ts
cd chat-ui
npm install
Replace src/App.vue with a thin wrapper that mounts our chat component. Keep main.ts as generated.
The Streaming Composable
The core of a vue 3 composition api streaming chat is a composable that owns the message array and a send() function. It opens a fetch request, reads the body stream, and appends decoded tokens to the active assistant message.
Parsing Server-Sent Events
OpenAI-compatible streams emit data: {json}\n\n lines. The terminal event is data: [DONE]. We split on \n\n and parse each data: payload.
// utils/sse.ts
export function parseSSEChunk(buffer: string): { events: string[]; rest: string } {
const parts = buffer.split("\n\n")
const rest = parts.pop() ?? ""
const events = parts
.map(p => p.trim())
.filter(p => p.startsWith("data:"))
.map(p => p.slice(5).trim())
return { events, rest }
}
Implementation
// composables/useChatStream.ts
import { ref } from "vue"
import { parseSSEChunk } from "../utils/sse"
interface Message {
role: "user" | "assistant"
content: string
}
export function useChatStream() {
const messages = ref<Message[]>([])
const input = ref("")
const isStreaming = ref(false)
const error = ref<string | null>(null)
let abort: AbortController | null = null
async function send() {
const text = input.value.trim()
if (!text || isStreaming.value) return
messages.value.push({ role: "user", content: text })
messages.value.push({ role: "assistant", content: "" })
input.value = ""
isStreaming.value = true
error.value = null
abort = new AbortController()
const assistantIdx = messages.value.length - 1
try {
const res = await fetch(import.meta.env.VITE_API_BASE + "/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
},
body: JSON.stringify({
model: import.meta.env.VITE_MODEL ?? "gpt-3.5-turbo",
messages: messages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
stream: true,
}),
signal: abort.signal,
})
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const { events, rest } = parseSSEChunk(buffer)
buffer = rest
for (const evt of events) {
if (evt === "[DONE]") continue
const json = JSON.parse(evt)
const token = json.choices?.[0]?.delta?.content ?? ""
messages.value[assistantIdx].content += token
}
}
} catch (e: any) {
if (e.name !== "AbortError") error.value = e.message ?? "stream failed"
} finally {
isStreaming.value = false
abort = null
}
}
function stop() {
abort?.abort()
}
return { messages, input, isStreaming, error, send, stop }
}
The composable keeps the assistant message reactive by mutating content in place. Vue’s reactivity tracks the ref array element. Because we only append and extend strings, no deep watchers are needed.
Building the Chat Component
Create src/components/ChatWindow.vue.
<script setup lang="ts">
import { useChatStream } from "../composables/useChatStream"
import { watch, nextTick, ref } from "vue"
const { messages, input, isStreaming, error, send, stop } = useChatStream()
const scrollEl = ref<HTMLElement | null>(null)
watch(
() => messages.value.map(m => m.content).join(""),
async () => {
await nextTick()
scrollEl.value?.scrollTo({ top: scrollEl.value.scrollHeight })
}
)
</script>
<template>
<div class="chat">
<div class="messages" ref="scrollEl">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role]">
<span class="role">{{ m.role }}</span>
<span class="content">{{ m.content }}<span v-if="m.role === 'assistant' && isStreaming && i === messages.length - 1">▌</span></span>
</div>
<div v-if="error" class="error">{{ error }}</div>
</div>
<form @submit.prevent="send" class="input-row">
<input v-model="input" :disabled="isStreaming" placeholder="Type a message…" />
<button type="submit" :disabled="isStreaming">Send</button>
<button type="button" @click="stop" :disabled="!isStreaming">Stop</button>
</form>
</div>
</template>
<style scoped>
.chat { display: flex; flex-direction: column; height: 80vh; max-width: 600px; margin: 0 auto; }
.messages { flex: 1; overflow-y: auto; padding: 1rem; border: 1px solid #ccc; }
.msg { margin-bottom: 0.75rem; }
.msg.user .role { color: #09f; }
.msg.assistant .role { color: #f60; }
.role { font-weight: bold; margin-right: 0.5rem; text-transform: capitalize; }
.input-row { display: flex; gap: 0.5rem; padding: 0.5rem; }
.input-row input { flex: 1; }
.error { color: red; }
</style>
Mount it in App.vue:
<script setup lang="ts">
import ChatWindow from "./components/ChatWindow.vue"
</script>
<template>
<ChatWindow />
</template>
Wiring the Endpoint
Create .env in the project root:
VITE_API_BASE=https://api.openai.com
VITE_API_KEY=sk-your-key
VITE_MODEL=gpt-3.5-turbo
If you need multi-provider resilience, an OpenAI-compatible gateway like n4n.ai handles fallback when a provider is degraded, so your vue 3 composition api streaming chat keeps flowing without client changes. The same /v1/chat/completions contract applies.
Checkpoint: First Stream
Run npm run dev. Open the app, type “Explain promises in JS”, and submit. You should see the assistant message populate token-by-token:
user: Explain promises in JS
assistant: A promise is an object representing a value that may be available now, later, or never.▌
The cursor blinks while isStreaming is true. Network tab shows a single pending request with chunked responses. The browser paints each mutation without blocking the main thread because reader.read() yields between chunks.
Handling Interrupt and Errors
The AbortController lets users stop generation. Clicking Stop triggers abort.abort(), which rejects the reader with AbortError. We swallow that specifically.
For provider errors, the stream may close with a non-200 status before any token. The res.ok check catches it and sets error. Surface that in the UI as shown.
A vue 3 composition api streaming chat should also guard against backpressure: if the user sends a new message while streaming, we disable the input. That prevents interleaving responses. If you want queueing, buffer pending inputs and send them after the active stream finishes.
Performance and UX Notes
- Use
shallowReffor messages if you push large arrays, butrefis fine for chat scales. - Key the
v-forby index is acceptable because we append only. If you support editing history, use stable IDs. - Decode with
{ stream: true }to avoid truncating multi-byte UTF-8 characters at chunk boundaries. - For production, move the API key to a backend proxy; exposing it in
VITE_variables ships it to the browser. - Auto-scroll on content change, not on every keystroke, to avoid jank. The watcher above joins all content and triggers after DOM update.
Extending the UI
Add tool calls by inspecting delta.tool_calls in the stream. Render thinking states by watching isStreaming. Swap the SSE parser for WebSocket if your gateway supports bidirectional streaming.
The pattern above isolates streaming logic in a composable, making the vue 3 composition api streaming chat reusable across Nuxt pages or standalone widgets. Build on it with markdown rendering or syntax highlighting as needed.