n4nAI

Nuxt streaming chat UI with n4n.ai's OpenAI-compatible API

Build a Nuxt streaming chat UI using an OpenAI-compatible API. Step-by-step tutorial with Vue components, server routes, and SSE handling.

n4n Team2 min read423 words

Audio narration

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

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-control hints 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 messages ref 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.

Tagsnuxtn4n-aistreamingchat-ui

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 →