Vercel Logo

Structured output

Text summaries are great, but structured data opens new possibilities. Extract specific insights—pros, cons, key themes—in a format you can filter, sort, and display in creative ways. The AI SDK's generateObject with Zod schemas makes this type-safe and reliable.

Outcome

Use generateObject to extract structured insights (pros, cons, themes) from reviews with full type safety using Zod schemas.

Fast Track

  1. Add ReviewInsightsSchema to lib/types.ts with pros, cons, themes arrays using .describe() hints
  2. Create getReviewInsights(product) in lib/ai-summary.ts using generateObject({ schema: ReviewInsightsSchema })
  3. Create components/review-insights.tsx with two-column pros/cons grid and theme tags, add to product page

Hands-on Exercise 2.5

Extract structured insights from reviews:

Requirements:

  1. Create a Zod schema for review insights (pros, cons, themes)
  2. Add a getReviewInsights function using generateObject
  3. Display pros and cons in a two-column layout
  4. Show key themes as tags/badges
  5. Keep the existing summary (don't replace it)

Implementation hints:

  • generateObject requires a Zod schema as schema parameter
  • The function returns typed data matching your schema
  • Use arrays for pros/cons/themes (3-5 items each)
  • Display insights in a Card below the AI summary
  • Consider using a grid layout for pros/cons columns

Understanding generateObject

The AI SDK provides generateObject for structured data extraction:

import { generateObject } from "ai";
import { z } from "zod";
 
const schema = z.object({
  pros: z.array(z.string()),
  cons: z.array(z.string()),
});
 
const { object } = await generateObject({
  model: "anthropic/claude-sonnet-4.5",
  schema,
  prompt: "Extract pros and cons from these reviews...",
});
 
// object is fully typed: { pros: string[], cons: string[] }

Benefits:

  • Type-safe output (TypeScript knows the structure)
  • Automatic validation (Zod ensures correct format)
  • Structured data (easy to filter, sort, display)

Step 1: Define Insights Schema

Add to lib/types.ts:

lib/types.ts
import { z } from "zod";
 
// Review schema
export const ReviewSchema = z.object({
  reviewer: z.string(),
  stars: z.number().min(1).max(5),
  review: z.string(),
  date: z.string(),
});
 
// Product schema
export const ProductSchema = z.object({
  slug: z.string(),
  name: z.string(),
  description: z.string(),
  reviews: z.array(ReviewSchema),
});
 
// Infer TypeScript types
export type Review = z.infer<typeof ReviewSchema>;
export type Product = z.infer<typeof ProductSchema>;
 
// Review insights schema
export const ReviewInsightsSchema = z.object({
  pros: z.array(z.string()).describe("Positive aspects mentioned in reviews"),
  cons: z.array(z.string()).describe("Negative aspects or concerns"),
  themes: z.array(z.string()).describe("Key themes across all reviews"),
});
 
export type ReviewInsights = z.infer<typeof ReviewInsightsSchema>;

Step 2: Create Insights Function

Add generateObject to your imports and the getReviewInsights function to lib/ai-summary.ts:

lib/ai-summary.ts
import { generateText, generateObject, streamText } from "ai";
import { Product, ReviewInsights, ReviewInsightsSchema } from "./types";
 
function buildSummaryPrompt(product: Product): string {
  // ... (existing prompt helper from 2.4)
}
 
export function streamReviewSummary(product: Product) {
  // ... (existing streaming function from 2.4)
}
 
export async function summarizeReviews(product: Product): Promise<string> {
  // ... (existing blocking function from 2.3)
}
 
export async function getReviewInsights(
  product: Product
): Promise<ReviewInsights> {
  const averageRating =
    product.reviews.reduce((acc, review) => acc + review.stars, 0) /
    product.reviews.length;
 
  const prompt = `Analyze the following customer reviews for the ${product.name} product (average rating: ${averageRating}/5).
 
Extract:
1. Pros: 3-5 positive aspects customers appreciate
2. Cons: 3-5 negative aspects or concerns mentioned
3. Themes: 3-5 key themes that emerge across reviews
 
Be specific and concise. Each item should be 3-7 words.
 
Reviews:
${product.reviews
    .map((review, i) => `Review ${i + 1} (${review.stars} stars):\n${review.review}`)
    .join("\n\n")}`;
 
  try {
    const { object } = await generateObject({
      model: "anthropic/claude-sonnet-4.5",
      schema: ReviewInsightsSchema,
      prompt,
    });
 
    return object;
  } catch (error) {
    console.error("Failed to extract insights:", error);
    throw new Error("Unable to extract review insights. Please try again.");
  }
}

What changed:

  • Added generateObject to imports (line 1)
  • Added ReviewInsights and ReviewInsightsSchema to type imports (line 2)
  • Added new getReviewInsights function at the end of the file

Step 3: Create Insights Component

Create components/review-insights.tsx:

components/review-insights.tsx
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Product } from "@/lib/types";
import { getReviewInsights } from "@/lib/ai-summary";
 
