Streaming Structured Objects: Type-Safe Partial JSON with streamObject in Next.js
generateObject waits for the complete response — streamObject delivers typed partial JSON as tokens arrive. Here's how to wire it into a Next.js 16 route handler, read it with useObject, and show incremental structured data without a full-list wait.
6 min read
The Vercel AI SDK's generateObject is the right tool when you need a complete structured response before rendering anything. But structured data often has the same latency problem as unstructured text — the model thinks for several seconds before emitting a single token. If you have a product recommendation engine generating a list of five items, the user waits for all five before seeing any. streamObject fixes that.
How streamObject works
streamObject generates a structured JSON value matching a Zod schema, but delivers it incrementally as the model writes tokens. The SDK reconstructs valid partial JavaScript objects from the raw token stream — you get typed partial data in your UI as soon as the first field is ready, not after the last closing brace arrives.
It works the same way as streamText: you call it in a route handler and return a streaming response. The client reads the stream and receives successive snapshots of the partial object.
Route handler setup
// app/api/recommendations/route.ts
import { streamObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const recommendationSchema = z.object({
items: z.array(
z.object({
title: z.string(),
summary: z.string(),
tags: z.array(z.string()),
})
),
});
export async function POST(req: Request) {
const { query } = await req.json();
const result = streamObject({
model: anthropic("claude-sonnet-4-5"),
schema: recommendationSchema,
prompt: `Return 5 article recommendations for: ${query}`,
});
return result.toTextStreamResponse();
}The Zod schema is the source of truth for both the prompt (the SDK injects it as JSON Schema instructions automatically) and the TypeScript types you get back on the client. No manualJSON.parse, no as any cast.
Reading the stream on the client
The AI SDK's useObject hook mirrors useCompletion but for structured data. It tracks the partial object as it builds and re-renders on each new chunk:
"use client";
import { useObject } from "@ai-sdk/react";
import { z } from "zod";
const recommendationSchema = z.object({
items: z.array(
z.object({
title: z.string(),
summary: z.string(),
tags: z.array(z.string()),
})
),
});
export function Recommendations({ query }: { query: string }) {
const { object, submit, isLoading } = useObject({
api: "/api/recommendations",
schema: recommendationSchema,
});
return (
<div>
<button onClick={() => submit({ query })} disabled={isLoading}>
{isLoading ? "Thinking…" : "Get recommendations"}
</button>
<ul>
{object?.items?.map((item, i) => (
// Items appear as soon as they start arriving — no full-list wait
<li key={i}>
<strong>{item?.title}</strong>
<p>{item?.summary}</p>
{item?.tags?.map((tag) => <span key={tag}>{tag}</span>)}
</li>
))}
</ul>
</div>
);
}Because the object is partial while streaming, every field is typed as T | undefined. Optional chaining is required — but TypeScript enforces it, so you can't accidentally access a field that hasn't arrived yet.
When items appear
The key UX difference over generateObject is progressive disclosure. With a list schema, the first item typically arrives within 1–2 seconds even on a 5-item response. Users see something real immediately and the rest fills in. For dense schemas with many fields, the first top-level field appears as soon as the model writes its opening characters.
This matters most for lists, dashboards, and any UI where showing partial data is better than showing nothing. A single-object schema with a few scalar fields may not feel meaningfully different to the user — in that case generateObject is simpler.
Output mode and schema shape
streamObject accepts an output option that controls the generation strategy. The default is 'object'. Set it to 'array'with an element schema to receive items one at a time as a stream rather than a growing array partial — each complete element arrives as its own event:
const result = streamObject({
model: anthropic("claude-sonnet-4-5"),
output: "array",
schema: z.object({
title: z.string(),
summary: z.string(),
}),
prompt: "Return 5 article summaries as a JSON array",
});
for await (const item of result.elementStream) {
// Each iteration: one complete, validated item
console.log(item.title, item.summary);
}The elementStream async iterator yields each element only after it is fully valid against the schema. This is the right shape when downstream code needs complete items, not partials — a pipeline that embeds each item or writes it to a database can process them in parallel with generation rather than waiting for the full array.
The pattern to reach for
Use generateObject when the downstream operation needs the complete response — database writes, server-side transforms, anything where a partial object would cause an error. Use streamObject when the goal is UI responsiveness: lists that should load progressively, dashboards where each section can render independently, or any case where the user is watching a generation happen in real time.