Blog / Coding tips

Gemini Structured Output With Zod in Next.js

Cover image for Gemini Structured Output With Zod in Next.js

To get reliable structured output from Gemini in Next.js, describe the JSON you want once as a Zod schema, convert it with Zod 4's z.toJSONSchema(), and pass it to generateContent() as responseJsonSchema together with responseMimeType: "application/json". Then parse the reply with JSON.parse(), check it with the same Zod schema using safeParse(), and retry once with the validation errors if it fails. The schema tells Gemini what to write. Zod makes sure it actually did. This is the pattern behind Quizwright, my Gemini quiz generator, rebuilt here as a small, tested example.

I tested the schema conversion, the parsing and the retry loop with Zod 4.6 and @google/genai 2.26, using a fake model so the tests run without an API key, and type-checked the Gemini call against the SDK's own types.

Why you need both a schema and Zod

Gemini's structured output mode makes the model return syntactically valid JSON that follows your schema. But Google's own documentation says it supports only a subset of JSON Schema, and recommends that you still validate the values in your application. A quiz question can be perfectly valid JSON and still be wrong: three options instead of four, or a correct answer that isn't one of the options. Zod catches that. The schema guides; Zod enforces.

Step 1: define the shape in Zod

import { z } from "zod";

export const QuestionSchema = z
  .object({
    question: z.string().min(1).describe("The question text"),
    options: z.array(z.string().min(1)).length(4).describe("Exactly four answer choices"),
    correctAnswer: z.string().describe("Must be copied exactly from options"),
    explanation: z.string().min(1).describe("One or two sentences on why it is right"),
  })
  .refine((q) => q.options.includes(q.correctAnswer), {
    message: "correctAnswer must be one of the options",
    path: ["correctAnswer"],
  });

export const QuizSchema = z.object({
  questions: z.array(QuestionSchema).min(1).max(20),
});

export type Quiz = z.infer<typeof QuizSchema>;

The .describe() texts aren't just comments. They become description fields in the JSON Schema, and Gemini reads them as instructions. The .refine() adds a rule that plain types can't express: correctAnswer must match one of the options exactly.

Step 2: see what Gemini will receive

Before sending anything, I printed the converted schema:

import { z } from "zod";
import { QuizSchema } from "./quiz-schema";

console.log(JSON.stringify(z.toJSONSchema(QuizSchema), null, 2));

// Output:
// {
//   "$schema": "https://json-schema.org/draft/2020-12/schema",
//   "type": "object",
//   "properties": {
//     "questions": {
//       "minItems": 1,
//       "maxItems": 20,
//       "type": "array",
//       "items": {
//         "type": "object",
//         "properties": {
//           "question": {
//             "type": "string",
//             "minLength": 1,
//             "description": "The question text"
//           },
//           "options": {
//             "minItems": 4,
//             "maxItems": 4,
//             "type": "array",
//             "items": {
//               "type": "string",
//               "minLength": 1
//             },
//             "description": "Exactly four answer choices"
//           },
//           "correctAnswer": {
//             "type": "string",
//             "description": "Must be copied exactly from options"
//           },
//           "explanation": {
//             "type": "string",
//             "minLength": 1,
//             "description": "One or two sentences on why it is right"
//           }
//         },
//         "required": [
//           "question",
//           "options",
//           "correctAnswer",
//           "explanation"
//         ],
//         "additionalProperties": false
//       }
//     }
//   },
//   "required": [
//     "questions"
//   ],
//   "additionalProperties": false
// }

Two things stand out. First, .length(4) became minItems and maxItems, and the descriptions came through. Second, the .refine() rule is gone. JSON Schema has no way to say "this string must be one of the values in that array", so Zod leaves it out without any warning. Some other keywords, like minLength, aren't on the list of keywords Gemini's documentation says it supports, so don't count on the model following them either. That's exactly why the Zod check in step 4 isn't optional.

Step 3: call Gemini with the schema

import "server-only";

