Structured Output with the AI SDK
AI SDK generateObject turns a Zod schema into a typed object. Map it to provider structured outputs, and fail the request when parse fails.
- ai-sdk
- zod
- structured-output
JSON.parse on a chat reply will fail on a Tuesday: a fence, a trailing comma, an apology before the brace. AI SDK generateObject (and the current generateText({ output }) form) is the difference between a form-filler and a regex graveyard. You hand in a Zod schema. You get a typed object, or the call fails in a way you can handle.
Valid JSON can still be a wrong invoice. The schema is a type. Business rules are a second pass.
Key takeaways
- Prefer provider structured outputs through the SDK over “please reply in JSON.”
- Zod (or JSON Schema) is the contract. Parse failure is a failed request, not a string to tinker with forever.
- Repair is a bounded retry with validator errors, not an infinite cleanup loop.
- Check domain invariants after parse: amounts, ids, enums you actually support.
- Stream partial objects only when the UI can render incomplete trees safely.
AI SDK generateObject is schema plus a request
The mental model is one function: schema plus prompt in, object out. That is what AI SDK generateObject names.
import { generateObject } from 'ai';
import { z } from 'zod';
const TicketDraft = z.object({
title: z.string().min(1).max(120),
severity: z.enum(['low', 'medium', 'high']),
stepsToReproduce: z.array(z.string()).min(1),
component: z.enum(['checkout', 'auth', 'docs', 'other']),
});
export async function draftTicket(fromNotes: string) {
const { object } = await generateObject({
model: 'openai/gpt-4.1-mini',
schema: TicketDraft,
prompt: `Turn these notes into a ticket.\n${fromNotes}`,
});
return object;
}
SDK versions drift. Current docs fold this into generateText with output: Output.object({ schema }) and you read result.output. Older examples import generateObject and read result.object. Same job: constrain decoding, validate, type the result. Use whatever your installed ai package documents. Do not keep a hand-rolled fence stripper.
Zod .describe() strings are prompt. They are cheaper than a paragraph of “the title should be short.”
This sits on top of structured outputs and JSON Schema. That page is the provider idea. This page is the TypeScript API you actually call from a Next.js route.
When parse fails, fail the request
Do not return {} and hope the form is blank. Surface a 422 or a typed error to the UI.
import { generateText, Output, NoObjectGeneratedError } from 'ai';
import { z } from 'zod';
export async function extractInvoice(raw: string) {
try {
const result = await generateText({
model: 'openai/gpt-4.1-mini',
output: Output.object({
schema: z.object({
invoiceId: z.string(),
lineItems: z.array(
z.object({
sku: z.string(),
qty: z.number().int().positive(),
unitCents: z.number().int().nonnegative(),
}),
),
totalCents: z.number().int().nonnegative(),
}),
}),
prompt: `Extract the invoice.\n${raw}`,
});
return result.output;
} catch (err) {
if (NoObjectGeneratedError.isInstance?.(err)) {
const fail = new Error('Could not extract an invoice');
(fail as Error & { status: number }).status = 422;
throw fail;
}
throw err;
}
}
Map that 422 in the Route Handler. The point is the status code: this is a validation miss, not a 500 from your database.
Repair is a budget
Some SDK versions accept repairText on object generation. The idea is portable even if the option name moves: take the bad text plus the schema error, ask once or twice, then stop.
async function generateWithRepair<T>(
schema: z.ZodType<T>,
prompt: string,
repairOnce: (badText: string, error: string) => Promise<string>,
): Promise<T> {
const first = await generateObject({
model: 'openai/gpt-4.1-mini',
schema,
prompt,
}).catch(async (err) => {
const text = typeof err?.text === 'string' ? err.text : '';
if (!text) throw err;
const fixed = await repairOnce(text, String(err.cause ?? err));
return generateObject({
model: 'openai/gpt-4.1-mini',
schema,
prompt: `Fix this JSON so it matches the schema.\nError: ${String(err.cause ?? err)}\nJSON:\n${fixed}`,
});
});
return first.object;
}
Cap at one repair in product code. A second failure is a model or schema problem. Loops that “try to parse again” with jsonrepair on arbitrary model prose will eventually accept a plausible lie.
Prefer native structured outputs (constrained decoding) so repair stays rare. If the provider cannot constrain, you are in output parsing territory.
Business rules after parse
The object can be well-typed and still wrong.
function assertInvoiceMath(
invoice: {
lineItems: { qty: number; unitCents: number }[];
totalCents: number;
},
) {
const sum = invoice.lineItems.reduce(
(acc, line) => acc + line.qty * line.unitCents,
0,
);
if (sum !== invoice.totalCents) {
throw new Error('total_mismatch');
}
}
Other checks that schemas will not save:
invoiceIdexists in your databasecomponentmaps to a real owner- dates are not “next Tuesday”
- arrays are not 400 items because you forgot
max
Put those in your route, next to auth. The model is not the accounting layer.
Should you stream the object?
Use streamObject / streamText with an object output when the UI can show fields as they close: a title appearing, then a list of steps. Do not bind a submit button to a partial tree. Incomplete objects are not schema-valid. The SDK may expose partialObjectStream that is explicitly unvalidated.
| Mode | Use when |
|---|---|
generateObject / generateText + object output |
You need the full record before any side effect |
| Streamed partials | Preview only: forms, outlines, live classification chips |
| Text + your own parse | Last resort when the provider has no schema mode |
For a chat UI that happens to return JSON, structured output is usually the wrong product. Chat wants tokens. Extraction wants an object. Split the routes.
Prompt still matters
A schema does not replace a spec. Say what each field is for. Give one short example if the shape is ambiguous. Do not few-shot five full invoices into the prompt unless that earns its tokens. Prompt engineering still applies. Temperature should be low for extraction. “Creative” sampling invents SKUs.
Footguns
.passthrough()on the schema. Extra keys from the model become extra keys in your DB.z.string()for money. Use integer cents.- Streaming partials into
JSON.parse. You will parse'{"title":"He'and throw on every frame. - Catching parse errors and returning the raw text to the client. Now the UI is a parser again.
- Huge nested schemas. Constrained decoding can get fussy. Split into two calls if the provider starts refusing the schema.
Interview answer (out loud)
“I use the AI SDK object generators with a Zod schema, generateObject or generateText with Output.object depending on the version. That maps to the provider’s structured output mode. If parse fails I fail the request, maybe repair once with the validator error, then stop. After parse I run business checks: totals, ids, allowed enums. I only stream partial objects for preview, never for writes.”
Further reading (primary sources)
Related on this site
- Structured outputs and JSON Schema
- Vercel AI SDK fundamentals
- Prompt engineering fundamentals
- Output parsing and repair
Related guides
- Structured Outputs and JSON SchemaForce the model into a schema instead of parsing its vibes. When constrained decoding helps, and when the model still fills fields with junk.
- Tool Calling in Next.jsRun Next.js tool calling in a Route Handler: stream the UI, execute tools on the server, and keep secrets off the client.
- Vercel AI SDK FundamentalsThe AI SDK gives TypeScript apps streaming, tools, and UI hooks without hand-rolling SSE. Where it helps, and where you still own the server.
- Agent Failure ModesAgents loop, lie about tool results, skip confirmation, and get stuck apologizing. Catalog the failures and the circuit breakers.
- Agent HandoffsA handoff passes the conversation to another agent with a slimmer prompt and different tools. What to copy, what to drop, and how to avoid ping-pong.