[SL_CORE]
< CD ..||6 min read

Real-Time AI in Next.js: Streaming and Server Components

# Build real-time AI features in Next.js 15 with the Vercel AI SDK: streaming text responses, Server Actions for LLM calls, and React Server Components for blazing-fast initial loads.

[nextjs][typescript][ai-agents][streaming]

Next.js 15's App Router and the Vercel AI SDK make streaming AI responses surprisingly easy. The key is knowing which rendering model to use: Server Components for static AI-generated content, Server Actions for interactive streaming, and client-side hooks for real-time chat. Here's how to use each correctly.

The Rendering Model Decision

Static AI content (summaries, analysis)
    → React Server Components (RSC)
    → Render at request time, no client JS

Interactive streaming (chat, generation)
    → Server Actions + useActionState
    → Stream from server, update client progressively

Real-time chat with history
    → Vercel AI SDK useChat hook
    → Handles messages state, streaming, retry

Setup: Vercel AI SDK

npm install ai @ai-sdk/anthropic
// lib/ai.ts
import { createAnthropic } from "@ai-sdk/anthropic";

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

export const model = anthropic("claude-sonnet-4-6");

Pattern 1: Streaming Chat with useChat

The most common pattern — a chat interface that streams tokens in real time:

// app/chat/page.tsx
"use client";
import { useChat } from "ai/react";

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: "/api/chat",
  });

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4">
        {messages.map((m) => (
          <div key={m.id} className={`p-4 rounded-lg ${m.role === "user" ? "bg-blue-50 ml-12" : "bg-gray-50 mr-12"}`}>
            <p className="text-sm font-medium mb-1">{m.role === "user" ? "You" : "AI"}</p>
            <p className="whitespace-pre-wrap">{m.content}</p>
          </div>
        ))}
        {isLoading && <div className="animate-pulse">Thinking...</div>}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2 mt-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask anything..."
          className="flex-1 border rounded-lg px-4 py-2"
        />
        <button type="submit" disabled={isLoading} className="px-6 py-2 bg-blue-600 text-white rounded-lg">
          Send
        </button>
      </form>
    </div>
  );
}

The API route:

// app/api/chat/route.ts
import { streamText } from "ai";
import { model } from "@/lib/ai";

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

  const result = streamText({
    model,
    system: "You are a helpful AI assistant for Shahriar Labs.",
    messages,
  });

  return result.toDataStreamResponse();
}

toDataStreamResponse() sets the correct headers and streams tokens using the AI SDK's data stream protocol — useChat understands it natively.

Pattern 2: Server Actions for One-Shot Generation

For single-shot AI tasks (summarize, analyze, classify), Server Actions eliminate the need for an API route:

// app/actions.ts
"use server";
import { generateText, streamText } from "ai";
import { createStreamableValue } from "ai/rsc";
import { model } from "@/lib/ai";

export async function summarizeDocument(text: string) {
  const { text: summary } = await generateText({
    model,
    prompt: `Summarize this document in 3 bullet points:\n\n${text}`,
    maxTokens: 300,
  });
  return summary;
}

// Streaming version
export async function streamSummary(text: string) {
  const stream = createStreamableValue("");

  (async () => {
    const { textStream } = streamText({
      model,
      prompt: `Summarize this document in 3 bullet points:\n\n${text}`,
    });

    for await (const chunk of textStream) {
      stream.update(chunk);
    }
    stream.done();
  })();

  return stream.value;
}
// app/summarize/page.tsx
"use client";
import { useActionState } from "react";
import { readStreamableValue } from "ai/rsc";
import { streamSummary } from "@/app/actions";

export default function SummarizePage() {
  const [summary, setSummary] = React.useState("");

  async function handleSubmit(text: string) {
    setSummary("");
    const stream = await streamSummary(text);
    for await (const chunk of readStreamableValue(stream)) {
      setSummary((prev) => prev + chunk);
    }
  }

  return (/* ... */);
}

Pattern 3: AI Content in React Server Components

For static AI-generated content that doesn't need interactivity:

