What I Learned Building a Canvas Design and Animation Editor
Notes from building Kinetica, a browser design and animation editor: one canvas renderer for editor, previews and exports, timeline keyframes and undo.
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 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.
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:
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.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."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.
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:
responseMimeType: "application/json" and a response schema built for that request.correctAnswer must match one of the options exactly, there must be an explanation, and the topic and difficulty must match what was asked.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.
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
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.
Notes from building Kinetica, a browser design and animation editor: one canvas renderer for editor, previews and exports, timeline keyframes and undo.
How I built StudyPilot to run fully in the browser with localStorage, a drag-and-drop dashboard and a rule-based planner that picks what to study today.
A sample article for the blog layout. It is a practice note, not a finished tutorial or a claim of expertise.