import { GoogleGenAI } from "@google/genai";

const MODEL = process.env.GEMINI_MODEL?.trim() || "gemini-3.5-flash-lite";

/** Ask Gemini for JSON that follows `jsonSchema`. Returns the raw text. */
export async function askGeminiJson(prompt: string, jsonSchema: unknown): Promise<string> {
  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const response = await ai.models.generateContent({
    model: MODEL,
    contents: prompt,
    config: {
      responseMimeType: "application/json",
      responseJsonSchema: jsonSchema,
    },
  });
  return response.text ?? "";
}

Notes on this file:

  • responseJsonSchema takes standard JSON Schema, which is what z.toJSONSchema() produces. The SDK also has a separate responseSchema option that expects Gemini's own Schema format. Use one or the other, not both.
  • Pass the converted schema, not the Zod object. A Zod schema is a JavaScript class instance, and the API can't read it.
  • response.text can be undefined, for example when a response is blocked. The ?? "" turns that into an empty string, which the parser in the next step rejects cleanly.
  • import "server-only" keeps the key and the SDK out of the browser bundle. I explain the full key setup in how to add a Gemini API key to Next.js and Vercel.

Step 4: validate, then retry once

import { z } from "zod";

import { askGeminiJson } from "./gemini-json";
import { QuizSchema, type Quiz } from "./quiz-schema";

const quizJsonSchema = z.toJSONSchema(QuizSchema);

type ParseResult = { ok: true; quiz: Quiz } | { ok: false; error: string };

export function parseQuiz(text: string, count: number): ParseResult {
  let data: unknown;
  try {
    data = JSON.parse(text);
  } catch {
    return { ok: false, error: "The response was not valid JSON." };
  }

  const result = QuizSchema.safeParse(data);
  if (!result.success) {
    return { ok: false, error: z.prettifyError(result.error) };
  }
  if (result.data.questions.length !== count) {
    return { ok: false, error: `Expected ${count} questions, got ${result.data.questions.length}.` };
  }
  return { ok: true, quiz: result.data };
}

export async function generateQuiz(topic: string, count: number, ask = askGeminiJson): Promise<Quiz> {
  const prompt =
    `Write ${count} multiple-choice questions about "${topic}" for beginners. ` +
    `Each question has exactly 4 different options, and correctAnswer is copied exactly from options.`;

  let lastError = "";
  for (let attempt = 1; attempt <= 2; attempt++) {
    const retryNote = lastError ? `\n\nYour previous answer was rejected:\n${lastError}\nFix it.` : "";
    const result = parseQuiz(await ask(prompt + retryNote, quizJsonSchema), count);
    if (result.ok) return result.quiz;
    lastError = result.error;
  }
  throw new Error(`Gemini returned an invalid quiz twice: ${lastError}`);
}

How it works:

  • parseQuiz() never throws. It returns either the typed quiz or a readable error, which keeps the retry loop simple. z.prettifyError() turns Zod's error object into short lines like "correctAnswer must be one of the options → at questions[0].correctAnswer".
  • It checks things the schema can't, like whether the number of questions matches what the user asked for.
  • The retry includes the errors. Telling the model exactly what was wrong fixes most failures on the second try. I stop at two attempts, because every call costs time and quota, and a model that fails twice usually needs a better prompt, not a third try.
  • ask is a parameter with the real Gemini call as its default. In production nothing changes. In tests I can pass a fake.

If the async/await loop looks unfamiliar, my JavaScript async/await tutorial explains how awaiting inside a for loop runs the attempts one after another.

Step 5: test it without an API key

// Tests parseQuiz and the retry loop with a fake model (no API key needed)
import { generateQuiz, parseQuiz } from "./generate-quiz";

const good = JSON.stringify({
  questions: [{
    question: "Which tag holds the main content of a page?",
    options: ["<div>", "<main>", "<span>", "<aside>"],
    correctAnswer: "<main>",
    explanation: "<main> marks the dominant content of the document.",
  }],
});
const wrongAnswer = good.replace('"correctAnswer":"<main>"', '"correctAnswer":"main"');

