Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
To add a Gemini API key to a Next.js app and Vercel: create the key in Google AI Studio, put it in .env.local as GEMINI_API_KEY (with no NEXT_PUBLIC_ prefix), read it only in server code such as a route handler, then add the same variable in your Vercel project under Settings → Environment Variables and redeploy. The browser calls your route, your route calls Gemini, and the key never leaves the server. This is the setup I use in Quizwright, my Gemini quiz generator, and below I show each step plus a test proving where the key does and doesn't end up.
I tested this on Next.js 16 (App Router) with the official @google/genai SDK: I built the app with fake keys, searched the browser bundle for them, and called the route with a valid and an invalid key. If you want the deeper security story, like redacting errors and validating AI output, read how I kept my AI API key off the browser. This post is the practical setup.
Free-tier limits and prices change, so check Google's pricing page instead of trusting numbers in an old tutorial.
Create a file called .env.local in the project root, next to package.json:
# .env.local (never commit this file)
GEMINI_API_KEY=paste-your-key-here
Next.js already ignores .env* files in the .gitignore it generates, but check yours. Then restart npm run dev. Next.js reads env files once at startup, so a running dev server won't see a new key.
I keep everything that touches the key in one file, lib/gemini.ts. Install the SDK and the guard package first with npm install @google/genai server-only:
import "server-only"; // build fails if a client component imports this file
import { GoogleGenAI } from "@google/genai";
const MODEL = process.env.GEMINI_MODEL?.trim() || "gemini-3.5-flash-lite";
export async function askGemini(prompt: string): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY?.trim();
if (!apiKey) {
throw new Error("GEMINI_API_KEY is not set");
}
const ai = new GoogleGenAI({ apiKey });
const response = await ai.models.generateContent({
model: MODEL,
contents: prompt,
});
return response.text ?? "";
}
The first line is the important one. import "server-only" makes the build fail if any client component imports this file, even by accident through another file. I tested it by importing askGemini into a "use client" page, and next build stopped with "You're importing a module that depends on "server-only"" and the import trace that led there. That's a much better time to find the mistake than after deploying.
The key is also read inside the function, not at the top of the file. That way the project still builds on Vercel before you've added the variable, and a missing key gives a clear error at request time.
Create app/api/ask/route.ts. This is the only door between the browser and Gemini:
import { askGemini } from "@/lib/gemini";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const prompt = typeof body?.prompt === "string" ? body.prompt.trim() : "";
if (prompt === "" || prompt.length > 2000) {
return Response.json({ error: "Send a prompt of 1 to 2000 characters." }, { status: 400 });
}
try {
const text = await askGemini(prompt);
return Response.json({ text });
} catch (error) {
// Details go to the server log only. The browser gets a fixed message.
console.error("Gemini request failed:", error instanceof Error ? error.message : error);
return Response.json({ error: "The AI request failed. Please try again." }, { status: 502 });
}
}
Two habits here matter as much as hiding the key. The route checks its input on the server, because anyone can send any request to /api/ask, not just your page. It's the same "never trust the client" rule from my PHP form validation guide, just in TypeScript. And the error details go only to the server log, while the browser gets a fixed message.
When I called this route with a fake key, the server log showed Google's "API key not valid" error and the browser only got {"error":"The AI request failed. Please try again."} with status 502. With no key at all, the log said GEMINI_API_KEY is not set and the browser saw the same fixed message. Bad input, like an empty prompt or a body that isn't JSON, returned a 400 without calling Gemini at all.
"use client";
import { useState } from "react";
export default function Home() {
const [prompt, setPrompt] = useState("");
const [answer, setAnswer] = useState("");
async function ask() {
setAnswer("Thinking…");
const res = await fetch("/api/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const data = await res.json();
setAnswer(res.ok ? data.text : data.error);
}
return (
<main>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button onClick={ask}>Ask Gemini</button>
<p>{answer}</p>
</main>
);
}
The client component only knows the URL /api/ask. It never sees the key or the SDK. If async, await and fetch are still new to you, my JavaScript async/await tutorial covers them step by step, including checking res.ok.
Any variable whose name starts with NEXT_PUBLIC_ is copied into the JavaScript files sent to the browser at build time. To see it for myself, I built a test page with two fake keys:
"use client";
export default function Leak() {
console.log(process.env.GEMINI_API_KEY); // undefined in the browser
console.log(process.env.NEXT_PUBLIC_GEMINI_API_KEY); // the real key, baked into the JS
return <p>Open the console</p>;
}
After next build, I searched the browser files with grep -rl "FAKE_PUBLIC" .next/static. The NEXT_PUBLIC_ value was sitting in plain text in a JavaScript chunk that anyone can download. The plain GEMINI_API_KEY value appeared in none of the files in .next/static. That's the whole rule: a NEXT_PUBLIC_ secret is not a secret. Run the same grep with the start of your real key before you deploy if you're ever unsure.
GEMINI_API_KEY and paste the key as the value. If Vercel offers to store it as a secret or sensitive value, choose that.vercel dev.Vercel doesn't read your .env.local, because that file isn't in your Git repository. The dashboard is the only place the deployed server gets the key from. To copy the Development variables to your computer, the Vercel CLI has vercel env pull .env.local.
.trim() in the helper removes stray spaces.undefined in a component: that's correct behaviour in client components. Move the call to a route handler or server code..env.local: real environment variables win over env files. While testing, I had a GEMINI_API_KEY exported in my terminal, and Next.js used it instead of my file. Check with echo $GEMINI_API_KEY.Yes. Server Components run on the server too, and the same server-only helper works there. Use a route handler when the browser needs to send input, like a prompt from a form.
Delete it in AI Studio right away, create a new one, update it in Vercel and .env.local, and redeploy. Removing it from a later commit isn't enough, because it stays in your Git history.
Yes, a .env.example with the variable name and an empty value helps anyone who clones the project. Just never put the real value in it.
Using a Gemini API key safely in Next.js is mostly about where you read it: GEMINI_API_KEY in .env.local without NEXT_PUBLIC_, a helper protected with import "server-only", one route handler the browser calls, and the same variable in Vercel's settings followed by a redeploy. The Next.js guide to environment variables explains the loading order in more detail.
// 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.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
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.