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.
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.
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.
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.
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.
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.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.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".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.
// 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.
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.
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().parse() instead of safeParse() inside the loop. parse() throws, so one bad reply skips your retry.Mostly, but not completely. Keep the try/catch: an empty or blocked response is still not valid JSON.
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.
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.
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
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.
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.
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.
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.