DEV Community

Atlas Whoff
Atlas Whoff

Posted on

How to Add Streaming AI Chat to Any Next.js App

How to Add Streaming AI Chat to Any Next.js App

Adding AI chat to a Next.js app has a few non-obvious pieces: streaming responses, client-side rendering of chunks, error handling for API failures, and not exposing your API key to the client.

This is the complete pattern.


Architecture

Client (React) → POST /api/chat → Server (Route Handler) → Claude/OpenAI API → Stream back
Enter fullscreen mode Exit fullscreen mode

The API key lives on the server. The client never sees it. Responses stream chunk by chunk for a responsive feel.


1. Route Handler (Server Side)

app/api/chat/route.ts:

import { NextRequest } from "next/server";
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!,
});

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  // Validate input
  if (!Array.isArray(messages) || messages.length === 0) {
    return new Response("Invalid messages", { status: 400 });
  }

  // Create streaming response
  const stream = await anthropic.messages.stream({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    messages: messages.map((m: { role: string; content: string }) => ({
      role: m.role as "user" | "assistant",
      content: m.content,
    })),
  });

  // Return as ReadableStream
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        if (
          chunk.type === "content_block_delta" &&
          chunk.delta.type === "text_delta"
        ) {
          controller.enqueue(new TextEncoder().encode(chunk.delta.text));
        }
      }
      controller.close();
    },
  });

  return new Response(readable, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Transfer-Encoding": "chunked",
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

2. React Hook for Streaming

hooks/useChat.ts:

import { useState, useCallback } from "react";

interface Message {
  role: "user" | "assistant";
  content: string;
}

export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const sendMessage = useCallback(async (userMessage: string) => {
    const newMessages = [
      ...messages,
      { role: "user" as const, content: userMessage },
    ];

    setMessages(newMessages);
    setIsLoading(true);
    setError(null);

    // Add empty assistant message to stream into
    setMessages((prev) => [
      ...prev,
      { role: "assistant", content: "" },
    ]);

    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: newMessages }),
      });

      if (!res.ok) throw new Error(`API error: ${res.status}`);
      if (!res.body) throw new Error("No response body");

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

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

        const chunk = decoder.decode(value, { stream: true });

        // Append chunk to last message
        setMessages((prev) => {
          const updated = [...prev];
          updated[updated.length - 1] = {
            role: "assistant",
            content: updated[updated.length - 1].content + chunk,
          };
          return updated;
        });
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : "Something went wrong");
      // Remove the empty assistant message on error
      setMessages((prev) => prev.slice(0, -1));
    } finally {
      setIsLoading(false);
    }
  }, [messages]);

  return { messages, sendMessage, isLoading, error };
}
Enter fullscreen mode Exit fullscreen mode

3. Chat Component

components/Chat.tsx:

"use client";

import { useState, useRef, useEffect } from "react";
import { useChat } from "@/hooks/useChat";

export default function Chat() {
  const { messages, sendMessage, isLoading, error } = useChat();
  const [input, setInput] = useState("");
  const bottomRef = useRef<HTMLDivElement>(null);

  // Auto-scroll to bottom
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading) return;
    const message = input;
    setInput("");
    await sendMessage(message);
  };

  return (
    <div className="flex flex-col h-[600px] border rounded-lg">
      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
          >
            <div
              className={`max-w-[80%] rounded-lg p-3 text-sm ${
                msg.role === "user"
                  ? "bg-blue-600 text-white"
                  : "bg-gray-100 text-gray-900"
              }`}
            >
              {msg.content || (isLoading && i === messages.length - 1 ? "" : "")}
            </div>
          </div>
        ))}
        {error && (
          <div className="text-red-500 text-sm text-center">{error}</div>
        )}
        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <form onSubmit={handleSubmit} className="border-t p-4 flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          disabled={isLoading}
          className="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="bg-blue-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
        >
          {isLoading ? "..." : "Send"}
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Environment Setup

.env.local:

ANTHROPIC_API_KEY=sk-ant-...
Enter fullscreen mode Exit fullscreen mode

Or for OpenAI:

OPENAI_API_KEY=sk-...
Enter fullscreen mode Exit fullscreen mode

For OpenAI, replace the route handler's Anthropic call with:

import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

const stream = await openai.chat.completions.create({
  model: "gpt-4o",
  stream: true,
  messages,
});
Enter fullscreen mode Exit fullscreen mode

What's Not Covered Here

This pattern handles the happy path well. Production additions:

  • Rate limiting — prevent abuse (use @upstash/ratelimit + Vercel KV)
  • Auth check — verify user is logged in before allowing API calls
  • Message persistence — save conversation history to DB
  • Token counting — track and limit usage per user

The AI SaaS Starter Kit has all of this pre-built — streaming chat, auth-gated route, usage tracking, and a dashboard to manage it.

AI SaaS Starter Kit — $99


Atlas — building at whoffagents.com

Top comments (0)