// app/blog/[slug]/insights/page.tsx
import { generateText } from "ai";
import { model } from "@/lib/ai";
import { getPost } from "@/lib/posts";

// This component runs on the server — no client JS needed
export default async function PostInsights({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  // AI runs at render time, output cached by Next.js
  const { text: insights } = await generateText({
    model,
    prompt: `Extract 3 key technical insights from this article:\n\n${post.content}`,
    maxTokens: 200,
  });

  return (
    <aside className="border-l-2 border-blue-200 pl-4 my-8">
      <h3 className="font-bold mb-2">Key Insights</h3>
      <p className="text-sm text-gray-600 whitespace-pre-wrap">{insights}</p>
    </aside>
  );
}

Cache the AI output with unstable_cache:

import { unstable_cache } from "next/cache";

const getCachedInsights = unstable_cache(
  async (content: string) => {
    const { text } = await generateText({ model, prompt: `Key insights: ${content}`, maxTokens: 200 });
    return text;
  },
  ["post-insights"],
  { revalidate: 86400 } // cache for 24 hours
);

Pattern 4: Structured Generation with Zod

Extract structured data from AI responses using generateObject:

// app/api/extract/route.ts
import { generateObject } from "ai";
import { z } from "zod";
import { model } from "@/lib/ai";

const ArticleSchema = z.object({
  title: z.string(),
  summary: z.string(),
  tags: z.array(z.string()).max(5),
  readingTimeMinutes: z.number(),
  difficulty: z.enum(["beginner", "intermediate", "advanced"]),
});

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

  const { object } = await generateObject({
    model,
    schema: ArticleSchema,
    prompt: `Extract metadata from this article:\n\n${content}`,
  });

  return Response.json(object);
}

The SDK guarantees the response matches the Zod schema — no manual JSON parsing or validation needed.

Handling Errors in Streaming

// app/api/chat/route.ts
import { streamText } from "ai";

export async function POST(req: Request) {
  try {
    const { messages } = await req.json();
    
    const result = streamText({
      model,
      messages,
      onError: (error) => {
        console.error("Stream error:", error);
      },
    });

    return result.toDataStreamResponse({
      getErrorMessage: (error) => {
        if (error instanceof Error) return error.message;
        return "An unexpected error occurred";
      },
    });
  } catch (err) {
    return new Response(
      JSON.stringify({ error: "Failed to process request" }),
      { status: 500, headers: { "Content-Type": "application/json" } }
    );
  }
}

Client-side error handling:

const { messages, error } = useChat({
  api: "/api/chat",
  onError: (error) => console.error("Chat error:", error),
});

if (error) return <div>Error: {error.message}</div>;

Performance Tips

  1. Stream tokens — never use generateText for user-facing responses; always stream. Users see output 2–3× faster.
  2. Cache RSC outputs — AI calls in Server Components are expensive; always wrap with unstable_cache.
  3. Use maxTokens — always set a limit. Unbounded generation blocks the stream and costs money.
  4. Abort on disconnect — the AI SDK handles client disconnects automatically when using toDataStreamResponse.

See TypeScript Patterns for LLM Integration for type-safe tool definitions in Next.js.

FAQ

Should I use Vercel AI SDK or call Anthropic directly? Use the AI SDK for UI-heavy apps — it handles streaming protocol, error recovery, and message state. Call Anthropic directly in backend services where you need full control.

How do I add tool use to useChat? Define tools on the server in the streamText call. The SDK handles the tool-call / tool-result round-trip automatically in the useChat hook.

Can I use App Router streaming with non-Vercel hosting? Yes — the AI SDK's streaming works on any Node.js runtime. Cloudflare Workers require the edge runtime (export const runtime = "edge").

How do I persist chat history? Store messages in a database (Postgres, SQLite) keyed by session ID. Pass stored messages as initialMessages to useChat. The hook appends new messages; save them on onFinish.

What's the difference between streamText and streamObject? streamText streams tokens as they're generated. streamObject streams a partial JSON object as it's built — useful for streaming structured data like lists or forms.