Blog / Learning notes

How I Kept My AI API Key Off the Browser in a Quiz App

Cover image for How I Kept My AI API Key Off the Browser in a Quiz App

Quizwright is a small web app I built that writes multiple-choice questions for a class. You type a topic, pick a difficulty, choose how many questions you want and enter the student level. Each question comes back with four options, one correct answer and a short explanation. Gemini writes the questions. Before I wrote any of the UI, I had one rule for the project: the Gemini API key must never reach the browser.

Here is how I set that up in Next.js, and how I handle bad model responses.

The problem with calling the AI from the browser

The easiest way to wire up an AI API is to call it straight from a React component. That works, but the key has to be in the client bundle, and anyone who opens the browser dev tools can read it. In Next.js, any environment variable starting with NEXT_PUBLIC_ is also inlined into the browser JavaScript, so a key can leak by accident.

One server route between the browser and Gemini

The browser only ever talks to one endpoint: POST /api/generate. The page sends the form data there with a plain fetch, and the route handler calls Gemini on the server using the official @google/genai SDK. The route file itself is tiny: it sets the Node.js runtime and hands the request to a handler function.

The key is read in lib/ai/gemini.ts, which starts with import "server-only". That import makes the build fail if a client component ever imports the file, so I can't pull the SDK or the key into the browser by mistake. This is the part that reads the key:

export function createGeminiModel(): LanguageModel {
  const apiKey = process.env.GEMINI_API_KEY?.trim();
  if (!apiKey) {
    throw new MissingApiKeyError();
  }

  const model = process.env.GEMINI_MODEL?.trim() || DEFAULT_GEMINI_MODEL;
  const ai = new GoogleGenAI({ apiKey });

A few other small choices back this up:

  • The variable is just GEMINI_API_KEY. The README and .env.example both say not to add a NEXT_PUBLIC_ prefix, and on Vercel the key goes in the project's environment variables, not in a committed file.
  • .gitignore ignores .env* but keeps .env.example, which has an empty key line.
  • Errors are redacted. A small redactSecrets helper replaces anything that looks like a Google API key, or GEMINI_API_KEY=..., with [redacted] before it goes into a server log. The client never sees the raw provider error. It gets a fixed message like "The question generator could not reach Gemini."
  • A missing key fails clearly. If the variable isn't set, the route returns a 500 with a message saying how to add it, instead of crashing somewhere deep in the SDK. The project still builds without it, since the key is only read when someone generates questions.

Tests that check the boundary

I didn't want this to rely on me remembering the rules, so some of the Vitest tests check them directly. One test reads the client files (app/page.tsx, app/layout.tsx and the two components) and fails if any of them mention GEMINI_API_KEY, NEXT_PUBLIC_ or @google/genai. Another checks that gemini.ts contains the server-only import. Other tests check that the example env file has no real key and that a provider error containing a key never reaches the client.

Validating what the model sends back

Keeping the key safe was the first half. The second half was not trusting the model's output. Zod checks the data at each step:

  1. The request. The route checks the body with a strict schema: a topic up to 200 characters, a difficulty of Beginner, Intermediate or Advanced, a whole-number count from 1 to 20 and a student level. Unknown fields are rejected. A bad request gets a 400 with errors for each field and never reaches Gemini.
  2. The model call. Gemini is asked for JSON using responseMimeType: "application/json" and a response schema built for that request.
  3. The response. The JSON is parsed (a Markdown code fence around it is removed first) and checked against a schema built from the request. It must have exactly the requested number of questions and four distinct, non-empty options each, the correctAnswer must match one of the options exactly, there must be an explanation, and the topic and difficulty must match what was asked.

One retry, then a clear error

Models sometimes get the format slightly wrong, so generateQuestions allows two attempts. If the first response fails, the validation errors are added to the prompt ("Your previous response failed validation. Fix every issue...") and the model gets one more try. If that fails too, the route returns a 502 with a plain message and the page shows no question cards. Finally, the page checks the result against the same schema before showing it.

What I took away from it

The main lesson was that "keep the key on the server" isn't one step. It's a small group of habits: a single route, a server-only import, a variable name without the public prefix, redacted logs and tests that would catch a mistake. I also kept the model code (lib/ai/) separate from the question checks (lib/questions/), which don't import the SDK. That means I could switch to another provider without rewriting the validation.


You can try Quizwright at ai-question-generator-lilac.vercel.app, and the full source, tests included, is on GitHub at mfaysal-dev/ai-question-generator.

// 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

Sample note: what I am using PHP for
Learning notes Sample

Sample note: what I am using PHP for

A sample article for the blog layout. It is a practice note, not a finished tutorial or a claim of expertise.

September 20, 2026 · 2 min read