Blog
Practical notes on design, product, and shipping for startups.
Practical notes on design, product, and shipping for startups.
A practical 2026 guide to reliable LLM JSON in Next.js: JSON mode vs structured outputs, one zod schema, server-side safeParse, one retry with the error, safe fallbacks, business checks, and tests, with TypeScript code.
To get reliable JSON from an LLM in a Next.js app, define the shape once with a zod schema, turn on your provider's structured output mode, validate every response on the server with safeParse, retry once with the validation error, and fall back safely. Never pass raw model output to your database or UI.
Last updated: November 5, 2026. Written by Luminix Studio (luminixstudio.com) from how we wire LLM features into Next.js apps. Provider APIs change often, so check the current docs for the SDK you use.
A model that returns perfect JSON in your test prompt will eventually return something else for real users. The common failures look like this:
| Failure | What you see | Why it happens |
|---|
| Wrapped output | JSON inside a markdown code fence, or "Sure! Here is the JSON:" first | The model is being helpful in chat style |
| Truncated output | JSON that stops halfway through | The response hit the max token limit |
| Wrong shape | A field renamed, nested differently, or missing | Loose instructions, or a long and confusing input |
| Wrong type | "price": "12.50" as a string, or "count": "three" | Nothing enforced the type |
| Invalid enum | "status": "Done" when you allow only "done" or "open" | Casing and synonyms drift |
| Valid but wrong | Perfect JSON with an invented order id | Structure is not truth |
| Refusal | A short apology instead of data | Safety filters or a vague request |
Structured output modes fix most of the first five. Nothing fixes the last two except validation, business checks, and a safe fallback. That is why we treat the model as an untrusted input source, the same as a form submitted by a stranger.
These two names get mixed up, and they give very different guarantees.
| Approach | What it guarantees | When to use it |
|---|---|---|
| Prompt only ("reply in JSON") | Nothing | Prototypes only |
| JSON mode | The output parses as JSON, but any shape | Older models or providers without schema support |
| Structured outputs with a JSON schema | The output matches your schema (within the schema features the provider supports) | The default for any production feature |
| Tool or function calling | Arguments match the tool's schema | When the model chooses between several actions |
OpenAI, Anthropic, and Google Gemini all offer some form of schema-constrained output in 2026, and the Vercel AI SDK wraps them behind one function. Each provider supports a slightly different subset of JSON Schema, so keep your schemas simple: objects, strings, numbers, booleans, arrays, and enums. Avoid deep unions and clever regex patterns in the schema you send to the model. Put those checks in your own validation instead.
Write one zod schema and derive everything from it: the TypeScript type, the schema you send to the model, and the server-side validator. Here is an example for a support inbox that classifies incoming tickets.
// lib/ai/ticket-schema.ts
import { z } from "zod";
export const TicketTriage = z.object({
category: z.enum(["billing", "bug", "feature_request", "account", "other"]),
priority: z.enum(["low", "normal", "urgent"]),
summary: z.string().min(1).max(280),
customerEmail: z.string().email().nullable(),
orderId: z.string().nullable(),
needsHuman: z.boolean(),
});
export type TicketTriage = z.infer<typeof TicketTriage>;Three habits that save time later:
Keep the call on the server, in a route handler, Server Action, or background job. Never call the model from the browser with your API key. This example uses the Vercel AI SDK, which accepts the zod schema directly.
// lib/ai/triage.ts
import "server-only";
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { TicketTriage } from "./ticket-schema";
export async function callTriageModel(ticketText: string) {
const { object } = await generateObject({
model: openai("gpt-4o-mini"), // pick the model your evals support
schema: TicketTriage,
system:
"You triage support tickets for a SaaS product. " +
"Use only facts present in the ticket. " +
"If a value is not in the ticket, return null. Never guess an order id or email.",
prompt: ticketText,
maxOutputTokens: 400,
});
return object;
}If you use a provider SDK directly, the idea is the same: convert the zod schema to JSON Schema (the OpenAI Node SDK ships a zod helper for this, and zod 4 has a built-in JSON Schema export), send it as the response format, and parse the reply. Function names differ between SDK versions, so check the docs for the version in your package.json.
Even with structured outputs, validate on your side. The SDK may already throw on a schema mismatch, but your own safeParse also runs the checks the provider could not enforce, and it gives you one place to handle errors. Then retry once with the error message, and if that fails too, fall back to something safe.
// lib/ai/triage-safe.ts
import "server-only";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { TicketTriage } from "./ticket-schema";
const FALLBACK: TicketTriage = {
category: "other",
priority: "normal",
summary: "Needs manual review",
customerEmail: null,
orderId: null,
needsHuman: true,
};
function extractJson(text: string): unknown {
// Strip code fences or chatty prefixes, then parse the first JSON object.
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) throw new Error("No JSON object found");
return JSON.parse(text.slice(start, end + 1));
}
export async function triageTicket(ticketText: string): Promise<{
result: TicketTriage;
source: "model" | "retry" | "fallback";
}> {
let lastError = "";
for (let attempt = 0; attempt < 2; attempt++) {
const repairNote = attempt === 0 ? "" :
`\n\nYour previous reply was invalid: ${lastError}\nReturn only corrected JSON.`;
try {
const { text, finishReason } = await generateText({
model: openai("gpt-4o-mini"),
system:
"Return only a JSON object with keys category, priority, summary, " +
"customerEmail, orderId, needsHuman. Use null for unknown values.",
prompt: ticketText + repairNote,
maxOutputTokens: 400,
});
if (finishReason === "length") {
lastError = "the reply was cut off; keep the summary under 280 characters";
continue;
}
const parsed = TicketTriage.safeParse(extractJson(text));
if (parsed.success) {
return { result: parsed.data, source: attempt === 0 ? "model" : "retry" };
}
lastError = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
} catch (err) {
lastError = err instanceof Error ? err.message : "unknown error";
}
}
console.warn("triage fallback", { lastError });
return { result: FALLBACK, source: "fallback" };
}This version uses plain text generation so you can see every step. In production you would usually combine both: generateObject with the schema for the first attempt, and this safeParse, retry, and fallback wrapper around it. The rules we follow:
Schema validation proves the shape. It does not prove the facts. Add business checks after safeParse for anything the model could invent:
// After a successful parse
if (result.orderId) {
const order = await db.order.findFirst({
where: { id: result.orderId, accountId: session.accountId },
});
if (!order) result = { ...result, orderId: null, needsHuman: true };
}
if (result.customerEmail && !ticketText.includes(result.customerEmail)) {
// The model "found" an email that is not in the ticket. Drop it.
result = { ...result, customerEmail: null };
}The pattern is simple: any id, email, amount, or date that your code will act on must be checked against your database or the original input. If it does not match, drop it and send the item to a person.
Put 30 to 100 real examples in a JSONL file in the repo, with personal data removed, and run them through the same triageTicket function on every prompt or model change. Track three numbers:
| Metric | What it tells you |
|---|---|
| Valid on first try | How well the prompt and schema fit the model |
| Fallback rate | How often users would hit the safe path |
| Field accuracy (for example, correct category) | Whether the JSON is right, not just well formed |
We covered a lightweight version of this setup in our guide to adding evals to an LLM feature before launch. Structured output is the easiest place to start, because most checks are plain code: did it parse, is the enum valid, is the id real.
If you want help building AI features like this into a web or mobile product, Luminix Studio builds apps and AI automations for startups worldwide. You can reach us at luminixstudio.com.