Vercel Logo

Streaming summaries

Is anyone in physical pain right now from watching the routes change? If the slowness is killing you, let's fix it with streaming.

Outcome

Replace generateText with streamText to stream AI summaries in real-time, showing users content as it's generated.

Fast Track

  1. Update lib/ai-summary.ts: change generateText to streamText, return result.textStream
  2. Convert AIReviewSummary to Client Component with "use client", use useEffect to consume the stream
  3. Test at /mower—summary text appears word-by-word instead of all at once

Hands-on Exercise 2.4

Add streaming to the AI summary feature:

Requirements:

  1. Change summarizeReviews to return a stream instead of a string
  2. Create a new async function that returns the stream object
  3. Update AIReviewSummary to consume the stream with React state
  4. Show a loading indicator while waiting for first chunk
  5. Display text as it streams in

Implementation hints:

  • streamText returns { textStream } which is an async iterable
  • Client Components can use useState and useEffect to handle streams
  • Use Server Actions to call the streaming function from Client Components
  • Consider showing "Generating summary..." before first chunk arrives

Understanding streamText

The AI SDK provides streamText for streaming responses:

import { streamText } from "ai";
 
const result = streamText({
  model: "anthropic/claude-sonnet-4-5",
  prompt: "Your instructions here",
});
 
// result.textStream is an async iterable
for await (const chunk of result.textStream) {
  console.log(chunk); // Each chunk as it arrives
}

Key differences from generateText:

  • Returns immediately (doesn't wait for full response)
  • textStream yields chunks as they're generated
  • Better UX for longer responses

Step 1: Create Streaming Function

Update lib/ai-summary.ts to add a streaming version:

lib/ai-summary.ts
import { generateText, streamText } from "ai";
import { Product } from "./types";
 
// Keep the existing summarizeReviews function for now
// Add this new streaming function
 
export async function streamReviewSummary(product: Product) {
  const averageRating =
    product.reviews.reduce((acc, review) => acc + review.stars, 0) /
    product.reviews.length;
 
  const prompt = `Write a summary of the reviews for the ${
    product.name
  } product. The product's average rating is ${averageRating} out of 5 stars.
 
Your goal is to highlight the most common themes and sentiments expressed by customers.
If multiple themes are present, try to capture the most important ones.
If no patterns emerge but there is a shared sentiment, capture that instead.
Try to use natural language and keep the summary concise.
Use a maximum of 4 sentences and 30 words.
Don't include any word count or character count.
No need to reference which reviews you're summarizing.
Do not reference the star rating in the summary.
 
Start the summary with "Customers like…" or "Customers mention…"
 
Here are 3 examples of good summaries:
Example 1: Customers like the quality, space, fit and value of the sport equipment bag case. They mention it's heavy duty, has lots of space and pockets, and can fit all their gear. They also appreciate the portability and appearance. That said, some disagree on the zipper.
Example 2: Customers like the quality, ease of installation, and value of the transport rack. They mention that it holds on to everything really well, and is reliable. Some complain about the wind noise, saying it makes a whistling noise at high speeds. Opinions are mixed on fit, and performance.
Example 3: Customers like the quality and value of the insulated water bottle. They say it keeps drinks cold for hours and the lid seals well. Some customers have different opinions on size and durability.
 
Hit the following tone based on rating:
- 1-2 stars: negative
- 3 stars: neutral
- 4-5 stars: positive
 
The customer reviews to summarize are as follows:
${product.reviews
    .map((review, i) => `Review ${i + 1}:\n${review.review}`)
    .join("\n\n")}`;
 
  const result = streamText({
    model: "anthropic/claude-sonnet-4-5",
    prompt,
    maxTokens: 1000,
    temperature: 0.75,
  });
 
  return result;
}

What changed:

  • Added streamText import
  • New streamReviewSummary function returns the stream result directly
  • Same prompt as the engineered version from 2.3

Step 2: Create Server Action

Create app/actions/stream-summary.ts:

app/actions/stream-summary.ts
"use server";
 
import { streamReviewSummary } from "@/lib/ai-summary";
import { getProduct } from "@/lib/sample-data";
 
export async function getStreamingSummary(productSlug: string) {
  const product = getProduct(productSlug);
  const result = await streamReviewSummary(product);
  return result.toTextStreamResponse();
}

This Server Action wraps the streaming function and returns a response that can be consumed by the client.

Step 3: Create Streaming Component

Create components/streaming-summary.tsx:

components/streaming-summary.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { FiveStarRating } from "./five-star-rating";
import { Product } from "@/lib/types";
 
export function StreamingSummary({ product }: { product: Product }) {
  const [summary, setSummary] = useState("");
  const [isLoading, setIsLoading] = useState(true);
 
  const averageRating =
    product.reviews.reduce((acc, review) => acc + review.stars, 0) /
    product.reviews.length;
 
  useEffect(() => {
    async function fetchStream() {
      setIsLoading(true);
      setSummary("");
 
      try {
        const response = await fetch(`/api/summary/${product.slug}`);
 
        if (!response.ok) {
          throw new Error("Failed to fetch summary");
        }
 
        const reader = response.body?.getReader();
        const decoder = new TextDecoder();
 
        if (!reader) {
          throw new Error("No reader available");
        }
 
        setIsLoading(false);
 
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
 
          const chunk = decoder.decode(value, { stream: true });
          setSummary((prev) => prev + chunk);
        }
      } catch (error) {
        console.error("Stream error:", error);
        setSummary("Unable to generate summary. Please try again.");
        setIsLoading(false);
      }
    }
 
    fetchStream();
  }, [product.slug]);
 
  return (
    <Card className="w-full max-w-prose p-10 grid gap-10">
      <CardHeader className="items-center space-y-0 gap-4 p-0">
        <div className="grid gap-1 text-center">
          <CardTitle className="text-lg">AI Summary</CardTitle>
          <p className="text-xs text-muted-foreground">
            Based on {product.reviews.length} customer ratings
          </p>
        </div>
        <div className="bg-gray-100 px-3 rounded-full flex items-center py-2 dark:bg-gray-800">
          <FiveStarRating rating={Math.round(averageRating)} />
          <span className="text-sm ml-4 text-gray-500 dark:text-gray-400">
            {averageRating.toFixed(1)} out of 5
          </span>
        </div>
      </CardHeader>
      <CardContent className="p-0 grid gap-4">
        <p className="text-sm leading-loose text-gray-500 dark:text-gray-400 min-h-[4rem]">
          {isLoading ? (
            <span className="animate-pulse">Generating summary...</span>
          ) : (
            summary
          )}
        </p>
      </CardContent>
    </Card>
  );
}

