How to extract LLM responses as type-safe objects: Between Vercel AI SDK's `streamText` and `generateObject`
The first time you wire an LLM into a backend, most people walk the same path. You call the OpenAI API directly with fetch, parse the response with JSON.parse, and then one day the model spits out a JSON structure you didn't expect and you get a runtime error. I've been there. At first I bolded "you MUST reply in this exact JSON format" three times in the prompt. When the model still wrapped the output in ```json ... ```, I stripped the code fence with a regex. When a field went missing, I papered over it with optional chaining. Then the day a required field came back as null and blew up the entire downstream logic, I thought "this isn't working" and switched to the Vercel AI SDK.
The core value of AI SDK 4.x is not simplicity. The real value is the abstraction that lets you swap any LLM provider with a single model parameter, and the structural type safety that handles schema delivery + runtime validation + TypeScript type inference all at once from a single Zod schema. That difference becomes stark especially in pipelines where LLM responses are handed directly to downstream logic or a database.
In this post I'll walk through two functions — streamText and generateObject — covering when to choose each one, what Zod schemas actually do under the hood, and the pitfalls that documentation alone doesn't make obvious, all with real code.
A quick map of the four functions
The SDK core has four generation functions. They look similar at first glance, but the selection criteria fall cleanly along two axes: whether you need streaming and whether you need structured output.
generateText: Non-streaming text generation. For classification or batch processing where the UX can tolerate waiting.streamText: Sends text in real time, chunk by chunk. For chat and similar cases where response latency directly affects UX.generateObject: Returns a fully structured object based on a Zod schema in a single call. For when you need to pass type-safe data to downstream logic.streamObject: The streaming version ofgenerateObject. For UIs where fields are filled in progressively.
Start with installation.
npm install ai @ai-sdk/openai @ai-sdk/anthropic zodWhat a Zod schema actually does
Honestly, I used to think of Zod as "just a validation library," but in the AI SDK it handles three things at once.
import { z } from 'zod';
const LeadSchema = z.object({
name: z.string().describe('Customer name'),
email: z.string().email().describe('Email address'),
company: z.string().optional().describe('Company name'),
intent: z.enum(['purchase', 'demo', 'inquiry']).describe('Inquiry intent'),
urgency: z.number().min(1).max(10).describe('Urgency score 1-10'),
});
type Lead = z.infer<typeof LeadSchema>;This single schema:
- Is converted into a schema definition passed to the model and included in the request (handled internally by the SDK)
- Runs runtime validation when the response arrives
- Automatically infers a TypeScript type via
z.infer<typeof LeadSchema>
This is why .describe() matters. The model reads the schema's descriptions to understand what each field means, so the better those descriptions are written, the higher the quality of the structured output.
Extracting structured data with generateObject
Basic usage pattern
An example pipeline that pulls structured data out of unstructured text.
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const LeadSchema = z.object({
name: z.string().describe('Customer name'),
email: z.string().email().describe('Email address'),
company: z.string().optional().describe('Company name'),
intent: z.enum(['purchase', 'demo', 'inquiry']).describe('Inquiry intent'),
urgency: z.number().min(1).max(10).describe('Urgency score 1-10'),
});
async function extractLead(rawInput: string) {
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: LeadSchema,
prompt: `Extract lead information from the following inquiry:\n\n${rawInput}`,
});
console.log(object.intent);
console.log(object.urgency);
return object;
}object is already narrowed to the Lead type, so autocomplete works as expected. If schema validation fails the function throws, so wrapping with try/catch to explicitly separate the failure path is the safer approach.
The internal behavior of generateObject differs by provider. The OpenAI provider uses Structured Outputs (JSON Schema enforcement) for recent models, while the Anthropic provider uses tool-use-based extraction. The code looks identical on the surface, but the internal mechanisms differ — more on that tradeoff later.
Swapping models is a single parameter
import { anthropic } from '@ai-sdk/anthropic';
const { object } = await generateObject({
model: anthropic('claude-opus-5'),
schema: LeadSchema,
prompt: `...`,
});Building a chat interface with streamText
The standard pattern for using streamText in a Next.js App Router Route Handler. Compatible with Edge Runtime. The onError callback is just one of the options, so it's included inline with the basic example rather than in a separate section.
// app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
system: 'You are a friendly customer support agent.',
onError: ({ error }) => {
console.error('Streaming error:', error);
},
});
return result.toDataStreamResponse();
}On the client, the useChat hook consumes this stream.
// app/chat/page.tsx
'use client';
import { useChat } from 'ai/react';
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: '/api/chat',
});
return (
<div>
{messages.map(m => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit">Send</button>
</form>
</div>
);
}Building an AI-filled UI with streamObject
A pattern where structured data arrives via streaming and fills UI fields one by one.
// Server side (API Route)
import { streamObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const ProductAnalysisSchema = z.object({
summary: z.string().describe('Product summary'),
strengths: z.array(z.string()).describe('List of strengths'),
weaknesses: z.array(z.string()).describe('List of weaknesses'),
targetAudience: z.string().describe('Target audience'),
pricePoint: z.enum(['budget', 'mid-range', 'premium']).describe('Price tier'),
});
export async function POST(req: Request) {
const { productDescription } = await req.json();
const result = streamObject({
model: openai('gpt-4o'),
schema: ProductAnalysisSchema,
prompt: `Analyze the following product:\n\n${productDescription}`,
});
return result.toTextStreamResponse();
}On the client, the useObject hook renders partial data in real time. useObject parses the JSON text stream emitted by toTextStreamResponse() and assembles it into a partial object. It's safest to follow the documentation examples so that the server response format and the client hook are properly paired.
'use client';
import { experimental_useObject as useObject } from 'ai/react';
import { z } from 'zod';
const ProductAnalysisSchema = z.object({
summary: z.string(),
strengths: z.array(z.string()),
weaknesses: z.array(z.string()),
targetAudience: z.string(),
pricePoint: z.enum(['budget', 'mid-range', 'premium']),
});
export default function ProductAnalysis() {
const { object, submit, isLoading } = useObject({
api: '/api/analyze',
schema: ProductAnalysisSchema,
});
return (
<div>
<button onClick={() => submit({ productDescription: '...' })}>
Start Analysis
</button>
{object?.summary && <p>{object.summary}</p>}
{object?.strengths?.map((s, i) => <li key={i}>{s}</li>)}
{object?.pricePoint && <span>Price tier: {object.pricePoint}</span>}
</div>
);
}How arrays flow in streamObject
In streamObject's default mode, object is exposed as Partial<T>. String fields grow token by token as they arrive, and array fields also reflect intermediate states as elements are being filled in. In practice it's hard to tell whether the last element in an array is complete, so it's common to add a defensive pattern: show the last element as a skeleton, or only render elements that are confirmed complete.
If you want to process each array element as it completes, streamObject also has an output: 'array' mode. In this mode each element is delivered via elementStream once it passes schema validation. Exact signatures may vary by SDK version, so check the docs for the version you're using.
Using streamText and structured output together (experimental)
Sometimes you want to stream text while simultaneously extracting structured data — for example, streaming a chat response while separately receiving a sentiment analysis result. The AI SDK provides an experimental option for this. Keep in mind that APIs with an experimental_ prefix may have their signatures changed even in minor releases, and the code below is a conceptual example. Verify the actual option names, import paths, and consumption patterns against the SDK version you're using.
// Conceptual example — actual API names may differ by version
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const SentimentSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
confidence: z.number().min(0).max(1),
});
// Illustrating the idea of getting both text and an object using an experimental structured output option
const result = streamText({
model: openai('gpt-4o'),
prompt: 'I just started a new project today and I am really excited!',
// experimental_output: ... (check the official docs for the exact form per SDK version)
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}When using experimental APIs in production, it's safer to pin the exact patch version of the ai package in your lock file.
Multi-step tool calling pattern
Combining tool definitions with streamText lets you build structures where an agent chains calls to external APIs or databases. maxSteps is the upper bound on recursion depth; leaving it unset can let tool calls run longer than expected.
import { streamText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// db and priceService below are conceptual examples. In practice, wire in your
// project's data access layer and domain services here.
declare const db: {
products: { search(q: string, n: number): Promise<unknown[]> };
};
declare const priceService: {
calculate(productId: string, quantity: number): Promise<number>;
};
const result = streamText({
model: openai('gpt-4o'),
maxSteps: 5,
tools: {
searchProducts: tool({
description: 'Search the product database',
parameters: z.object({
query: z.string().describe('Search query'),
maxResults: z.number().default(10),
}),
execute: async ({ query, maxResults }) => {
const results = await db.products.search(query, maxResults);
return results;
},
}),
calculatePrice: tool({
description: 'Calculate price',
parameters: z.object({
productId: z.string(),
quantity: z.number(),
}),
execute: async ({ productId, quantity }) => {
const price = await priceService.calculate(productId, quantity);
return { totalPrice: price };
},
}),
},
prompt: 'What is the total price for 5 laptops?',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}Without maxSteps, tool calls can end up referencing themselves in a loop. In production, set an explicit upper bound.
Tradeoffs: what documentation alone doesn't show you
Comparison table
| Item | generateText |
generateObject |
streamObject |
streamText |
|---|---|---|---|---|
| Return form | String | Complete object | Partial<T> streaming |
Text chunks |
| Type safety | Text only | Full types | Partial types | Text only |
| Structured data | None | After response completes | Progressive, field by field | Available alongside via experimental option |
| UX | Show after complete | Show all after loading | Fields fill in one by one | Text streaming |
| Array handling | N/A | Delivered when complete | Intermediate state of elements filling in exposed | N/A |
Pitfalls commonly hit in practice
1. Large arrays can lose coherence (rule of thumb)
This is personal experience, not a benchmark, but as the number of array elements grows, the probability of missing fields or truncated JSON toward the end rises noticeably. The threshold varies greatly with model, prompt length, and schema complexity, so it's hard to pin down a single "dangerous above N" number. When failures repeat, I halve the batch size until I find a stable point, then run parallel calls at that size and merge the results.
import { chunk } from 'es-toolkit'; // or lodash's chunk
async function extractLargeList(items: string[]): Promise<Result[]> {
const BATCH_SIZE = 10; // find the stable point for your project through experimentation
const batches = chunk(items, BATCH_SIZE);
const results = await Promise.all(
batches.map(batch =>
generateObject({
model: openai('gpt-4o'),
schema: z.object({ items: z.array(ResultSchema) }),
prompt: `Process the following items: ${JSON.stringify(batch)}`,
})
)
);
return results.flatMap(r => r.object.items);
}2. Cost tracking and rate limiting must be wired up yourself
The SDK does include usage in responses, but cost aggregation and rate limiting are the application layer's responsibility. Rather than scattering usage-reading code across every call, it's easier to manage by wrapping the model itself with wrapLanguageModel and middleware, so all observability is consolidated in one place.
import { wrapLanguageModel, generateObject, type LanguageModelV1Middleware } from 'ai';
import { openai } from '@ai-sdk/openai';
// Conceptual example — verify middleware hook names and signatures against your SDK version's docs
const telemetryMiddleware: LanguageModelV1Middleware = {
wrapGenerate: async ({ doGenerate, params }) => {
const started = Date.now();
const result = await doGenerate();
metrics.track({
model: params.mode.type,
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
latencyMs: Date.now() - started,
});
return result;
},
};
const observedModel = wrapLanguageModel({
model: openai('gpt-4o'),
middleware: telemetryMiddleware,
});
// All subsequent calls use observedModel
const { object } = await generateObject({
model: observedModel,
schema: LeadSchema,
prompt: '...',
});Rate limiting is also a natural fit for the same place — you can extend the middleware to check a token bucket and throw when the limit is exceeded.
3. Structured output and forced tool use can conflict
Depending on the provider, simultaneously forcing structured output mode and toolChoice can result in an error or one of them being silently ignored. If you need both structured responses and tools together, it's safer to separate the steps: finish information gathering with tools first, then extract the structured response in the final step.
4. Provider-specific behavior differences
Even with the same Zod schema, the internal execution path differs. The OpenAI provider uses Structured Outputs (JSON Schema enforcement) for recent models; the Anthropic provider uses tool-use-based extraction. This leads to subtle differences in edge cases like optional field handling, values outside an enum, or missing array elements. When switching providers, run your regression tests against the same golden set.
5. Auto-repair for truncated JSON
Some versions of generateObject provide an experimental hook that can attempt to fix and re-parse text when the model returns unparseable JSON. The form below is conceptual — verify the exact option name and callback signature against your SDK version's documentation.
// Conceptual example — option names and parameter names may differ by SDK version
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: LeadSchema,
prompt: '...',
// In versions that provide a hook like experimental_repairText,
// you can pass a callback that manipulates the text and attempts re-parsing.
});Ecosystem status
Based on release highlights from the official Vercel blog, here is a summary of recent developments.
| Version | Changes highlighted in the official blog |
|---|---|
| 4.1 (2025) | Introduction of image generation (experimental_generateImage), non-blocking data streaming, and more (blog) |
| 4.2 (2025) | onError callback, text repair, redesigned useChat message parts, and more (blog) |
For changes in later versions, checking the official release notes at the time of use is the most accurate approach. Because the AI SDK frequently adjusts experimental API signatures even in minor updates, making a habit of verifying option names against your current version's documentation is worthwhile.
Wrap-up
The perspective in this post comes back to the 2×2 matrix sketched at the start. Do you need streaming? and Do you need structure? — these two axes determine almost everything about which SDK function to choose. For batch processing where you just need free text in final form, use generateText. Where perceived latency to the first token matters, as in chat, use streamText. When you need to extract data to hand directly to downstream logic or a database, use generateObject. For the "AI-filled UI" pattern, use streamObject.
If you set up a structure where a Zod schema is shared as a single file referenced by both server and client, that one schema file becomes your API contract and both sides get the benefit of type inference. The remaining production concerns — coherence with large arrays, cost and rate observability, version risk from experimental APIs — are substantially mitigated by three habits: wrap the model with middleware, tune batch sizes with data, and pin patch versions in the lock file.