Streaming Frontend with Next.js

Reading the Stream in React

EventSource only supports GET, so for a POST chat request read the response body stream with fetch and split it into SSE events. A minimal client component for a Next.js app (the same code works in plain React):

'use client';
import { useState } from 'react';

type Msg = { role: 'user' | 'assistant'; text: string };

export default function Chat() {
  const [messages, setMessages] = useState<Msg[]>([]);
  const [input, setInput] = useState('');
  const [busy, setBusy] = useState(false);

  async function send() {
    const text = input.trim();
    if (!text || busy) return;
    setInput('');
    setBusy(true);
    setMessages((m) => [...m, { role: 'user', text }, { role: 'assistant', text: '' }]);

    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ conversation_id: 'c-77', message: text }),
    });
    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const events = buffer.split('\n\n');
      buffer = events.pop() ?? '';                 // keep an incomplete event for the next chunk
      for (const e of events) {
        if (!e.startsWith('data: ')) continue;
        const data = JSON.parse(e.slice(6));
        if (data.type === 'text' || data.type === 'error') {
          const piece = data.type === 'text' ? data.text : data.message;
          setMessages((m) => {
            const copy = [...m];
            copy[copy.length - 1] = { role: 'assistant', text: copy[copy.length - 1].text + piece };
            return copy;
          });
        }
      }
    }
    setBusy(false);
  }

  return (
    <div className="chat">
      {messages.map((m, i) => (
        <p key={i} className={m.role}>{m.text}</p>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)}
             onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="Ask ShopEase..." />
      <button onClick={send} disabled={busy}>Send</button>
    </div>
  );
}

Wiring and UX Tips

  • In development, proxy /api/chat to FastAPI with Next.js rewrites in next.config.js, or call the backend URL directly with CORS enabled.
  • Never call the LLM API from the browser: the API key would be visible to everyone.
  • Show a typing indicator until the first event arrives, disable Send while streaming, and render Markdown only after sanitizing it.
  • Alternative: write the route in Next.js itself (a Route Handler using @anthropic-ai/sdk) if your backend is Node.