Build an AI Interview Prep Tool
Build a full-stack Next.js app that generates AI interview questions.
Introduction
30 Second Summary
Technical interviews are stressful because you never know what questions are coming. What if you could practice with an AI that generates questions tailored to your exact skill level and topic?
In this project, you will build PrepAI, a full-stack web application that uses Google Gemini AI to generate personalized technical interview questions. You will practice answering them, reveal model answers, and track your progress over time.
What You'll Build
PrepAI is a deployed AI-powered interview preparation tool built with Next.js, TypeScript, and Tailwind CSS.
By the end of this project, you'll have:
- A responsive landing page with topic selection cards and a difficulty picker.
- A server-side API route that calls Google Gemini to generate contextual interview questions with model answers.
- An interactive quiz interface with question navigation and a results dashboard that tracks your practice history.
- Secret Mission: Add a countdown timer that simulates real interview pressure, plus AI-graded answers with scoring and feedback.
Are there any prerequisites?
You should have a basic understanding of HTML and CSS. Familiarity with programming concepts like variables, functions, and loops from any language is helpful. You will also need Node.js installed on your computer.
Set Up the Project
Before you can build PrepAI, you need a working development environment with the right tools wired together.
In this step, you'll scaffold a Next.js project with TypeScript and Tailwind CSS, install the Google GenAI SDK, and wire up your API key so the dev server runs cleanly.
In this step, get ready to:
- Create a Next.js project with TypeScript, Tailwind CSS, and App Router.
- Install the Google GenAI SDK and store your API key.
- Verify the development server runs successfully.
Create the Next.js project and install dependencies
Next.js gives you a full-stack React framework with server-side API routes built in. This means your Gemini API key stays secret on the server instead of being exposed in the browser.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the PrepAI project by running this command:
npx create-next-app@latest prepai
What happens next?
The create-next-app wizard will ask you a series of configuration questions. The first question asks whether you want recommended defaults or custom settings. You need to choose No, customize settings so you can skip the linter and React Compiler.
- When asked Would you like to use the recommended Next.js defaults?, select No, customize settings.
- Enter Yes for the Would you like to use TypeScript? prompt.
- Select None for the Which linter would you like to use? prompt.
- Enter No for the Would you like to use React Compiler? prompt.
- Enter Yes for the Would you like to use Tailwind CSS? prompt.
- Enter No for the Would you like your code inside a src/ directory? prompt.
- Enter Yes for the Would you like to use App Router? prompt.
- Enter No for the Would you like to customize the import alias? prompt.
- Enter No for the Would you like to include AGENTS.md? prompt.
The installer will download dependencies and scaffold your project. This takes about 30 seconds.
- Move into the project folder by running:
cd prepai
- Install the Google GenAI SDK by running:
npm install @google/genai
What is @google/genai?
This is Google's official SDK for connecting to the Gemini AI models. It provides a simple interface for sending prompts and receiving AI-generated responses from your server-side code.
- Open the project in VS Code by running:
code .
VS Code opens the prepai folder. You should see the app/ folder in the Explorer sidebar on the left.
Don't see the project files?
Make sure you ran cd prepai before code . so VS Code opens the correct folder. If VS Code opened an empty window, click File then Open Folder and navigate to Desktop/prepai.
help me open my prepai project in VS Code
Configure your API key and update the layout
Your Gemini API key needs to live in an environment variable file that Next.js reads automatically. This keeps the key out of your source code and off GitHub.
- In the VS Code Explorer sidebar, right-click on the prepai root folder (the top-level item).
- Click New File.
- Name the file .env.local and press Enter.
- Add your Gemini API key to the file by pasting the following:
GEMINI_API_KEY=your-api-key-here
- Replace your-api-key-here with the API key you obtained from Google AI Studio earlier.
- Save the file.
Why .env.local?
Next.js automatically loads variables from .env.local into your server-side code. The .local suffix tells Git to ignore this file, so your key never gets committed to version control.
Next, you'll customize the root layout file to set up PrepAI's fonts, metadata, and base styling.
- In the VS Code Explorer, expand the app/ folder and click on layout.tsx to open it.
- Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Replace it with the following code:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
// Load the Inter font for clean, modern typography
const inter = Inter({ subsets: ["latin"] });
// Set the page title and description for browser tabs and search engines
export const metadata: Metadata = {
title: "PrepAI - AI Interview Prep",
description: "AI-powered technical interview preparation tool",
};
// Root layout wraps every page in the app
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-slate-50 text-slate-900 min-h-screen`}>
{children}
</body>
</html>
);
}
What does this code do?
This layout wraps every page in your app. It loads the Inter font from Google Fonts, sets metadata for browser tabs, and applies base Tailwind classes for a light background and dark text.
The min-h-screen class ensures the page background fills the entire viewport height.
- Save app/layout.tsx.
Seeing red underlines in the editor?
TypeScript may show errors briefly while it indexes the new project. Wait a few seconds for the language server to catch up. If errors persist, try closing and reopening the file.
help me fix TypeScript errors in my layout.tsx file
Verify everything works
Time to confirm the dev server starts and your project loads in the browser.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js welcome page. This confirms your project is scaffolded correctly and the dev server is running.
Page not loading?
Check your terminal for error messages. If you see a port conflict, another process may be using port 3000. Close other dev servers or try npm run dev -- -p 3001 to use a different port.
If you see module not found errors, make sure you ran npm install @google/genai inside the prepai folder.
help me fix my Next.js dev server not starting
✔️ Awesome, I've got everything!
Great. Double check you've saved both .env.local and app/layout.tsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point:
GEMINI_API_KEY=your-api-key-here
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "PrepAI - AI Interview Prep",
description: "AI-powered technical interview preparation tool",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-slate-50 text-slate-900 min-h-screen`}>
{children}
</body>
</html>
);
}
Your development environment is ready. Next up, you'll build the landing page where users pick their interview topic and difficulty level.
Build the Landing Page
Your environment is ready and the dev server is running. Now it's time to build the first thing users see when they land on PrepAI.
A good landing page answers two questions immediately: what does this tool do, and how do I start using it? You'll build a page that lets users pick an interview topic and difficulty level, then sends them straight into a practice session.
In this step, get ready to:
- Build a responsive topic selection grid with five interview categories.
- Add a difficulty picker and a start button with client-side navigation.
- Style the page with a hero section and clean Tailwind CSS design.
Create the topic selection cards
The landing page is a single React component that manages which topic and difficulty the user picks. Next.js uses file-based routing, so app/page.tsx is automatically the homepage.
- In VS Code, open app/page.tsx from the Explorer sidebar.
- Select all the existing code and delete it.
- Replace it with the following code that defines the topic data and renders clickable cards:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const topics = [
{ id: "frontend", title: "Frontend", description: "React, CSS, DOM, performance" },
{ id: "backend", title: "Backend", description: "APIs, databases, auth, scaling" },
{ id: "system-design", title: "System Design", description: "Architecture, trade-offs, scalability" },
{ id: "dsa", title: "DSA", description: "Arrays, trees, graphs, algorithms" },
{ id: "fullstack", title: "Full-Stack", description: "End-to-end features, integration" },
];
const difficulties = ["Junior", "Mid", "Senior"];
export default function Home() {
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [selectedDifficulty, setSelectedDifficulty] = useState<string>("Junior");
const router = useRouter();
const handleStart = () => {
if (!selectedTopic) return;
router.push(`/quiz?topic=${selectedTopic}&difficulty=${selectedDifficulty}`);
};
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-slate-900 mb-2">PrepAI</h1>
<p className="text-lg text-slate-600">
AI-powered interview prep that adapts to your level
</p>
</div>
<section className="mb-10">
<h2 className="text-xl font-semibold mb-4">Choose a Topic</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<button
key={topic.id}
onClick={() => setSelectedTopic(topic.id)}
className={`p-5 rounded-xl border-2 text-left transition-all ${
selectedTopic === topic.id
? "border-blue-500 bg-blue-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}
>
<h3 className="font-semibold text-lg">{topic.title}</h3>
<p className="text-sm text-slate-500 mt-1">{topic.description}</p>
</button>
))}
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold mb-4">Select Difficulty</h2>
<div className="flex gap-3">
{difficulties.map((diff) => (
<button
key={diff}
onClick={() => setSelectedDifficulty(diff)}
className={`px-5 py-2 rounded-lg font-medium transition-all ${
selectedDifficulty === diff
? "bg-blue-500 text-white"
: "bg-white border border-slate-200 text-slate-700 hover:border-slate-300"
}`}
>
{diff}
</button>
))}
</div>
</section>
<button
onClick={handleStart}
disabled={!selectedTopic}
className="w-full py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Start Practice
</button>
</main>
);
}
What does this code do?
The "use client" directive at the top tells Next.js this component runs in the browser. You need this because the page uses useState for interactivity.
- The topics array stores the five interview categories. Each object has an id (for URL params), a display title, and a short description.
- The selectedTopic state starts as null and updates when a card is clicked. The card that matches the current selection gets a blue border.
- The topics.map() call renders one <button> per topic. A ternary expression swaps the CSS classes based on whether that card is selected.
- Save app/page.tsx.
- Switch to your browser at http://localhost:3000. You should see the PrepAI heading, five topic cards in a grid, three difficulty buttons, and a Start Practice button at the bottom.
- Click any topic card. It should highlight with a blue border and light blue background.
Page not updating?
- Make sure you saved the file. Unsaved changes will not trigger a refresh.
- Check your terminal for red error text. A missing comma in the topics array or a misplaced backtick in the template literal are common causes.
- Confirm your dev server is still running. If you stopped it, run npm run dev again in the terminal.
Help me fix my PrepAI landing page. I pasted the code but the page is not rendering correctly.
Add the difficulty selector and start button
The difficulty section and start button are already in the code you just pasted. Let's walk through how the navigation logic works.
How does the difficulty selector work?
The difficulties array holds three strings: Junior, Mid, and Senior. The selectedDifficulty state defaults to "Junior" so the user always has a valid selection.
When the user clicks a difficulty button, setSelectedDifficulty updates the state and the active button turns solid blue. The handleStart function uses useRouter from Next.js to navigate to /quiz?topic=frontend&difficulty=Junior (with the actual selections). This passes the user's choices to the quiz page via URL parameters.
- In your browser, click any topic card and then click a different difficulty button (try Senior). The Senior button should turn solid blue while Junior returns to its default style.
- Click Start Practice. Your browser URL should change to something like http://localhost:3000/quiz?topic=frontend&difficulty=Senior.
Why does the start button look faded?
The disabled={!selectedTopic} prop prevents the button from working until the user picks a topic. The disabled:opacity-40 and disabled:cursor-not-allowed Tailwind classes make it visually obvious that a selection is required first.
Verify the responsive layout and styling
The Tailwind classes you used already handle responsive design. The topic grid adapts from one column on mobile to three columns on desktop.
How does the responsive grid work?
The class grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 is a Tailwind CSS responsive pattern. By default (mobile), cards stack in one column. At the sm breakpoint (640px), it switches to two columns. At lg (1024px), it becomes a three-column grid.
The color scheme uses slate for backgrounds and text, with blue as the accent color for selected states. This keeps the page clean and professional for a portfolio piece.
- In your browser, open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the device toggle icon (the phone/tablet icon) in the top-left of DevTools to enable responsive mode.
- Drag the viewport width narrower. The topic cards should collapse from three columns to two, then to a single stack on narrow screens.
Cards not changing layout at different widths?
- Check that the grid container has all three breakpoint classes: grid-cols-1, sm:grid-cols-2, and lg:grid-cols-3.
- Make sure you are resizing the viewport in DevTools responsive mode, not just resizing your browser window (which may not go narrow enough).
Help me debug why my PrepAI topic cards grid is not responsive.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx and that your browser shows the full landing page with topic cards, difficulty buttons, and the Start Practice button.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx file. Compare it with what you have:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const topics = [
{ id: "frontend", title: "Frontend", description: "React, CSS, DOM, performance" },
{ id: "backend", title: "Backend", description: "APIs, databases, auth, scaling" },
{ id: "system-design", title: "System Design", description: "Architecture, trade-offs, scalability" },
{ id: "dsa", title: "DSA", description: "Arrays, trees, graphs, algorithms" },
{ id: "fullstack", title: "Full-Stack", description: "End-to-end features, integration" },
];
const difficulties = ["Junior", "Mid", "Senior"];
export default function Home() {
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [selectedDifficulty, setSelectedDifficulty] = useState<string>("Junior");
const router = useRouter();
const handleStart = () => {
if (!selectedTopic) return;
router.push(`/quiz?topic=${selectedTopic}&difficulty=${selectedDifficulty}`);
};
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-slate-900 mb-2">PrepAI</h1>
<p className="text-lg text-slate-600">
AI-powered interview prep that adapts to your level
</p>
</div>
<section className="mb-10">
<h2 className="text-xl font-semibold mb-4">Choose a Topic</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{topics.map((topic) => (
<button
key={topic.id}
onClick={() => setSelectedTopic(topic.id)}
className={`p-5 rounded-xl border-2 text-left transition-all ${
selectedTopic === topic.id
? "border-blue-500 bg-blue-50"
: "border-slate-200 bg-white hover:border-slate-300"
}`}
>
<h3 className="font-semibold text-lg">{topic.title}</h3>
<p className="text-sm text-slate-500 mt-1">{topic.description}</p>
</button>
))}
</div>
</section>
<section className="mb-10">
<h2 className="text-xl font-semibold mb-4">Select Difficulty</h2>
<div className="flex gap-3">
{difficulties.map((diff) => (
<button
key={diff}
onClick={() => setSelectedDifficulty(diff)}
className={`px-5 py-2 rounded-lg font-medium transition-all ${
selectedDifficulty === diff
? "bg-blue-500 text-white"
: "bg-white border border-slate-200 text-slate-700 hover:border-slate-300"
}`}
>
{diff}
</button>
))}
</div>
</section>
<button
onClick={handleStart}
disabled={!selectedTopic}
className="w-full py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Start Practice
</button>
</main>
);
}
Your landing page is complete. Users can pick a topic, choose their difficulty, and hit Start Practice. Next up, you'll build the API route that calls Gemini to generate real interview questions based on those selections.
Build the AI Question Generation API
Your landing page looks great, but clicking Start Practice leads nowhere useful yet. The quiz page needs questions, and those questions need to come from somewhere intelligent.
In this step, you will build a server-side API route that calls Google Gemini to generate tailored interview questions. The API lives on the server, which means your API key stays hidden from anyone using the app in a browser.
In this step, get ready to:
- Create a server-side API route in Next.js.
- Write a prompt that generates structured interview questions.
- Add error handling for missing keys, bad requests, and AI failures.
Create the API route
In Next.js, any file named route.ts inside the app/api/ directory becomes a server-side endpoint. The folder path maps directly to the URL.
- In VS Code's Explorer sidebar, create a new folder path inside app/ called api/generate/.
- Inside app/api/generate/, create a new file called route.ts.
- Add the following code to set up the route handler with request validation:
import { GoogleGenAI } from "@google/genai";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
// Read the API key from environment variables (stays on the server)
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "API key not configured" },
{ status: 500 }
);
}
// Extract topic and difficulty from the incoming request body
const { topic, difficulty } = await request.json();
if (!topic || !difficulty) {
return NextResponse.json(
{ error: "Topic and difficulty are required" },
{ status: 400 }
);
}
// Initialize the Gemini client with your API key
const ai = new GoogleGenAI({ apiKey });
}
What does this code do?
This sets up the skeleton of your API route. Here is what each section handles:
- GoogleGenAI is the client class from the @google/genai package that talks to the Gemini API.
- NextRequest and NextResponse are Next.js helpers for reading incoming data and sending JSON responses.
- The two if checks catch problems early. If the API key is missing or the frontend forgot to send topic/difficulty, the route returns an error immediately instead of crashing.
What is an API route?
An API route is server-side code that runs on the backend, not in the user's browser. Because it runs on the server, it can safely access secrets like your GEMINI_API_KEY without ever exposing them to the frontend.
When your quiz page sends a POST request to /api/generate, Next.js routes it to this file and runs the POST function.
- Save the file. You will see a TypeScript warning about the function not returning a value on all paths. That is expected and will disappear once you add the Gemini call next.
Seeing a module not found error?
- Make sure you ran npm install @google/genai in Step 1. If you skipped it, run it now in your terminal.
- Confirm the import says @google/genai (not @google/generative-ai which is the deprecated package).
Help me fix my import error in route.ts
Write the prompt and call Gemini
The quality of AI-generated questions depends entirely on how well you write the prompt. A good prompt tells the AI exactly what format to return so your code can reliably parse it.
- In app/api/generate/route.ts, replace the closing } of the POST function with the following code to add the prompt, Gemini call, and response parsing:
const prompt = `You are a technical interviewer. Generate exactly 5 interview questions for a ${difficulty}-level software engineer on the topic of ${topic}.
For each question, provide:
- A clear, specific technical question
- A comprehensive model answer
- 3 key points the candidate should mention
Return ONLY valid JSON in this exact format, no markdown:
[
{
"question": "...",
"modelAnswer": "...",
"keyPoints": ["...", "...", "..."]
}
]`;
try {
// Call the Gemini API with the structured prompt
const response = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: prompt,
});
// Extract the text and strip any markdown code fences the AI might add
const text = response.text ?? "";
const cleaned = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
const questions = JSON.parse(cleaned);
return NextResponse.json({ questions });
} catch (error) {
console.error("Gemini API error:", error);
return NextResponse.json(
{ error: "Failed to generate questions. Please try again." },
{ status: 500 }
);
}
}
What does this code do?
- The prompt uses template literals to inject the user's chosen topic and difficulty. It explicitly asks for JSON output with the exact fields your frontend will expect.
- ai.models.generateContent() sends the prompt to the gemini-3.5-flash model and waits for a response.
- The cleaned variable strips markdown code fences that Gemini sometimes wraps around JSON. Without this cleanup, JSON.parse() would fail.
- The catch block handles any failure from Gemini (rate limits, network errors, malformed responses) and returns a user-friendly error message.
Why ask for JSON in the prompt?
AI models return plain text by default. If you just ask for "5 questions," you might get a numbered list, bullet points, or freeform paragraphs. By specifying the exact JSON structure in the prompt, you get data your code can parse programmatically.
This technique is called structured output prompting. It is one of the most important skills in AI integration.
- Save app/api/generate/route.ts.
- The TypeScript warning from earlier should now be gone since the function returns a response on all code paths.
Still seeing TypeScript errors?
- Check that the closing } at the very end of the file closes the POST function. You should have exactly one opening { after POST(request: NextRequest) and one closing } at the bottom of the file.
- Verify the backtick at the end of the prompt string (after the closing ]) is a backtick and not a regular quote.
Help me fix TypeScript errors in my API route
Test the API route
Your API route is complete. It validates inputs, calls Gemini, cleans the response, and handles errors. Time to verify it actually works.
- Open a new terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Test the API route by sending a POST request with this command:
curl -X POST http://localhost:3000/api/generate -H "Content-Type: application/json" -d '{"topic":"frontend","difficulty":"Junior"}'
- Wait a few seconds for Gemini to respond. You should see a JSON object printed in the terminal containing a questions array with 5 interview questions, each having question, modelAnswer, and keyPoints fields.
Seeing an error response?
- If you see API key not configured, check that your .env.local file exists in the project root and contains your actual Gemini key (not the placeholder text).
- If you see a 500 error from Gemini, your API key may be invalid or you may have hit a rate limit. Verify the key at https://aistudio.google.com/apikey.
- If you changed your .env.local file while the dev server was running, stop the server with Ctrl+C and restart it with npm run dev. Next.js only reads environment variables at startup.
Help me debug my Gemini API route
✔️ Awesome, I've got everything!
Great. Double check you have saved app/api/generate/route.ts and your test returned a JSON response with questions.
ⓧ I'd like to double check the full code
Here is the complete app/api/generate/route.ts file. Make sure yours matches exactly:
import { GoogleGenAI } from "@google/genai";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "API key not configured" },
{ status: 500 }
);
}
const { topic, difficulty } = await request.json();
if (!topic || !difficulty) {
return NextResponse.json(
{ error: "Topic and difficulty are required" },
{ status: 400 }
);
}
const ai = new GoogleGenAI({ apiKey });
const prompt = `You are a technical interviewer. Generate exactly 5 interview questions for a ${difficulty}-level software engineer on the topic of ${topic}.
For each question, provide:
- A clear, specific technical question
- A comprehensive model answer
- 3 key points the candidate should mention
Return ONLY valid JSON in this exact format, no markdown:
[
{
"question": "...",
"modelAnswer": "...",
"keyPoints": ["...", "...", "..."]
}
]`;
try {
const response = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: prompt,
});
const text = response.text ?? "";
const cleaned = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
const questions = JSON.parse(cleaned);
return NextResponse.json({ questions });
} catch (error) {
console.error("Gemini API error:", error);
return NextResponse.json(
{ error: "Failed to generate questions. Please try again." },
{ status: 500 }
);
}
}
Your AI backend is live and generating questions on demand. Next up, you will build the quiz interface that calls this API and lets users practice answering one question at a time.
Build the Quiz Interface
Your API route is ready to generate AI interview questions. Now you need a page that calls it.
In this step, you will build the interactive quiz interface. This page fetches questions from your API, displays them one at a time, lets the user write their own answer before revealing the model answer, and saves the entire session for the results page.
In this step, get ready to:
- Create a quiz page that fetches AI-generated questions from the API route.
- Build an interactive question card with answer input and model answer reveal.
- Track user responses and save session data for the results page.
Create the quiz page
When a user clicks Start Practice on the landing page, they navigate to /quiz with their selected topic and difficulty as URL parameters. You need a page that reads those parameters, calls the API, and manages a loading state while questions generate.
- Create a new folder called quiz inside the app directory.
- Inside app/quiz/, create a new file called page.tsx.
- Add the following code to set up the component, state management, and data fetching:
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
// Define the shape of each question from the API
interface Question {
question: string;
modelAnswer: string;
keyPoints: string[];
}
function QuizContent() {
const searchParams = useSearchParams();
const router = useRouter();
// Read URL params passed from the landing page
const topic = searchParams.get("topic") ?? "";
const difficulty = searchParams.get("difficulty") ?? "";
// State for questions, navigation, answers, and UI
const [questions, setQuestions] = useState<Question[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [userAnswers, setUserAnswers] = useState<string[]>([]);
const [revealed, setRevealed] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// Fetch questions from the API on page load
useEffect(() => {
async function fetchQuestions() {
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic, difficulty }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setQuestions(data.questions);
setUserAnswers(new Array(data.questions.length).fill(""));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load questions";
setError(message);
} finally {
setLoading(false);
}
}
fetchQuestions();
}, [topic, difficulty]);
// Update the current question's answer in the array
const handleAnswerChange = (value: string) => {
const updated = [...userAnswers];
updated[currentIndex] = value;
setUserAnswers(updated);
};
// Advance to next question or finish the session
const handleNext = () => {
setRevealed(false);
if (currentIndex < questions.length - 1) {
setCurrentIndex(currentIndex + 1);
} else {
// Save session data to localStorage for the results page
const session = {
topic,
difficulty,
date: new Date().toISOString(),
questions,
userAnswers,
};
localStorage.setItem("prepai-current-session", JSON.stringify(session));
router.push("/results");
}
};
// Show loading animation while AI generates questions
if (loading) {
return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<div className="animate-pulse">
<div className="text-2xl font-semibold text-slate-700">Generating questions...</div>
<p className="text-slate-500 mt-2">AI is crafting {difficulty}-level {topic} questions</p>
</div>
</div>
);
}
// Show error state with a link back to home
if (error) {
return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<p className="text-red-600 text-lg">{error}</p>
<button onClick={() => router.push("/")} className="mt-4 text-blue-600 underline">
Go back
</button>
</div>
);
}
What does this code do?
This sets up the data layer for the quiz page. The Question interface defines the shape of each question object returned by the API.
The useSearchParams hook reads the topic and difficulty from the URL. The useEffect calls your /api/generate endpoint on page load, storing the response in state.
While the API responds, the component shows a pulsing animation. If the API fails, it displays the error with a link back to the home page.
Build the question card with navigation
Once questions load, the user needs to see them one at a time with a text area to write their answer, a button to reveal the model answer, and navigation to move through all five questions.
- Still in app/quiz/page.tsx, add the following code directly below the error if block (after its closing }):
const current = questions[currentIndex];
return (
<main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<span className="text-sm font-medium text-slate-500">
Question {currentIndex + 1} of {questions.length}
</span>
<span className="text-sm bg-blue-100 text-blue-700 px-3 py-1 rounded-full">
{topic} / {difficulty}
</span>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">{current.question}</h2>
<textarea
value={userAnswers[currentIndex]}
onChange={(e) => handleAnswerChange(e.target.value)}
placeholder="Type your answer here..."
className="w-full h-32 p-3 border border-slate-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{!revealed && (
<button
onClick={() => setRevealed(true)}
className="w-full py-3 bg-slate-800 text-white font-medium rounded-xl hover:bg-slate-900 transition-colors mb-4"
>
Reveal Model Answer
</button>
)}
{revealed && (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
<h3 className="font-semibold text-green-800 mb-2">Model Answer</h3>
<p className="text-slate-700 mb-4">{current.modelAnswer}</p>
<h4 className="font-medium text-green-800 mb-2">Key Points</h4>
<ul className="list-disc list-inside text-slate-600 space-y-1">
{current.keyPoints.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{revealed && (
<button
onClick={handleNext}
className="w-full py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors"
>
{currentIndex < questions.length - 1 ? "Next Question" : "See Results"}
</button>
)}
</main>
);
}
// Wrap in Suspense because useSearchParams requires it for production builds
export default function QuizPage() {
return (
<Suspense fallback={<div className="text-center py-20">Loading...</div>}>
<QuizContent />
</Suspense>
);
}
What does this code do?
The main return renders one question at a time. A progress indicator shows which question the user is on. The textarea stores the user's answer attempt in the userAnswers array.
Clicking Reveal Model Answer shows the AI-generated answer and key points. After revealing, a Next Question button appears. On the last question, the button text changes to See Results.
The QuizPage export wraps QuizContent in a Suspense boundary. This is required by Next.js because useSearchParams triggers client-side rendering up to the nearest Suspense boundary during production builds.
- Save app/quiz/page.tsx.
- Return to your browser at http://localhost:3000.
- Select any topic and difficulty, then click Start Practice.
- You should see a pulsing "Generating questions..." message, followed by the first interview question appearing in a white card with a text area below it.
Seeing an error instead of questions?
- Check that your dev server is still running in the terminal. If it stopped, run npm run dev again.
- Make sure your .env.local file has a valid GEMINI_API_KEY value. If you see "API key not configured", double-check the key is pasted correctly.
- If you see a rate limit error, wait 60 seconds and try again. The Gemini free tier has request limits.
Still stuck? Why aren't my questions loading on the quiz page?
Track user responses and navigate to results
The quiz interface is working, but you need to confirm the full flow. Each user answer is stored in the userAnswers array as the user types. When the user finishes the last question, handleNext saves the entire session to localStorage and navigates to /results.
- In your browser, type an answer in the text area for the first question.
- Click Reveal Model Answer.
- Click Next Question to advance through all five questions.
- On the last question, click See Results. The page navigates to /results (which will show a 404 for now, since you have not built that page yet).
Why localStorage?
The quiz page stores the session in localStorage under the key prepai-current-session. This lets the results page read the data without needing a database or passing everything through URL parameters.
Data in localStorage persists across page refreshes within the same browser. It costs nothing and requires zero backend setup.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/quiz/page.tsx and move on.
ⓧ I'd like to double check the full code
Here is the complete app/quiz/page.tsx file. Make sure yours matches:
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
interface Question {
question: string;
modelAnswer: string;
keyPoints: string[];
}
function QuizContent() {
const searchParams = useSearchParams();
const router = useRouter();
const topic = searchParams.get("topic") ?? "";
const difficulty = searchParams.get("difficulty") ?? "";
const [questions, setQuestions] = useState<Question[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [userAnswers, setUserAnswers] = useState<string[]>([]);
const [revealed, setRevealed] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function fetchQuestions() {
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic, difficulty }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setQuestions(data.questions);
setUserAnswers(new Array(data.questions.length).fill(""));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load questions";
setError(message);
} finally {
setLoading(false);
}
}
fetchQuestions();
}, [topic, difficulty]);
const handleAnswerChange = (value: string) => {
const updated = [...userAnswers];
updated[currentIndex] = value;
setUserAnswers(updated);
};
const handleNext = () => {
setRevealed(false);
if (currentIndex < questions.length - 1) {
setCurrentIndex(currentIndex + 1);
} else {
const session = {
topic,
difficulty,
date: new Date().toISOString(),
questions,
userAnswers,
};
localStorage.setItem("prepai-current-session", JSON.stringify(session));
router.push("/results");
}
};
if (loading) {
return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<div className="animate-pulse">
<div className="text-2xl font-semibold text-slate-700">Generating questions...</div>
<p className="text-slate-500 mt-2">AI is crafting {difficulty}-level {topic} questions</p>
</div>
</div>
);
}
if (error) {
return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<p className="text-red-600 text-lg">{error}</p>
<button onClick={() => router.push("/")} className="mt-4 text-blue-600 underline">
Go back
</button>
</div>
);
}
const current = questions[currentIndex];
return (
<main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<span className="text-sm font-medium text-slate-500">
Question {currentIndex + 1} of {questions.length}
</span>
<span className="text-sm bg-blue-100 text-blue-700 px-3 py-1 rounded-full">
{topic} / {difficulty}
</span>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">{current.question}</h2>
<textarea
value={userAnswers[currentIndex]}
onChange={(e) => handleAnswerChange(e.target.value)}
placeholder="Type your answer here..."
className="w-full h-32 p-3 border border-slate-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{!revealed && (
<button
onClick={() => setRevealed(true)}
className="w-full py-3 bg-slate-800 text-white font-medium rounded-xl hover:bg-slate-900 transition-colors mb-4"
>
Reveal Model Answer
</button>
)}
{revealed && (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
<h3 className="font-semibold text-green-800 mb-2">Model Answer</h3>
<p className="text-slate-700 mb-4">{current.modelAnswer}</p>
<h4 className="font-medium text-green-800 mb-2">Key Points</h4>
<ul className="list-disc list-inside text-slate-600 space-y-1">
{current.keyPoints.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{revealed && (
<button
onClick={handleNext}
className="w-full py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors"
>
{currentIndex < questions.length - 1 ? "Next Question" : "See Results"}
</button>
)}
</main>
);
}
export default function QuizPage() {
return (
<Suspense fallback={<div className="text-center py-20">Loading...</div>}>
<QuizContent />
</Suspense>
);
}
Your quiz interface is live. Users can practice answering interview questions and compare their answers to the AI-generated model answers. Next up, you will build the results dashboard that reads the session from localStorage and shows the user how they did.
Build the Results Dashboard
Your quiz interface generates questions, lets users answer them, and saves the session to localStorage when complete. But right now, clicking "See Results" navigates to a page that does not exist yet.
In this step, you will build the results dashboard that reads session data, displays a summary of the practice session, and tracks the learner's history over time.
In this step, get ready to:
- Create the results page that displays session data from localStorage.
- Save session history so learners can track their practice over time.
- Polish the UI with visual badges, formatted dates, and a clear history option.
Create the results page
When the quiz ends, handleNext saves the session object to localStorage under the key prepai-current-session and navigates to /results. The results page needs to read that data and display it.
- Create a new file at app/results/page.tsx and paste the following code:
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Session {
topic: string;
difficulty: string;
date: string;
questions: { question: string; modelAnswer: string; keyPoints: string[] }[];
userAnswers: string[];
}
export default function ResultsPage() {
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [history, setHistory] = useState<Session[]>([]);
useEffect(() => {
const raw = localStorage.getItem("prepai-current-session");
if (raw) {
const parsed: Session = JSON.parse(raw);
setSession(parsed);
const historyRaw = localStorage.getItem("prepai-history");
const existingHistory: Session[] = historyRaw ? JSON.parse(historyRaw) : [];
const updatedHistory = [parsed, ...existingHistory].slice(0, 20);
localStorage.setItem("prepai-history", JSON.stringify(updatedHistory));
setHistory(updatedHistory);
localStorage.removeItem("prepai-current-session");
} else {
const historyRaw = localStorage.getItem("prepai-history");
if (historyRaw) setHistory(JSON.parse(historyRaw));
}
}, []);
const clearHistory = () => {
localStorage.removeItem("prepai-history");
setHistory([]);
};
if (!session && history.length === 0) {
return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<p className="text-slate-600">No session data found.</p>
<button onClick={() => router.push("/")} className="mt-4 text-blue-600 underline">
Start practicing
</button>
</div>
);
}
return (
<main className="max-w-3xl mx-auto px-4 py-8">
{session && (
<section className="mb-10">
<h1 className="text-3xl font-bold mb-2">Session Complete</h1>
<div className="flex gap-3 mb-6">
<span className="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm font-medium">
{session.topic}
</span>
<span className="bg-slate-100 text-slate-700 px-3 py-1 rounded-full text-sm font-medium">
{session.difficulty}
</span>
<span className="bg-green-100 text-green-700 px-3 py-1 rounded-full text-sm font-medium">
{session.questions.length} questions
</span>
</div>
<div className="space-y-4">
{session.questions.map((q, i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold mb-2">Q{i + 1}: {q.question}</h3>
{session.userAnswers[i] && (
<div className="mb-3">
<span className="text-sm font-medium text-slate-500">Your answer:</span>
<p className="text-slate-700 mt-1">{session.userAnswers[i]}</p>
</div>
)}
<div className="bg-green-50 p-3 rounded-lg">
<span className="text-sm font-medium text-green-700">Model answer:</span>
<p className="text-slate-700 mt-1 text-sm">{q.modelAnswer}</p>
</div>
</div>
))}
</div>
</section>
)}
{history.length > 0 && (
<section className="mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Practice History</h2>
<button onClick={clearHistory} className="text-sm text-red-500 hover:underline">
Clear History
</button>
</div>
<div className="space-y-2">
{history.map((s, i) => (
<div key={i} className="bg-white border border-slate-200 rounded-lg p-3 flex justify-between items-center">
<div>
<span className="font-medium">{s.topic}</span>
<span className="text-slate-400 mx-2">/</span>
<span className="text-slate-600">{s.difficulty}</span>
</div>
<span className="text-sm text-slate-400">
{new Date(s.date).toLocaleDateString()}
</span>
</div>
))}
</div>
</section>
)}
<button
onClick={() => router.push("/")}
className="w-full py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors"
>
Practice Again
</button>
</main>
);
}
What does this code do?
The Session interface defines the shape of the data saved by the quiz page. It includes the topic, difficulty, date, questions array, and the user's answers.
The useEffect runs once on page load. It reads prepai-current-session from localStorage, parses it, and saves it into a history array capped at 20 entries. Then it removes the current session key so refreshing the page does not duplicate the entry.
If no current session exists (the user navigates directly to /results), the component falls back to showing any existing history. If there is nothing at all, it shows an empty state with a link back to the home page.
- Save app/results/page.tsx.
Seeing a blank page at /results?
- Make sure the file is saved at exactly app/results/page.tsx (not app/result/page.tsx or nested inside another folder).
- Check your terminal for TypeScript errors. A missing import or typo in the interface will prevent the page from rendering.
- If you navigated directly to http://localhost:3000/results without completing a quiz first, you will see the empty state message. That is expected.
Help me debug my results page
Save session history
The history logic is already built into the useEffect you just added. Every time a user finishes a quiz and lands on the results page, the current session is prepended to the history array in localStorage.
How does the history persist across sessions?
The key prepai-history stores an array of up to 20 past sessions. Each time a new session arrives, it is placed at the front of the array with [parsed, ...existingHistory].slice(0, 20). This means the most recent session always appears first.
After saving, the code removes prepai-current-session so the results page is safe to refresh without duplicating the entry. If the user returns to the results page later, they see only their accumulated history.
Why localStorage instead of a database?
For a portfolio project, localStorage demonstrates state persistence without requiring you to set up a database, configure authentication, or pay for hosting. It is zero-cost and zero-config.
In a production version you would swap localStorage for a database like PostgreSQL or Supabase. Mentioning this trade-off in your README shows system design awareness to recruiters.
Polish the UI and test end-to-end
The results page already includes several polish details that make it screenshot-ready for your portfolio. Take a look at what is built in.
What visual polish is included?
- Colored badges for topic (blue), difficulty (slate), and question count (green) give an instant visual summary.
- Each question card shows the user's answer and the model answer side by side for easy comparison.
- Dates in the Practice History section are formatted with toLocaleDateString() so they appear in the user's local format.
- The clearHistory function wipes the history from localStorage and resets the state so the UI updates immediately.
Now test the full app flow from start to finish.
- Switch back to your browser at http://localhost:3000.
- Select any topic and difficulty, then click Start Practice.
- Answer each question (or leave them blank), click Reveal Model Answer, then click Next Question until you reach the final question.
- On the last question, click See Results.
You should see the results page with a "Session Complete" heading, colored badges showing your topic and difficulty, and all 5 question cards with your answers and the model answers.
- Click Practice Again to return to the home page.
- Complete a second practice session with a different topic.
- On the results page, scroll down to the Practice History section. You should see both sessions listed with their dates.
History not showing your previous sessions?
- Open your browser's DevTools (press F12) and go to the Application tab. Under Local Storage, check that prepai-history contains a JSON array.
- If you opened the results page in a different browser or incognito window, localStorage is not shared across those contexts.
Help me debug my session history
✔️ Awesome, I've got everything!
Great. Double check you have saved app/results/page.tsx and confirmed the full flow works end-to-end.
ⓧ I'd like to double check the full code
Here is the complete app/results/page.tsx file for comparison:
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Session {
topic: string;
difficulty: string;
date: string;
questions: { question: string; modelAnswer: string; keyPoints: string[] }[];
userAnswers: string[];
}
export default function ResultsPage() {
const router = useRouter();
const [session, setSession] = useState<Session | null>(null);
const [history, setHistory] = useState<Session[]>([]);
useEffect(() => {
const raw = localStorage.getItem("prepai-current-session");
if (raw) {
const parsed: Session = JSON.parse(raw);
setSession(parsed);
const historyRaw = localStorage.getItem("prepai-history");
const existingHistory: Session[] = historyRaw ? JSON.parse(historyRaw) : [];
const updatedHistory = [parsed, ...existingHistory].slice(0, 20);
localStorage.setItem("prepai-history", JSON.stringify(updatedHistory));
setHistory(updatedHistory);
localStorage.removeItem("prepai-current-session");
} else {
const historyRaw = localStorage.getItem("prepai-history");
if (historyRaw) setHistory(JSON.parse(historyRaw));
}
}, []);
const clearHistory = () => {
localStorage.removeItem("prepai-history");
setHistory([]);
};
if (!session && history.length === 0) {
return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<p className="text-slate-600">No session data found.</p>
<button onClick={() => router.push("/")} className="mt-4 text-blue-600 underline">
Start practicing
</button>
</div>
);
}
return (
<main className="max-w-3xl mx-auto px-4 py-8">
{session && (
<section className="mb-10">
<h1 className="text-3xl font-bold mb-2">Session Complete</h1>
<div className="flex gap-3 mb-6">
<span className="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm font-medium">
{session.topic}
</span>
<span className="bg-slate-100 text-slate-700 px-3 py-1 rounded-full text-sm font-medium">
{session.difficulty}
</span>
<span className="bg-green-100 text-green-700 px-3 py-1 rounded-full text-sm font-medium">
{session.questions.length} questions
</span>
</div>
<div className="space-y-4">
{session.questions.map((q, i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-5">
<h3 className="font-semibold mb-2">Q{i + 1}: {q.question}</h3>
{session.userAnswers[i] && (
<div className="mb-3">
<span className="text-sm font-medium text-slate-500">Your answer:</span>
<p className="text-slate-700 mt-1">{session.userAnswers[i]}</p>
</div>
)}
<div className="bg-green-50 p-3 rounded-lg">
<span className="text-sm font-medium text-green-700">Model answer:</span>
<p className="text-slate-700 mt-1 text-sm">{q.modelAnswer}</p>
</div>
</div>
))}
</div>
</section>
)}
{history.length > 0 && (
<section className="mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Practice History</h2>
<button onClick={clearHistory} className="text-sm text-red-500 hover:underline">
Clear History
</button>
</div>
<div className="space-y-2">
{history.map((s, i) => (
<div key={i} className="bg-white border border-slate-200 rounded-lg p-3 flex justify-between items-center">
<div>
<span className="font-medium">{s.topic}</span>
<span className="text-slate-400 mx-2">/</span>
<span className="text-slate-600">{s.difficulty}</span>
</div>
<span className="text-sm text-slate-400">
{new Date(s.date).toLocaleDateString()}
</span>
</div>
))}
</div>
</section>
)}
<button
onClick={() => router.push("/")}
className="w-full py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 transition-colors"
>
Practice Again
</button>
</main>
);
}
Your app now works end-to-end locally. A user can pick a topic, generate AI questions, practice answering them, and review their results with persistent history tracking. Next up, you will deploy this to Vercel so anyone can use it from a live URL.
Deploy to Vercel
Your app is fully functional on your local machine. The quiz works, the AI generates questions, and the results page tracks your history.
Now you need to get PrepAI live on the internet so anyone can visit it. You will push your code to GitHub and deploy it to Vercel, which gives you a public URL in under a minute.
In this step, get ready to:
- Push your code to a GitHub repository.
- Deploy to Vercel and add your API key as an environment variable.
- Verify the live deployment works end-to-end.
Push your code to GitHub
Before Vercel can deploy your app, the code needs to live in a GitHub repository. Vercel watches the repo and automatically rebuilds whenever you push changes.
- Switch back to your terminal and stop the dev server by pressing Ctrl+C.
- Initialize a Git repository and commit your code by running these commands:
git init
git add .
git commit -m "Initial commit"
What do these commands do?
- git init turns the prepai folder into a Git repository so Git can track your files.
- git add . stages every file in the project for the next commit.
- git commit -m "Initial commit" saves a snapshot of all your files with a message describing the change.
- Go to github.com/new in your browser.
- Enter prepai as the repository name.
- Leave the repository set to Public.
- Do NOT check any of the initialization options (no README, no .gitignore, no license).
- Click Create repository.
Why skip the initialization options?
Your project already has code committed locally. If GitHub also creates a README or .gitignore, the two histories will conflict when you try to push.
- Back in your terminal, connect your local repo to GitHub and push by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/prepai.git
git branch -M main
git push -u origin main
- Refresh the GitHub page in your browser. You should see all your project files listed in the repository.
Push failed or permission denied?
- Make sure you replaced the username in the URL with your actual GitHub username.
- If Git asks for credentials, sign in with your GitHub username and a personal access token (not your password).
- help me push my code to GitHub
Deploy to Vercel
Vercel is built by the Next.js team and auto-detects your framework. You just point it at your GitHub repo, add the environment variable for your API key, and click deploy.
- Go to vercel.com and sign up with your GitHub account (or sign in if you already have an account).
- From your Vercel dashboard, click Add New... in the top right, then select Project.
- Find your prepai repository in the list and click Import.
Why deploy with Vercel?
Vercel automatically detects Next.js projects and handles the build configuration for you. Your API route stays server-side, which means your Gemini API key is never exposed to the browser.
The Hobby plan is free forever with no credit card required. Every push to your main branch triggers an automatic redeployment.
- On the configuration page, expand the Environment Variables section.
- In the Name field, enter GEMINI_API_KEY.
- In the Value field, paste your Gemini API key (the same value from your .env.local file).
- Click Deploy.
Why add the API key here?
Your .env.local file is excluded from Git by default (Next.js adds it to .gitignore). Vercel needs its own copy of the key to run your API route in production.
Vercel will build your project and show live build logs. This takes about 30-60 seconds.
- Wait for the build to complete. You should see a success message with a production URL (something like prepai-yourname.vercel.app).
Build failed?
- Check the build logs for a specific error message. The most common cause is a missing environment variable.
- Make sure you typed GEMINI_API_KEY exactly (all caps, underscores) and pasted the full key value.
- help me fix my Vercel build error
Verify the live deployment
- Click the production URL that Vercel shows you (or find it on your project dashboard).
- On the landing page, select any topic and difficulty, then click Start Practice.
- Wait for the AI to generate questions. Answer at least one question, reveal the model answer, and click through to the results page.
You should see the full flow working exactly as it did on localhost. The results page shows your session summary and practice history.
Questions not loading on the live site?
- Open your browser's developer console (F12) and check the Network tab. Look for a failed request to /api/generate.
- If you see a 500 error, your GEMINI_API_KEY environment variable may be missing or incorrect. Go to your Vercel project's Settings tab, then Environment Variables, and verify the key is present.
- After fixing the variable, redeploy by going to Deployments and clicking Redeploy on the latest deployment.
- help me fix my deployed PrepAI app
Your interview prep tool is now live on the internet. Anyone with the URL can practice technical interview questions powered by AI. Next up, you will take on a secret mission to add timed mode and AI-powered answer grading.
Secret mission
Add Timed Mode and AI Answer Grading
Real interviews have time pressure. Real interviewers give feedback. Right now, PrepAI is a flashcard tool. In this secret mission, you'll add a countdown timer that simulates actual interview conditions, plus an AI grading system that scores your free-text answers and tells you exactly what to improve.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free-tier services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Vercel deployment (free Hobby tier, no charges).
- Google AI Studio API key (free tier, no charges).
- Local prepai project folder on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep your live URL available.
- Your Vercel deployment stays live indefinitely on the free Hobby tier.
- Your Gemini API key remains active with free-tier rate limits.
- Your local prepai folder stays on your Desktop.
Pause - I'll come back to this later
No action needed. Free-tier resources do not incur charges when idle.
- Your Vercel deployment will stay live but costs nothing on the Hobby tier.
- Your Gemini API key stays active but is not billed unless you exceed free-tier usage.
- You can stop the local dev server by pressing Ctrl+C in the terminal if it is still running.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Delete your Vercel project:
- Revoke your Gemini API key:
- Delete your local project folder:
macOS
rm -rf ~/Desktop/prepai
Windows
rmdir /s /q %USERPROFILE%\Desktop\prepai
Nice Work!
Nice Work!
You just built and deployed a full-stack AI-powered interview prep tool from scratch. PrepAI is live on the internet with a URL you can share on your resume, your LinkedIn, or with anyone who wants to see what you can build.
You've learned how to:
- Built a full-stack Next.js application with TypeScript and Tailwind CSS, integrating Google Gemini AI to generate contextual interview questions through server-side API routes that keep your API key secure.
- Created an interactive quiz interface with state management, question navigation, answer reveal, and a results dashboard that tracks practice history using localStorage persistence.
- Deployed a production application to Vercel with a live URL ready for your resume and portfolio.
- Secret Mission: Added timed interview mode with countdown pressure and AI-powered answer grading that returns a score and personalized feedback.
Ready to quiz yourself?