console.log(parseQuiz(good, 1).ok);
console.log(parseQuiz("Sure! Here is your quiz:", 1));
console.log(parseQuiz(wrongAnswer, 1));
console.log(parseQuiz(good, 3));

// Fake model: bad answer first, good answer on the retry
const prompts: string[] = [];
const fakeAsk = async (prompt: string) => {
  prompts.push(prompt);
  return prompts.length === 1 ? wrongAnswer : good;
};
const quiz = await generateQuiz("HTML semantic tags", 1, fakeAsk);
console.log(quiz.questions[0].correctAnswer, "after", prompts.length, "calls");
console.log(prompts[1].includes("correctAnswer must be one of the options"));

// Output:
// true
// { ok: false, error: 'The response was not valid JSON.' }
// {
//   ok: false,
//   error: '✖ correctAnswer must be one of the options\n' +
//     '  → at questions[0].correctAnswer'
// }
// { ok: false, error: 'Expected 3 questions, got 1.' }
// <main> after 2 calls
// true

A good reply passes. Chatty text that isn't JSON, an answer that doesn't match the options, and the wrong question count all fail with a clear message. The fake model returns a bad quiz first and a good one second, and generateQuiz() recovers after exactly two calls, with the Zod error included in the retry prompt.

One gotcha I hit: running this test with tsx failed at first, because the server-only package throws when it's imported outside a React Server environment. Running it as npx tsx --conditions react-server test-quiz.ts fixed it. Test runners like Vitest can also mock the module instead.

Using it in a route handler

In the app, a route handler validates the request first, then calls generateQuiz() and returns Response.json(quiz). Use Zod for the request body too, with limits on the topic length and the question count. It's the same "validate on input" rule from my PHP form validation guide: the server checks everything, whatever the client sent. When generateQuiz() throws, log the detail on the server and send the browser a fixed error message.

Common mistakes

  • Using zod-to-json-schema with Zod 4. That package was written for Zod 3. Developers have reported it producing a nearly empty schema with Zod 4, which means Gemini gets no structure at all. Use the built-in z.toJSONSchema().
  • Trusting the schema alone. Refinements, and keywords outside Gemini's supported subset, aren't enforced by the API.
  • Using parse() instead of safeParse() inside the loop. parse() throws, so one bad reply skips your retry.
  • Huge descriptions. Very large or deeply nested schemas can be rejected by the API, so keep descriptions short.

FAQ

Does structured output mean I can skip JSON.parse errors?

Mostly, but not completely. Keep the try/catch: an empty or blocked response is still not valid JSON.

Can I stream structured output?

Yes, the API can stream the JSON in pieces, but you can only validate it with Zod once the last piece arrives. For a quiz, waiting for the full reply is simpler.

Which Gemini model should I use?

Quizwright reads the model id from a GEMINI_MODEL variable, so I can switch without changing code. Check Google's models page for the current ids before you pick one.

Conclusion

Write the shape once in Zod, send z.toJSONSchema() as responseJsonSchema, and always check the reply with safeParse(), because the API doesn't enforce every rule. Return errors instead of throwing, retry once with those errors in the prompt, and test the whole loop with a fake model. Google's guide to structured outputs and Zod's page on JSON Schema cover the details.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

How to Add a Gemini API Key to Next.js and Vercel
Coding tips

How to Add a Gemini API Key to Next.js and Vercel

Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.

October 2, 2026 · 8 min read

Build an Offline Habit Tracker With localStorage
Coding tips

Build an Offline Habit Tracker With localStorage

A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.

October 2, 2026 · 10 min read

Simple Math Captcha in PHP With Sessions
Coding tips

Simple Math Captcha in PHP With Sessions

A simple PHP math captcha in two functions: store the answer in the session, check it once with ctype_digit and ===, then add a honeypot and time check.

October 2, 2026 · 8 min read