Most chat UIs block until the model finishes, which feels sluggish for a 1k-token reply. This tutorial builds a vue 3 gpt-4o streaming component that renders GPT-4o output token-by-token using Server-Sent Events and a thin OpenAI-compatible client. You’ll end up with a reusable composable and a template that updates incrementally without jank.
Prerequisites
- Node 18+ and a Vue 3.4+ project (Vite + TypeScript scaffold is fine)
- An API key for an OpenAI-compatible endpoint (OpenAI direct, or a gateway)
- Comfort with Vue’s Composition API (
ref,script setup) and async/await curlfor a quick contract check
Scaffold the project
If you don’t have a project yet:
npm create vite@latest vue-stream-demo -- --template vue-ts
cd vue-stream-demo
npm install
Set your key in a .env file (we’ll proxy properly later, but for local dev this is fine):
echo "VITE_OPENAI_KEY=sk-..." > .env
Understand the GPT-4o streaming contract
GPT-4o’s /chat/completions endpoint with "stream": true returns newline-delimited data: frames. Each frame is a JSON object with a delta inside choices[0]:
{
"choices": [
{
"delta": { "content": "Hello" },
"index": 0,
"finish_reason": null
}
]
}
The stream terminates with data: [DONE]. There is no content on the first frame (only role), and the final frame carries finish_reason: "stop" with an empty delta. Your client must concatenate delta.content strings.
A quick contract check with curl:
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $VITE_OPENAI_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"stream":true}' \
--no-buffer
You should see data: {...} lines printing incrementally.
Build the vue 3 gpt-4o streaming component composable
Create src/composables/useChatStream.ts. This module owns the fetch call, SSE parsing, and reactive state.
import { ref } from 'vue'
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export function useChatStream() {
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | null = null
async function send(
prompt: string,
apiKey: string,
baseUrl = 'https://api.openai.com/v1',
system?: string,
) {
loading.value = true
error.value = null
abortController?.abort()
abortController = new AbortController()
const history: ChatMessage[] = system
? [{ role: 'system', content: system }]
: []
history.push(...messages.value.filter(m => m.role !== 'assistant'))
history.push({ role: 'user', content: prompt })
messages.value.push({ role: 'user', content: prompt })
messages.value.push({ role: 'assistant', content: '' })
try {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o',
messages: history,
stream: true,
}),
signal: abortController.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.body) throw new Error('Empty response body')
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 lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const payload = trimmed.slice(5).trim()
if (payload === '[DONE]') continue
try {
const json = JSON.parse(payload)
const token: string = json.choices?.[0]?.delta?.content ?? ''
if (token) {
const last = messages.value[messages.value.length - 1]
last.content += token
}
} catch {
// ignore keepalive comments or partial JSON
}
}
}
} catch (e: any) {
if (e.name !== 'AbortError') error.value = e.message
} finally {
loading.value = false
}
}
function abort() {
abortController?.abort()
}
return { messages, loading, error, send, abort }
}
Why manual SSE parsing
EventSource only issues GET requests; GPT-4o needs a POST with a JSON body. So we use fetch + ReadableStream reader. The buffer pattern handles TCP chunks that split mid-line: we keep the trailing partial line and prepend it to the next chunk.
Reactivity note
Mutating last.content += token on a ref<ChatMessage[]> triggers Vue’s deep reactivity, so the template re-renders the assistant bubble on every token. For very high token rates you could throttle with requestAnimationFrame, but raw updates are fine for most apps.
Create the component template
Create src/components/ChatStream.vue:
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStream } from '../composables/useChatStream'
const { messages, loading, error, send, abort } = useChatStream()
const input = ref('')
const apiKey = ref(import.meta.env.VITE_OPENAI_KEY ?? '')
async function submit() {
if (!input.value.trim() || loading.value) return
await send(input.value, apiKey.value, 'https://api.openai.com/v1',
'You are a terse assistant.')
input.value = ''
}
</script>
<template>
<div class="chat">
<div v-for="(m, i) in messages" :key="i" :class="m.role">
<strong>{{ m.role }}:</strong>
<span>{{ m.content }}</span>
</div>
<div v-if="loading" class="status">streaming…</div>
<div v-if="error" class="error">{{ error }}</div>
<form @submit.prevent="submit">
<input v-model="input" :disabled="loading" placeholder="Ask GPT-4o…" />
<button :disabled="loading">Send</button>
<button v-if="loading" type="button" @click="abort">Stop</button>
</form>
</div>
</template>
<style scoped>
.chat { max-width: 640px; margin: 0 auto; font-family: sans-serif; }
.assistant span { white-space: pre-wrap; }
.user { font-weight: 600; margin-top: 0.5rem; }
.error { color: #c00; }
.status { color: #888; font-style: italic; }
</style>
Mount it in App.vue:
<script setup lang="ts">
import ChatStream from './components/ChatStream.vue'
</script>
<template>
<ChatStream />
</template>
Checkpoint: first stream
Run npm run dev, open the local URL, and send “Explain SSE in one sentence.”
Expected network behavior: a single POST to /chat/completions with stream: true and a 200 response that stays open. In the UI you should see:
user: Explain SSE in one sentence.
assistant: Server-Sent Events let a server push text fragments to a browser over a single HTTP connection.
The assistant line paints word-by-word. If you click Stop mid-stream, the AbortController cancels the fetch and loading flips false.
Route through a gateway with fallback
If you point the same vue 3 gpt-4o streaming component at an OpenAI-compatible inference gateway, the code does not change. For example, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and performs automatic fallback when a provider is rate-limited or degraded, while forwarding provider cache-control hints so repeated prompts hit caches. Swap the base URL:
await send(input.value, gatewayKey, 'https://api.n4n.ai/v1',
'You are a terse assistant.')
The streaming parser above works unchanged because the chunk shape is identical.
Production hardening
- Never ship the API key in the browser. Proxy the call through a small backend (e.g., a Vite middleware or Nuxt server route) that injects the key and forwards the stream.
- Stable keys: Replace
:key="i"withcrypto.randomUUID()per message to avoid reuse bugs. - Nuxt: Put the composable in
composables/useChatStream.tsand call it from a client component; the samefetchlogic runs in the browser. For SSR streaming, use a server route withevent.node.resandpipe. - Error UX: Show a retry button when
erroris set; the assistant message may be partial, so either discard it or mark it failed.
Wrap-up
You now have a working vue 3 gpt-4o streaming component built on a clean composable, manual SSE parsing, and standard Vue reactivity. It handles incremental tokens, abort, and drops into any OpenAI-compatible backend. From here, add persistence, tool calls, or a markdown renderer—but the streaming core stays this small.