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
- Add
ReviewInsightsSchematolib/types.tswithpros,cons,themesarrays using.describe()hints - Create
getReviewInsights(product)inlib/ai-summary.tsusinggenerateObject({ schema: ReviewInsightsSchema }) - Create
components/review-insights.tsxwith two-column pros/cons grid and theme tags, add to product page
Hands-on Exercise 2.5
Extract structured insights from reviews:
Requirements:
- Create a Zod schema for review insights (pros, cons, themes)
- Add a
getReviewInsightsfunction usinggenerateObject - Display pros and cons in a two-column layout
- Show key themes as tags/badges
- Keep the existing summary (don't replace it)
Implementation hints:
generateObjectrequires a Zod schema asschemaparameter- 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:
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:
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
generateObjectto imports (line 1) - Added
ReviewInsightsandReviewInsightsSchemato type imports (line 2) - Added new
getReviewInsightsfunction at the end of the file
Step 3: Create Insights Component
Create 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:
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
-
Visit a product page:
http://localhost:3000/mower -
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
-
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
-
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:
- AI SDK sends your Zod schema to Claude
- Claude generates structured JSON matching the schema
- AI SDK validates the response against your schema
- 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 typeWith 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 immediatelySchema 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 pushDone-When
ReviewInsightsSchemadefined in typesgetReviewInsightsfunction usinggenerateObjectReviewInsightscomponent 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:
Was this helpful?