Implement real-time streaming AI responses for a better user experience.

Overview

This tutorial shows you how to:

  • Set up Server-Sent Events (SSE) streaming
  • Display streaming responses in real-time
  • Handle streaming errors
  • Show typing indicators

Why Streaming?

Streaming provides:

  • Instant feedback - Users see responses immediately
  • Better UX - Feels faster and more responsive
  • Progressive loading - Can show partial results

Step 1: Update Your Chat Component

Modify your chat component to use streaming:

"use client";

import { useState } from "react";

export default function StreamingChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [streamingContent, setStreamingContent] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);

  const handleStream = async () => {
    if (!input.trim() || isStreaming) return;

    const userMessage: Message = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setIsStreaming(true);
    setStreamingContent("");

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

      if (!response.ok) {
        throw new Error("Stream failed");
      }

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

      if (!reader) {
        throw new Error("No reader available");
      }

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

        const chunk = decoder.decode(value);
        const lines = chunk.split("\n");

        for (const line of lines) {
          if (line.startsWith("data: ")) {
            try {
              const data = JSON.parse(line.slice(6));
              
              if (data.done) {
                // Stream complete
                setMessages((prev) => [
                  ...prev,
                  { role: "assistant", content: streamingContent },
                ]);
                setStreamingContent("");
                setIsStreaming(false);
              } else {
                // Append chunk
                setStreamingContent((prev) => prev + data.content);
              }
            } catch (e) {
              // Invalid JSON, skip
            }
          }
        }
      }
    } catch (error) {
      console.error("Stream error:", error);
      setIsStreaming(false);
      setStreamingContent("");
    }
  };

  return (
    <div className="min-h-screen bg-base-200 p-4">
      <div className="max-w-4xl mx-auto">
        {/* Messages */}
        <div className="bg-base-100 rounded-lg p-4 mb-4 min-h-[400px] max-h-[600px] overflow-y-auto">
          {messages.map((msg, idx) => (
            <div
              key={idx}
              className={`flex ${
                msg.role === "user" ? "justify-end" : "justify-start"
              } mb-4`}
            >
              <div
                className={`max-w-[80%] rounded-lg p-3 ${
                  msg.role === "user"
                    ? "bg-primary text-primary-content"
                    : "bg-base-300 text-base-content"
                }`}
              >
                <p className="whitespace-pre-wrap">{msg.content}</p>
              </div>
            </div>
          ))}

          {/* Streaming content */}
          {isStreaming && streamingContent && (
            <div className="flex justify-start mb-4">
              <div className="bg-base-300 rounded-lg p-3 max-w-[80%]">
                <p className="whitespace-pre-wrap">{streamingContent}</p>
                <span className="inline-block w-2 h-4 bg-base-content ml-1 animate-pulse">
                  |
                </span>
              </div>
            </div>
          )}

          {/* Typing indicator */}
          {isStreaming && !streamingContent && (
            <div className="flex justify-start mb-4">
              <div className="bg-base-300 rounded-lg p-3">
                <span className="loading loading-dots"></span>
              </div>
            </div>
          )}
        </div>

        {/* Input */}
        <div className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyPress={(e) => e.key === "Enter" && handleStream()}
            placeholder="Type your message..."
            className="flex-1 input input-bordered"
            disabled={isStreaming}
          />
          <button
            onClick={handleStream}
            disabled={isStreaming || !input.trim()}
            className="btn btn-primary"
          >
            {isStreaming ? "Streaming..." : "Send"}
          </button>
        </div>
      </div>
    </div>
  );
}

Step 2: Handle SSE Format

The streaming endpoint sends data in SSE format:

data: {"content":"Hello","done":false} data: {"content":" world","done":false} data: {"content":"","done":true}

Parse each line:

for (const line of lines) {
  if (line.startsWith("data: ")) {
    const data = JSON.parse(line.slice(6));
    // Process data.content and data.done
  }
}

Step 3: Add Error Handling

Handle streaming errors gracefully:

try {
  const response = await fetch("/api/ai/stream", { ... });
  
  if (!response.ok) {
    if (response.status === 403) {
      alert("Subscription required. Please upgrade your plan.");
    } else if (response.status === 400) {
      alert("Invalid request");
    } else {
      alert("Stream failed");
    }
    setIsStreaming(false);
    return;
  }
  
  // Continue with streaming...
} catch (error) {
  console.error("Stream error:", error);
  setIsStreaming(false);
  alert("Connection error. Please try again.");
}

Step 4: Add Cancellation

Allow users to cancel in-progress streams:

const [abortController, setAbortController] = useState<AbortController | null>(null);

const handleStream = async () => {
  const controller = new AbortController();
  setAbortController(controller);
  
  try {
    const response = await fetch("/api/ai/stream", {
      signal: controller.signal,
      // ... other options
    });
    // ... rest of streaming logic
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("Stream cancelled");
    }
  }
};

const handleCancel = () => {
  if (abortController) {
    abortController.abort();
    setIsStreaming(false);
    setStreamingContent("");
  }
};

// Add cancel button
{isStreaming && (
  <button onClick={handleCancel} className="btn btn-error">
    Cancel
  </button>
)}

Step 5: Optimize Performance

Debounce Rapid Updates

import { useMemo } from "react";

// Only update UI every 100ms
const debouncedContent = useMemo(() => {
  return streamingContent;
}, [streamingContent]);

Auto-scroll to Bottom

import { useEffect, useRef } from "react";

const messagesEndRef = useRef<HTMLDivElement>(null);

const scrollToBottom = () => {
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};

useEffect(() => {
  scrollToBottom();
}, [messages, streamingContent]);

Best Practices

  1. Show typing indicator while waiting for first chunk
  2. Handle connection errors gracefully
  3. Allow cancellation of long streams
  4. Auto-scroll to show latest content
  5. Debounce updates for performance
  6. Show progress for very long responses

Common Issues

Stream Stops Mid-Response

Solution: Check for network timeouts and handle reconnection

Chunks Arrive Out of Order

Solution: The SSE format ensures order, but verify your parsing logic

Memory Issues with Long Streams

Solution: Limit message history and clear old messages

Next Steps