export async function ReviewInsights({ product }: { product: Product }) {
  const insights = await getReviewInsights(product);
 
  return (
    <Card className="w-full max-w-prose">
      <CardHeader>
        <CardTitle className="text-lg">Key Insights</CardTitle>
      </CardHeader>
      <CardContent className="space-y-6">
        {/* Pros and Cons Grid */}
        <div className="grid md:grid-cols-2 gap-6">
          {/* Pros */}
          <div>
            <h3 className="text-sm font-semibold mb-3 text-green-700 dark:text-green-400">
              Pros
            </h3>
            <ul className="space-y-2">
              {insights.pros.map((pro, i) => (
                <li key={i} className="text-sm flex items-start gap-2">
                  <span className="text-green-600 mt-0.5">✓</span>
                  <span className="text-muted-foreground">{pro}</span>
                </li>
              ))}
            </ul>
          </div>
 
          {/* Cons */}
          <div>
            <h3 className="text-sm font-semibold mb-3 text-red-700 dark:text-red-400">
              Cons
            </h3>
            <ul className="space-y-2">
              {insights.cons.map((con, i) => (
                <li key={i} className="text-sm flex items-start gap-2">
                  <span className="text-red-600 mt-0.5">✗</span>
                  <span className="text-muted-foreground">{con}</span>
                </li>
              ))}
            </ul>
          </div>
        </div>
 
        {/* Themes */}
        <div>
          <h3 className="text-sm font-semibold mb-3">Key Themes</h3>
          <div className="flex flex-wrap gap-2">
            {insights.themes.map((theme, i) => (
              <span
                key={i}
                className="px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full text-xs"
              >
                {theme}
              </span>
            ))}
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Step 4: Add to Product Page

Update app/[productId]/page.tsx:

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";
import { ReviewInsights } from "@/components/review-insights";
 
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} />
        <ReviewInsights product={product} />
 
        <Reviews product={product} />
      </div>
    </main>
  );
}
 
// ... (generateStaticParams and generateMetadata remain the same)

Try It

  1. Visit a product page:

    http://localhost:3000/mower
    
  2. You should see:

    • AI Summary card (existing)
    • New: Key Insights card with:
      • Pros column (green checkmarks)
      • Cons column (red X marks)
      • Theme tags at the bottom
  3. Example output for Mower3000:

    Pros:

    • ✓ Quiet operation
    • ✓ Autonomous cutting
    • ✓ Good app integration
    • ✓ Quality mulching

    Cons:

    • ✗ Struggles on slopes
    • ✗ Boundary wire setup difficult
    • ✗ Gets stuck occasionally
    • ✗ Limited customer support

    Themes:

    • Autonomous Operation | Slope Challenges | Setup Complexity | Quiet Performance
  4. Check AI Gateway dashboard:

    • Now making 2 API calls per product page
    • One for summary (generateText)
    • One for insights (generateObject)
    • Combined cost: ~$0.004 per page load

How generateObject Works

Request:

generateObject({
  schema: ReviewInsightsSchema,
  prompt: "Extract pros, cons, themes...",
})

Behind the scenes:

  1. AI SDK sends your Zod schema to Claude
  2. Claude generates structured JSON matching the schema
  3. AI SDK validates the response against your schema
  4. Returns typed object (TypeScript knows the structure)

Response:

{
  pros: ["Quiet operation", "Autonomous cutting", ...],
  cons: ["Struggles on slopes", "Setup difficult", ...],
  themes: ["Autonomous Operation", "Slope Challenges", ...]
}

Fully typed. TypeScript autocomplete works. Runtime validation ensures correctness.

Type Safety Benefits

Without Zod:

const data: any = await callAI(); // Hope it has the right shape
const pros = data.pros; // Maybe? Could be undefined or wrong type

With Zod and generateObject:

const { object } = await generateObject({
  schema: ReviewInsightsSchema,
  // ...
});
 
// TypeScript knows:
object.pros;    // string[]
object.cons;    // string[]
object.themes;  // string[]
 
// Runtime: Zod validates before returning
// If AI returns wrong shape, error is caught immediately

Schema Descriptions

Notice the .describe() calls:

pros: z.array(z.string()).describe("Positive aspects mentioned in reviews")

These descriptions are sent to the AI to guide extraction. More descriptive schemas = better results.

Performance Note

Current behavior:

  • 2 API calls per page load (summary + insights)
  • ~4 seconds total generation time
  • ~$0.004 per page load

Coming in Section 3:

  • Smart caching reduces this to 1-time cost
  • Subsequent loads: instant (cached)
  • 97% cost reduction

Commit

git add lib/types.ts lib/ai-summary.ts components/review-insights.tsx app/\[productId\]/page.tsx
git commit -m "feat(ai): add structured output with generateObject"
git push

Done-When

  • ReviewInsightsSchema defined in types
  • getReviewInsights function using generateObject
  • ReviewInsights component displays pros/cons/themes
  • Insights appear on all product pages
  • Data is fully type-safe
  • Pros/cons displayed in two-column grid
  • Themes shown as tags

What's Next

You now have both text summaries and structured insights. But every page load costs tokens. In Section 3, you'll add Next.js 16 smart caching to generate once and reuse, reducing costs by 97% while maintaining great UX.


Sources: