Skip to main content
Jodei
Insights

Structured Outputs with Zod: Type-Safe LLM Responses in Next.js

LLMs return untyped JSON that can drift from your schema at any time. Zod lets you validate and type every response at the boundary — so hallucinated keys and mistyped fields never reach your JSX.

5 min read

LLMs are non-deterministic by design. Ask one to return JSON and it will — most of the time, in roughly the shape you described, with fields spelled the way you expect. The rest of the time you get a truncated object, a hallucinated key, or a string where a number belongs. In a Next.js Server Component that renders directly from the response, any of those becomes a runtime error.

Zod solves this at the boundary. Instead of trusting the raw response, you declare the exact shape you expect, parse it, and get a fully-typed value back — or a clear error you can handle gracefully.

Define the schema first

Write the Zod schema before writing the prompt. The schema is the source of truth; the prompt tells the model to conform to it.

import { z } from 'zod'

const ProductSummarySchema = z.object({
  name: z.string(),
  oneLinePitch: z.string().max(120),
  targetAudience: z.array(z.string()).min(1).max(5),
  pricingTier: z.enum(['free', 'freemium', 'paid']),
  confidenceScore: z.number().min(0).max(1),
})

type ProductSummary = z.infer<typeof ProductSummarySchema>

z.infer derives the TypeScript type from the schema automatically — no duplicated interface definition that drifts out of sync.

Use response_format to constrain the model

Modern providers (OpenAI, Anthropic, Google) support a response_format or structured-output parameter that forces the model to emit valid JSON matching a schema. On the OpenAI-compatible API, pass { type: 'json_object' } and include the schema description in the system prompt:

const completion = await openai.chat.completions.create({
  model: 'gpt-4o-mini',
  response_format: { type: 'json_object' },
  messages: [
    {
      role: 'system',
      content: `Return a JSON object with keys: name (string), oneLinePitch (string, max 120 chars),
targetAudience (string array, 1-5 items), pricingTier ("free"|"freemium"|"paid"),
confidenceScore (number 0-1). No extra keys.`,
    },
    { role: 'user', content: `Analyse this product: ${rawProductText}` },
  ],
})

const raw = JSON.parse(completion.choices[0].message.content ?? '{}')

json_object mode guarantees parseable JSON; it does not guarantee your schema. Zod is still needed for the second guarantee.

Parse and handle validation errors

Call safeParse instead of parse. The difference: parsethrows on failure; safeParse returns a discriminated union you can branch on without a try/catch:

const result = ProductSummarySchema.safeParse(raw)

if (!result.success) {
  // result.error.issues lists every field that failed validation
  console.error('LLM response failed schema:', result.error.issues)
  return null          // render a fallback instead of crashing
}

const summary: ProductSummary = result.data   // fully typed, guaranteed safe

In a Server Component, returning null and rendering a fallback is almost always preferable to surfacing an error boundary for a validation failure — the user can still see the rest of the page.

A complete Server Component

// app/products/[id]/summary/page.tsx
import { z } from 'zod'

const SummarySchema = z.object({
  headline: z.string(),
  bullets: z.array(z.string()).length(3),
  cta: z.string(),
})

async function fetchSummary(id: string) {
  const completion = await openai.chat.completions.create({ /* ... */ })
  const raw = JSON.parse(completion.choices[0].message.content ?? '{}')
  const result = SummarySchema.safeParse(raw)
  return result.success ? result.data : null
}

export default async function SummaryPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const summary = await fetchSummary(id)

  if (!summary) {
    return <p>Summary unavailable — try again shortly.</p>
  }

  return (
    <section>
      <h1>{summary.headline}</h1>
      <ul>{summary.bullets.map((b) => <li key={b}>{b}</li>)}</ul>
      <a href="/contact">{summary.cta}</a>
    </section>
  )
}

Coerce types the model gets wrong

Models occasionally return numbers as strings (""0.87"" instead of 0.87). Zod's coerce namespace handles this without weakening the schema:

const Schema = z.object({
  confidenceScore: z.coerce.number().min(0).max(1),
  publishedAt:     z.coerce.date(),
})

Use coerce selectively — only on fields where the model's type wobbles in practice. Broad coercion masks schema drift that is worth knowing about.

One rule for AI boundaries

Treat every LLM response like untrusted user input: parse at the boundary, reject what doesn't fit, never assume. Zod makes this a one-liner per field rather than a wall of defensive conditionals. The result is a Server Component that is both typed and resilient — the model can hallucinate a key all it wants; it will never reach your JSX.