Skip to content
Deep diveData & APIs

Streaming AI responses: fetch, ReadableStream and the AI SDK

A chat reply that appears word by word is an ordinary HTTP response read in pieces. Build that by hand with fetch and a stream reader, stop it with an AbortController, then see what AI SDK 6 adds on top.

vuejs.school team7 May 20266 min readai 6 · @ai-sdk/vue 3 · Nuxt 4.4

A language model writes its answer one token at a time (a token is a word or a piece of a word). If your app waits for the whole answer, the user stares at a spinner for ten seconds. If it streams, the first words appear in under a second and the rest follow as they are written. You will build that twice in a Nuxt 4.4 app: first by hand, to see every moving part, then with AI SDK 6 and @ai-sdk/vue 3, to see what the library does for you. Pages live in app/pages/, and server/ stays at the project root.

One rule runs through both versions. The provider's API key stays on the server. The browser talks to your Nuxt route, and only your route talks to the provider.

What a streamed response is

An HTTP response does not have to arrive in one piece. The server can send the headers, then write the body in chunks over several seconds, and the connection stays open until it is done. That is chunked transfer, and browsers have always supported it.

With fetch, JavaScript can read those chunks as they land. In the browser, response.body is a ReadableStream: an object you pull chunks from, one at a time, as Uint8Array bytes. Call getReader() to get a reader, then await reader.read() in a loop. Each call resolves with { done, value }, and done turns true when the server closes the body.

Model providers stream with Server-Sent Events, a simple text format on top of this: each message is a line starting with data: , followed by a blank line. OpenAI's Chat Completions API sends a JSON object per line with the next piece of text in choices[0].delta.content, and ends with data: [DONE].

You might expect WebSockets here. They are not needed. A chat reply travels one way, from server to browser, and ends when the answer ends. A plain POST with a streamed response does that with the routes, cookies and authentication you already have, and nothing has to keep a socket alive between questions.

A Nuxt route that proxies the provider

Put the key in runtime config so it is read from the environment on the server and never bundled into client code:

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    openaiApiKey: '',
  },
})

Set NUXT_OPENAI_API_KEY in .env and Nuxt fills it in.

The browser only needs the words, so a TransformStream (a stream that changes each chunk as it passes through) keeps those and drops the rest of each event. Nuxt auto-imports what server/utils/ exports, so the route can call this helper without an import:

server/utils/sse-to-text.ts
export function sseToText() {
  const decoder = new TextDecoder()
  const encoder = new TextEncoder()
  let buffer = ''
  return new TransformStream<Uint8Array, Uint8Array>({
    transform(chunk, controller) {
      buffer += decoder.decode(chunk, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() ?? ''
      for (const line of lines) {
        if (!line.startsWith('data: ')) continue
        const data = line.slice(6)
        if (data === '[DONE]') continue
        const token = JSON.parse(data).choices[0]?.delta?.content
        if (token) controller.enqueue(encoder.encode(token))
      }
    },
  })
}

The buffer matters. Network chunks do not line up with event lines: a chunk can end halfway through a JSON object. Without the buffer, JSON.parse sees half an object and throws a SyntaxError. Keeping the last, unfinished line for the next chunk fixes that.

The route reads the prompt, calls the provider with the key, and pipes the answer through the helper:

server/api/stream.post.ts
export default defineEventHandler(async (event) => {
  const { prompt } = await readBody<{ prompt: string }>(event)
  const { openaiApiKey } = useRuntimeConfig(event)

  const upstreamAbort = new AbortController()
  event.node.res.on('close', () => upstreamAbort.abort())

  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${openaiApiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-5.1',
      stream: true,
      messages: [{ role: 'user', content: prompt }],
    }),
    signal: upstreamAbort.signal,
  })

  if (!upstream.ok || !upstream.body) {
    throw createError({ statusCode: 502, statusMessage: 'The model provider did not answer' })
  }

  setResponseHeaders(event, {
    'Content-Type': 'text/plain; charset=utf-8',
    'Cache-Control': 'no-cache',
    'X-Accel-Buffering': 'no',
  })
  return sendStream(event, upstream.body.pipeThrough(sseToText()))
})

sendStream writes each chunk to the browser as soon as it exists. The close listener is the server half of cancelling: when the browser goes away, the request to the provider is aborted too, so it stops generating tokens you would pay for and nobody would read.

The two extra headers are for production. A reverse proxy such as nginx may buffer a response and deliver it in one piece, which makes a perfect stream look broken only after you deploy. X-Accel-Buffering: no asks nginx not to, and Cache-Control: no-cache keeps caches out of the way. The AI SDK sends both on its own streams for the same reason.

Reading the stream into a ref

On the page, use the native fetch. Nuxt's $fetch waits for the whole body by default, which is exactly what you are trying to avoid.

app/pages/ask.vue
<script setup lang="ts">
const prompt = ref('Explain computed properties in two sentences.')
const answer = ref('')
const errorMessage = ref('')
const streaming = ref(false)
let controller: AbortController | undefined

async function ask() {
  controller = new AbortController()
  answer.value = ''
  errorMessage.value = ''
  streaming.value = true
  try {
    const response = await fetch('/api/stream', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt: prompt.value }),
      signal: controller.signal,
    })
    if (!response.ok || !response.body) throw new Error(`The server answered ${response.status}`)

    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      answer.value += decoder.decode(value, { stream: true })
    }
  }
  catch (error) {
    const aborted = error instanceof DOMException && error.name === 'AbortError'
    if (!aborted) errorMessage.value = error instanceof Error ? error.message : String(error)
  }
  finally {
    streaming.value = false
  }
}

function stop() {
  controller?.abort()
}

onBeforeUnmount(stop)
</script>

