Most LLM chat UIs need token-by-token streaming, and the cleanest way to bridge a browser to a model API in a Nuxt app is through nuxt sse server event handlers. This guide walks through building a server route that opens an SSE stream, proxies an upstream LLM response, and cleans up on client disconnect. You will end up with a runnable Nuxt 3 server route and a Vue client that renders tokens as they arrive.
Step 1: Scaffold the server route
Nuxt 3 picks up files in server/api/ as HTTP handlers. Create server/api/chat.ts. The handler receives an H3Event with node.req and node.res from Node’s http stack. Do not return a JSON body; you own the raw socket.
// server/api/chat.ts
import { defineEventHandler } from 'h3'
export default defineEventHandler(async (event) => {
const res = event.node.res
// headers set in step 2
})
Keep the function async but never call return with data. If you return an object, Nuxt serializes it and closes the response.
Step 2: Set SSE headers and open the stream
SSE requires three headers and a kept-alive connection. Set them before writing any bytes, then flush so the client sees the stream immediately.
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') // disable proxy buffering on nginx
res.flushHeaders()
// send a comment to force flush and confirm connection
res.write(': connected\n\n')
The X-Accel-Buffering: no header is non-negotiable if you deploy behind nginx. Without it, nginx buffers the response and the browser gets one giant blob at the end.
Step 3: Proxy the upstream LLM stream
Assume an OpenAI-compatible chat completions endpoint with stream: true. Use an AbortController tied to the client socket so an early disconnect kills the upstream fetch.
const ac = new AbortController()
event.node.req.on('close', () => ac.abort())
const prompt = (getQuery(event).prompt as string) ?? ''
const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: prompt }],
}),
signal: ac.signal,
})
If you proxy through n4n.ai, the same request shape works against its single OpenAI-compatible endpoint and you get automatic fallback across providers plus per-token metering without extra code.
Now read the upstream SSE, parse JSON lines, and re-emit only the token delta to the browser:
const reader = upstream.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 | undefined = json.choices?.[0]?.delta?.content
if (token) {
res.write(`data: ${JSON.stringify({ token })}\n\n`)
}
} catch {
// ignore malformed keep-alive lines
}
}
}
res.write('data: [DONE]\n\n')
res.end()
This strips the upstream metadata and ships a minimal { token } object. The browser does not need finish_reason or usage mid-stream.
Step 4: Handle client disconnect and resource cleanup
The close listener on req already aborts the upstream fetch. Add an explicit guard so you never write to a closed socket:
let closed = false
event.node.req.on('close', () => {
closed = true
ac.abort()
})
// inside the read loop, before each write:
if (closed) break
If you skip this, Node throws ERR_STREAM_WRITE_AFTER_END under load and your server logs fill with noise. In serverless environments, also catch abort errors from fetch so they don’t surface as 500s.
try {
await upstream.body!.pipeThrough(new TextDecoderStream()).pipeTo(...)
} catch (err) {
if ((err as Error).name === 'AbortError') return
throw err
}
Step 5: Consume the stream in the browser
EventSource only supports GET, which is fine for a chat prompt passed as a query param. In a Vue component:
<script setup lang="ts">
import { ref } from 'vue'
const prompt = ref('')
const output = ref('')
const streaming = ref(false)
function startStream() {
streaming.value = true
output.value = ''
const es = new EventSource(`/api/chat?prompt=${encodeURIComponent(prompt.value)}`)
es.onmessage = (e) => {
if (e.data === '[DONE]') {
es.close()
streaming.value = false
return
}
const { token } = JSON.parse(e.data)
output.value += token
}
es.onerror = () => {
es.close()
streaming.value = false
}
}
</script>
<template>
<input v-model="prompt" :disabled="streaming" />
<button @click="startStream" :disabled="streaming">Send</button>
<pre>{{ output }}</pre>
</template>
If you need to send a large conversation history, skip EventSource and use fetch with a ReadableStream reader on the client instead. The server route stays identical; only the client transport changes.
Step 6: Verify the end-to-end flow
Start the dev server and hit the route with curl using -N (no buffering):
curl -N "http://localhost:3000/api/chat?prompt=Explain%20SSE%20in%20one%20sentence"
You should see lines like:
: connected
data: {"token":"Server"}
data: {"token":"-Sent"}
data: {"token":"Events"}
data: [DONE]
If you see the whole response at once after a delay, a proxy is buffering—check X-Accel-Buffering and any CDN settings. In the browser, open the network tab, click the event stream, and confirm frames arrive incrementally.
Deployment caveats
On serverless platforms (Vercel, Netlify), SSE works only on functions that allow long-lived connections; some plans cap response time at 10–30s. For production LLM chat, run a dedicated Node server or use a platform that supports streaming responses natively. Nuxt’s nitro preset node-server is the path of least resistance.
Why not WebSocket?
WebSockets give bidirectional control but require a separate server process or adapter in Nuxt and complicate load balancers. SSE rides on HTTP/1.1, auto-reconnects via the browser, and fits a strictly server-push use case like token streaming. Use the simpler primitive unless you need client-to-server messages mid-stream.
Following these steps gives you robust nuxt sse server event handlers that proxy LLM output without leaking sockets or buffering tokens. The pattern generalizes to any upstream that emits SSE or a raw text stream.