Key features:

  • "use client" directive for React hooks
  • useState tracks the streaming text and loading state
  • useEffect fetches and consumes the stream
  • Shows "Generating summary..." while waiting for first chunk
  • Appends each chunk as it arrives

Step 4: Create API Route for Streaming

Create app/api/summary/[slug]/route.ts:

app/api/summary/[slug]/route.ts
import { streamReviewSummary } from "@/lib/ai-summary";
import { getProduct } from "@/lib/sample-data";
 
export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
 
  let product;
  try {
    product = getProduct(slug);
  } catch {
    return new Response("Product not found", { status: 404 });
  }
 
  const result = await streamReviewSummary(product);
 
  return result.toTextStreamResponse();
}

What this does:

  • Creates a streaming endpoint at /api/summary/[slug]
  • Calls the streaming function and returns a text stream response
  • The client reads this stream chunk by chunk

Step 5: Update Product Page

Update app/[productId]/page.tsx to use the streaming component:

app/[productId]/page.tsx
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getProduct, getProducts } from "@/lib/sample-data";
import { Reviews } from "@/components/reviews";
import { StreamingSummary } from "@/components/streaming-summary";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ productId: string }>;
}) {
  const { productId } = await params;
 
  let product;
  try {
    product = getProduct(productId);
  } catch {
    notFound();
  }
 
  return (
    <main className="min-h-screen p-8">
      <div className="max-w-4xl mx-auto space-y-8">
        <div>
          <h1 className="text-4xl font-bold">{product.name}</h1>
          <p className="text-lg text-muted-foreground mt-2">
            {product.description}
          </p>
        </div>
 
        <StreamingSummary product={product} />
 
        <Reviews product={product} />
      </div>
    </main>
  );
}
 
// ... (generateStaticParams and generateMetadata remain the same)

Try It

  1. Run your dev server:

    pnpm dev
  2. Visit a product page:

    http://localhost:3000/mower
    
  3. Watch the summary stream in:

    • "Generating summary..." appears first
    • Text starts appearing word-by-word
    • Summary completes in 2-3 seconds
    • Much better UX than waiting for full response!
  4. Compare the experience:

    • Before (blocking): Blank card → wait 2-3s → full text appears
    • After (streaming): Loading text → immediate first word → text flows in
  5. Test different products:

    • /ecoBright - Watch it stream
    • /aquaHeat - Each product streams independently

How Streaming Works

Request flow:

1. Page loads → StreamingSummary component mounts
2. useEffect triggers → fetches /api/summary/mower
3. API route calls streamReviewSummary(product)
4. streamText sends request to Claude
5. Claude generates tokens one at a time
6. Each token streams back through:
   Claude → AI Gateway → Your API → Client
7. Client appends each chunk to state
8. React re-renders with new text
9. User sees words appear progressively

Why it feels faster:

  • Time to first byte: ~200ms (instead of waiting 2-3s)
  • User sees progress immediately
  • Perceived performance is much better
  • Same total generation time, better UX

Streaming vs Blocking Comparison

AspectgenerateText (Blocking)streamText (Streaming)
Time to first content2-3 seconds~200ms
Total time2-3 seconds2-3 seconds
User experienceWait, then see allSee progress immediately
ImplementationSimplerSlightly more complex
Best forShort responses, cachingLonger responses, real-time UX

When to Use Streaming

Use streaming when:

  • Response takes >1 second to generate
  • User is waiting and watching
  • Content is being read (summaries, explanations)
  • You want engaging, dynamic UX

Use blocking when:

  • Response is very short
  • Result will be cached
  • Processing happens in background
  • Structured output (generateObject doesn't stream content)

Commit

git add lib/ai-summary.ts app/api/summary/\[slug\]/route.ts components/streaming-summary.tsx app/\[productId\]/page.tsx
git commit -m "feat(ai): add streaming summaries with streamText"
git push

Done-When

  • streamReviewSummary function created using streamText
  • API route /api/summary/[slug] returns streaming response
  • StreamingSummary Client Component consumes the stream
  • Loading state shows "Generating summary..."
  • Text appears word-by-word as it streams
  • Product page uses streaming component
  • Verified streaming works on all product pages

What's Next

Streaming gives users immediate feedback for text summaries. In the next lesson, you'll use generateObject to extract structured data—pros, cons, and themes—with full type safety using Zod schemas. Note: generateObject returns complete objects, not streams, since partial structured data isn't useful.


Sources: