All posts

Streaming AI Responses in the Browser

How to build a ChatGPT‑like streaming experience with SSE and Vue Composables

If you've ever used ChatGPT, you've seen text appear word by word, like someone is typing in real time. It feels fast and almost human. Behind it is a simple protocol: Server-Sent Events (SSE).

In this post we'll build that experience with Vue 3 composables and a Nitro server endpoint. It's a one-way stream from server to browser, so there are no WebSockets and no polling.

Why streaming matters

When you call an LLM API the traditional way (a single request, wait for the full response), the user stares at a loading spinner for seconds. That's a bad experience, especially when the answer is long.

With streaming, the user starts reading the response immediately, while the model is still thinking. The total time is the same, but it feels faster.

You don't need WebSockets for this. SSE is built into every browser, and it fits because AI responses only flow one way.

What are Server-Sent Events?

SSE is a browser API that lets the server push data to the client over a single HTTP connection. Think of it as a one-way WebSocket: the server sends and the browser listens.

The browser opens a connection using EventSource (or a simple fetch with stream reading), and the server sends chunks of data as they become available.

For AI streaming, each chunk is typically a few tokens of text. The frontend appends them in real time.

The composable: useAIStream

Let's start from the frontend. We want a composable that:

  • Takes a prompt
  • Streams the response token by token
  • Exposes reactive state (the text so far, loading, errors)
// composables/useAIStream.ts
import { ref } from 'vue';

export function useAIStream() {
  const output = ref('');
  const isStreaming = ref(false);
  const error = ref<string | null>(null);

  async function send(prompt: string) {
    output.value = '';
    error.value = null;
    isStreaming.value = true;

    try {
      const response = await fetch('/api/ai/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt }),
      });

      if (!response.ok || !response.body) {
        throw new Error('Stream connection failed');
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        const lines = chunk.split('\n');

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') break;
            output.value += data;
          }
        }
      }
    } catch (err: any) {
      error.value = err.message ?? 'Something went wrong';
    } finally {
      isStreaming.value = false;
    }
  }

  return { output, isStreaming, error, send };
}

A few things to notice:

  • We're using the Fetch API with response.body.getReader() instead of EventSource, because we need to send a POST request with a body and EventSource doesn't support that.
  • The TextDecoder with { stream: true } handles partial chunks correctly.
  • Each SSE line starts with data: , and we use [DONE] as the end signal, the same convention OpenAI uses.

The server: streaming from Nitro

On the backend, we create a Nitro event handler that connects to an AI provider and streams the response back.

// server/api/ai/stream.post.ts
export default defineEventHandler(async (event) => {
  const { prompt } = await readBody(event);

  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.ANTHROPIC_API_KEY!,
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-5-20250514',
      max_tokens: 1024,
      stream: true,
      messages: [{ role: 'user', content: prompt }],
    }),
  });

  if (!response.ok || !response.body) {
    throw createError({ statusCode: 500, statusMessage: 'AI request failed' });
  }

  // Set SSE headers
  setResponseHeaders(event, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  const stream = new ReadableStream({
    async start(controller) {
      while (true) {
        const { done, value } = await reader.read();
        if (done) {
          controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
          controller.close();
          break;
        }

        const chunk = decoder.decode(value, { stream: true });
        const lines = chunk.split('\n').filter(Boolean);

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            try {
              const json = JSON.parse(line.slice(6));
              if (json.type === 'content_block_delta') {
                const text = json.delta?.text ?? '';
                controller.enqueue(
                  new TextEncoder().encode(`data: ${text}\n\n`),
                );
              }
            } catch {
              // Skip non-JSON lines
            }
          }
        }
      }
    },
  });

  return sendStream(event, stream);
});

This handler is a proxy. It takes the prompt, sends it to the AI API with stream: true, and re-streams the response to the browser in SSE format. The client never talks to the AI API directly, which keeps your API key on the server.

The component

A simple chat-like component puts it all together:

<template>
  <div class="chat">
    <form @submit.prevent="handleSubmit">
      <input
        v-model="prompt"
        placeholder="Ask me anything..."
        :disabled="isStreaming"
      />
      <button type="submit" :disabled="isStreaming || !prompt.trim()">
        Send
      </button>
    </form>

    <div v-if="output" class="response">
      <p>{{ output }}</p>
      <span v-if="isStreaming" class="cursor">|</span>
    </div>

    <p v-if="error" class="error">{{ error }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useAIStream } from '~/composables/useAIStream';

const { output, isStreaming, error, send } = useAIStream();
const prompt = ref('');

async function handleSubmit() {
  const text = prompt.value.trim();
  if (!text) return;
  prompt.value = '';
  await send(text);
}
</script>

<style scoped>
.chat {
  max-width: 640px;
  margin: 0 auto;
}

.response {
  margin-top: 1rem;
  padding: 1rem;
  background: var(--bg-secondary);
  border-radius: 8px;
  white-space: pre-wrap;
}

.cursor {
  animation: blink 0.8s step-end infinite;
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}

.error {
  color: #e11d48;
  margin-top: 0.5rem;
}
</style>

The blinking cursor at the end tells the user the response is still coming in.

Adding abort support

Users expect to be able to stop a response mid-stream. That's easy to add with AbortController:

// Inside useAIStream.ts
const controller = ref<AbortController | null>(null);

async function send(prompt: string) {
  controller.value = new AbortController();
  // ...
  const response = await fetch('/api/ai/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt }),
    signal: controller.value.signal,
  });
  // ...rest of the logic
}

function abort() {
  controller.value?.abort();
  isStreaming.value = false;
}

return { output, isStreaming, error, send, abort };

Now you can expose a "Stop" button in the UI that calls abort(), and the stream closes cleanly.

Takeaways

  • SSE beats WebSockets for AI streaming because it's simpler and the data only flows one way.
  • A composable makes the streaming logic reusable. You could use useAIStream in a chat page, a search bar, or a writing assistant, with a different UI each time.
  • Nitro as a proxy keeps your API keys on the server and gives you a place to add rate limiting, caching, or prompt validation before hitting the AI.
  • Always let users stop a stream. AbortController makes that easy.

Streaming is becoming the standard for AI-powered interfaces, and once you've built it you'll reuse it a lot.

TL;DR: Use SSE + Vue composables to stream AI responses token by token. It's simpler than WebSockets, feels instant to users, and the composable pattern makes it reusable across your entire app.