Most chat UIs block on a full response, which feels sluggish for LLM apps. This tutorial builds a Nuxt streaming chat UI using a nuxt openai-compatible api streaming chat approach: we proxy an OpenAI-compatible endpoint through a Nitro server route and consume the Server-Sent Events stream directly in Vue. You get token-by-token rendering without pulling in a heavy client SDK.
Prerequisites
- Node 18+ and a fresh Nuxt 3 project (
npx nuxi init chat-app && cd chat-app && npm i). - An API key from n4n.ai — its OpenAI-compatible endpoint fronts 240+ models and handles provider fallback automatically, so you can target one base URL.
- Basic familiarity with Vue 3
<script setup>and Nitro server routes.
Set the key in .env:
echo "N4N_API_KEY=sk-your-key" >> .env
Scaffold the route and composable
The nuxt openai-compatible api streaming chat pattern keeps credentials server-side. Create a server route that forwards the request and streams the response back.
// server/api/chat.ts
export default defineEventHandler(async (event) => {
const { messages, model } = await readBody(event)
const upstream = await fetch('https://api.n4n.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.N4N_API_KEY}`,
},
body: JSON.stringify({
model: model ?? 'gpt-3.5-turbo',
messages,
stream: true,
}),
})
// Pass the SSE stream straight through
return upstream.body
})
Nitro detects the ReadableStream and pipes it to the client with the upstream text/event-stream content type. No manual parsing on the server.
Parse the stream in a composable
Vue needs to read the SSE frames and append deltas. Write a composable that posts to /api/chat and decodes chunks.
// composables/useChatStream.ts
import { ref } from 'vue'
export function useChatStream() {
const messages = ref<{ role: string; content: string }[]>([])
const pending = ref(false)
async function send(userInput: string, model?: string) {
pending.value = true
messages.value.push({ role: 'user', content: userInput })
const assistant = { role: 'assistant', content: '' }
messages.value.push(assistant)
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messages.value.slice(0, -1),
model,
}),
})
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 frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
const line = frame.trim()
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]') continue
try {
const json = JSON.parse(data)
assistant.content += json.choices?.[0]?.delta?.content ?? ''
} catch {
// ignore keep-alive comments
}
}
}
pending.value = false
}
return { messages, pending, send }
}
The buffer handling matters: SSE frames can split across read() calls. We keep the tail and only parse complete data: blocks.
Build the chat component
Drop a minimal UI in pages/index.vue. It binds to the composable and renders each message reactively.
<template>
<main style="max-width: 720px; margin: 2rem auto; font-family: sans-serif">
<h2>Streaming Chat</h2>
<div v-for="(m, i) in messages" :key="i" style="margin: 0.5rem 0">
<strong>{{ m.role }}:</strong> {{ m.content }}
</div>
<input
v-model="input"
:disabled="pending"
@keydown.enter="submit"
placeholder="Type a message…"
style="width: 100%; padding: 0.5rem"
/>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStream } from '~/composables/useChatStream'
const { messages, pending, send } = useChatStream()
const input = ref('')
function submit() {
if (!input.value.trim() || pending.value) return
send(input.value)
input.value = ''
}
</script>
Run npm run dev and open the app. Type “Explain SSE in one sentence.” The assistant line updates character-by-character.
Verify the stream at the edge
Before wiring the UI, curl the route to confirm SSE format:
curl -N -X POST http://localhost:3000/api/chat \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"Say hi"}]}'
Expected output (truncated):
data: {"id":"chatcmpl-…","choices":[{"delta":{"role":"assistant"}}]}
data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" there!"}}]}
data: [DONE]
If you see data: [DONE] and incremental content fields, the nuxt openai-compatible api streaming chat proxy works.
Error handling and model routing
Network failures still surface as rejected promises in the composable. Wrap send in try/catch and surface a status message:
try {
await send(input.value)
} catch (e) {
messages.value.push({ role: 'system', content: 'Stream failed' })
}
Because the upstream gateway honors client routing directives, you can pass model: 'claude-3-opus' or any of the 240+ available IDs in the send call and the same route works. Provider rate limits trigger automatic fallback upstream, so the stream rarely dies mid-token.
Production notes
- Set
cache-controlhints on the server route if you proxy cached prompts; the gateway forwards provider cache headers. - Meter usage per token via the gateway’s usage field in the final frame (
json.usage). Log it server-side rather than trusting the client. - For multiple concurrent sessions, the composable’s local
messagesref is fine; scale out by moving state to a Pinia store.
That’s the full loop: Nuxt server route streams from an OpenAI-compatible endpoint, Vue decodes SSE, and the user sees tokens land in real time.