n4nAI

Streaming GPT-4o responses into a Vue 3 component

Build a vue 3 gpt-4o streaming component that renders token-by-token LLM output over SSE with a composable and minimal OpenAI-compatible client.

n4n Team3 min read566 words

Audio narration

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

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
  • curl for 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" with crypto.randomUUID() per message to avoid reuse bugs.
  • Nuxt: Put the composable in composables/useChatStream.ts and call it from a client component; the same fetch logic runs in the browser. For SSR streaming, use a server route with event.node.res and pipe.
  • Error UX: Show a retry button when error is 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.

Tagsvuegpt-4ostreamingtutorial

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 →