n4nAI

Handling SSE streams in Nuxt server-side event handlers

Learn how to implement nuxt sse server event handlers to proxy LLM streaming responses, with runnable code and verification steps for production.

n4n Team3 min read588 words

Audio narration

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

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.

Tagsnuxtssestreamingserver-side

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 →