<template>
  <form @submit.prevent="ask">
    <label>Question <input v-model="prompt" type="text"></label>
    <button type="submit" :disabled="streaming">Ask</button>
    <button type="button" :disabled="!streaming" @click="stop">Stop</button>
  </form>
  <p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
  <p>{{ answer }}</p>
</template>

Each answer.value += changes the ref, and Vue updates the paragraph on its next render, so the answer grows on screen as the tokens arrive.

The decoder line hides the classic bug:

app/pages/ask.vueBreaks
answer.value += new TextDecoder().decode(value)

A character such as "é" or an emoji takes several bytes, and a chunk can end in the middle of one. A fresh decoder for each chunk sees half a character and prints �. It only happens now and then, which makes it hard to reproduce.

app/pages/ask.vueWorks
const decoder = new TextDecoder()
// inside the loop:
answer.value += decoder.decode(value, { stream: true })

One decoder for the whole response, with stream: true, holds back an incomplete character until the rest of its bytes arrive. The server helper above decodes the same way, for the same reason.

Stopping a stream

One call to controller.abort() ends the stream at both ends:

  1. The pending fetch or reader.read() rejects with a DOMException named AbortError, which the catch treats as a normal ending, not an error.
  2. The browser closes the connection.
  3. On the server, the close event fires and aborts the provider request.

Calling stop in onBeforeUnmount means a user who navigates away mid-answer does not leave a stream running into a page that no longer exists.

The same chat with AI SDK 6

AI SDK 6 has been stable since 22 December 2025. Install it with the OpenAI provider and the Vue package:

npm install ai @ai-sdk/vue @ai-sdk/openai

The route shrinks to the parts that are about your app:

server/api/chat.post.ts
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, streamText, type UIMessage } from 'ai'

export default defineEventHandler(async (event) => {
  const { messages } = await readBody<{ messages: UIMessage[] }>(event)
  const openai = createOpenAI({ apiKey: useRuntimeConfig(event).openaiApiKey })

  const abort = new AbortController()
  event.node.res.on('close', () => abort.abort())

  const result = streamText({
    model: openai('gpt-5.1'),
    system: 'You are a patient Vue tutor. Keep answers short.',
    messages: await convertToModelMessages(messages),
    abortSignal: abort.signal,
  })

  return result.toUIMessageStreamResponse({
    onError: () => 'The tutor is unavailable. Try again in a minute.',
  })
})

What each part does:

  • convertToModelMessages turns the browser's messages (each with an id and a list of parts) into the shape the model takes. It is async in AI SDK 6, hence the await.
  • abortSignal works like the hand-built upstreamAbort: when the connection closes (the user presses Stop or closes the tab), the provider call is cancelled.
  • toUIMessageStreamResponse returns a web Response with the SDK's event stream, which Nuxt sends as it is. Its onError is covered below.

In @ai-sdk/vue 3 the client is a Chat class rather than a composable. Its state lives in Vue refs inside the class, so the template reads it directly:

app/pages/chat.vue
<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport } from 'ai'

const input = ref('')
const chat = new Chat({ transport: new DefaultChatTransport({ api: '/api/chat' }) })
const busy = computed(() => chat.status === 'submitted' || chat.status === 'streaming')

function send() {
  if (!input.value.trim()) return
  chat.sendMessage({ text: input.value })
  input.value = ''
}
</script>

<template>
  <div v-for="message in chat.messages" :key="message.id">
    <strong>{{ message.role === 'user' ? 'You' : 'Tutor' }}</strong>
    <template v-for="(part, index) in message.parts" :key="index">
      <p v-if="part.type === 'text'">{{ part.text }}</p>
    </template>
  </div>
  <p v-if="chat.error" role="alert">{{ chat.error.message }}</p>
  <form @submit.prevent="send">
    <label>Message <input v-model="input" type="text"></label>
    <button type="submit" :disabled="busy">Send</button>
    <button v-if="busy" type="button" @click="chat.stop()">Stop</button>
  </form>
</template>

sendMessage adds the user's message to chat.messages and posts the whole history to the route, which is why the route reads messages rather than a single prompt.

What the SDK adds

Watch the route with curl -N and you see a richer protocol than plain text:

curl -N http://localhost:3000/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"id":"m1","role":"user","parts":[{"type":"text","text":"What is a ref?"}]}]}'
# data: {"type":"start"}
# data: {"type":"start-step"}
# data: {"type":"text-start","id":"..."}
# data: {"type":"text-delta","id":"...","delta":"A ref holds"}
# data: {"type":"text-end","id":"..."}
# data: {"type":"finish-step"}
# data: {"type":"finish","finishReason":"stop"}
# data: [DONE]

It is still Server-Sent Events, with an x-vercel-ai-ui-message-stream: v1 header, but every chunk is typed. That buys four things the hand-built version lacks:

  • Message parts. A reply is an ordered list of parts: text, reasoning, sources, files, tool calls. The template loops over parts and renders each kind its own way.
  • Tool calls. When the model calls a tool you defined with tool() on the server, a part of type tool-<name> appears and its state moves from input-streaming to input-available to output-available, or output-error. The UI can show "looking that up" before the result exists.
  • Errors as data. A failure arrives as an error chunk and ends up in chat.error. By default the chunk carries the error's own message, which for a provider error might include account details. The SDK's chatbot guide describes a masked default, but AI SDK 6 forwards the message, so set onError to a safe sentence, as the route above does.
  • Conversation state. chat.status moves through submitted, streaming, ready and error; stop(), regenerate() and the message history are handled for you, and switching provider is one line.

Write the stream by hand once, so the reader loop, the decoder and the abort make sense. Then use the SDK for anything with more than one message.

Check it stuck3 questions from the Streaming AI responses quiz.
Practice now
Was this helpful?

Keep reading