Build a Product Design Interview Coach
Vibe code an AI interview coach tailored to any job description.
Introduction
30 Second Summary
Preparing for product design interviews is tough when you don't know what a specific company actually values. Generic practice questions miss the mark because every team looks for different signals.
In this project, you will build an AI-powered interview coach that analyzes real job descriptions and generates tailored practice questions with personalized feedback. You'll vibe code the entire app using Cursor and the Groq API.
What You'll Build
A Next.js app powered by a large language model that parses job descriptions, generates targeted interview questions across categories, and provides AI feedback on your practice answers.
By the end of this project, you'll have:
- A job description analyzer that extracts the skills and design values a hiring team cares about.
- A question generator that creates targeted behavioral, case study, and portfolio review questions.
- A practice interface with AI-powered feedback that scores your answers and gives specific suggestions for improvement.
- Secret Mission: Add a session history feature that tracks your practice sessions and shows improvement over time.
Are there any prerequisites?
You should be comfortable using an IDE and familiar with web development concepts like HTML, CSS, and JavaScript. No prior experience with AI APIs or Next.js is required.
Scaffold the Project in Cursor
To build your interview coach, you need a foundation to work from. Next.js gives you a full-stack framework where your UI pages and API routes live in the same project.
In this step, you will scaffold a Next.js app, install the SDK that connects to the Groq API, and configure your environment so everything is ready for vibe coding.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript and Tailwind.
- Install the Groq-compatible SDK and configure your API key.
- Start the development server and confirm everything works.
Scaffold the Next.js project
The create-next-app CLI scaffolds a complete project with TypeScript, Tailwind CSS, and the App Router in one command. The --yes flag accepts all the defaults so you skip the interactive prompts.
- Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npx create-next-app@latest design-interview-coach --yes
What does this command do?
npx create-next-app@latest downloads and runs the latest version of the Next.js project scaffolding tool.
design-interview-coach is the name of the folder it creates for your project. The --yes flag accepts all default options: TypeScript, ESLint, Tailwind CSS, App Router, and Turbopack.
- Once the command finishes, move into the new project folder by running:
cd design-interview-coach
- Open this folder in Cursor by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the design-interview-coach folder, then click Open.
Command not found?
If you see an error when running npx, confirm Node.js is installed by running node --version in your terminal. You need Node.js 20.9 or higher for Next.js.
help me fix a Node.js or npx error when scaffolding a Next.js project
Install the SDK and configure your API key
Groq's API is OpenAI-compatible, meaning you use the same openai npm package to talk to it. The only difference is the base URL and API key you provide.
- In the Cursor terminal, install the OpenAI SDK by running:
npm install openai
- Confirm the install succeeded by checking that openai now appears in the dependencies section of your package.json file.
Install failing?
Make sure your terminal is inside the design-interview-coach folder. Run pwd (macOS/Linux) or cd (Windows) to see your current directory. If you are not in the project folder, run cd ~/Desktop/design-interview-coach first.
help me troubleshoot npm install failures
Now you need to store your Groq API key as an environment variable so your app can use it without exposing the key in your code.
- In Cursor's Explorer sidebar, right-click in the root of your project and select New File.
- Name the file .env.local and press Enter.
- Paste the following into .env.local:
GROQ_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual Groq API key. If you need to find it, visit https://console.groq.com/keys and copy the key.
- Save .env.local.
Why .env.local?
Next.js automatically loads environment variables from .env.local and keeps them out of version control. Your API key stays on your machine and never gets committed to Git.
Next, update your app's metadata so the browser tab shows a meaningful title.
- In the Explorer sidebar, expand the app folder and open layout.tsx.
- Select all the code in this file and replace it with the following:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Product Design Interview Coach",
description: "AI-powered interview prep tailored to specific job descriptions",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}
What does this code do?
The metadata export sets the browser tab title and the page description for search engines. The RootLayout component wraps every page in your app with the base HTML structure and global styles.
- Save layout.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved both .env.local and layout.tsx.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point:
GROQ_API_KEY=your-api-key-here
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Product Design Interview Coach",
description: "AI-powered interview prep tailored to specific job descriptions",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}
Start the dev server and verify your setup
With the project scaffolded and your key in place, start the development server to confirm everything runs correctly.
- In your Cursor terminal, start the dev server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
- You should see the default Next.js welcome page with the Next.js logo and a message that says Edit page.tsx.
Page not loading?
Check your terminal for errors. If port 3000 is already in use, Next.js will try the next available port and show the URL in the terminal output.
If you see a module not found error, make sure you ran npm install openai from inside the design-interview-coach folder.
help me debug my Next.js dev server not starting
Your project is scaffolded and running locally. Next up, you will use Cursor to vibe code the job description input page where users paste a JD and kick off the AI analysis.
Build the Job Description Input Page
Right now, your app shows the default Next.js welcome page. To turn it into an interview coach, you need a custom interface where users paste a job description and kick off the AI analysis.
This is where vibe coding shines. Instead of writing every line yourself, you'll describe what you want to Cursor and let it generate the UI for you. Your job is crafting the right prompt and verifying the output.
In this step, get ready to:
- Prompt Cursor to replace the default page with a JD input form.
- Review the generated code for correct state management.
- Add a loading spinner for processing feedback.
Prompt Cursor to build the input interface
Cursor's Composer lets you describe a UI in plain English and generates the code for you. You'll open Composer and tell it exactly what elements your input page needs.
- Open Cursor's Composer by pressing Cmd+I (macOS) or Ctrl+I (Windows).
- Type a prompt that describes your new home page. Include these key elements in your description:
- Accept Cursor's generated code when it looks right.
Write specific prompts
The more detail you give Cursor, the closer its output will be to what you need. Mention file paths (like app/page.tsx and components/QuestionCard.tsx), state variable names, and Tailwind classes you want.
If the first generation is not quite right, iterate. Ask Cursor to adjust specific parts rather than regenerating everything.
Review what Cursor generates
Cursor may generate code that looks different from what you expected. The important thing is that the right pieces are in place for the app to work correctly later.
- Open app/page.tsx and verify the following state variables exist at the top of the component:
- jobDescription and companyName as strings for the form inputs.
- loading as a boolean to track processing state.
- questions as an array to hold generated questions.
- analysis to hold the JD analysis result.
- Confirm the form has an onSubmit handler called handleSubmit that calls /api/analyze and /api/questions endpoints.
- Check that components/QuestionCard.tsx exists as a separate file that accepts a question, company name, and analysis as props.
Why check for API routes that do not exist yet?
The handleSubmit function references /api/analyze and /api/questions endpoints that you have not built yet. That is fine. You are building the UI layer first, then wiring it to real API routes in the next steps.
If you try submitting the form now, you will see a network error in the browser console. This is expected behavior until you create those routes.
If Cursor's generated code is missing any of these elements, prompt it again with more specifics. For example: "Add a handleSubmit function that POSTs to /api/analyze then /api/questions."
Add a loading state
When the user submits a job description, the AI will take a few seconds to process it. Without visual feedback, the page looks frozen.
- If your generated code does not already include a loading spinner, open Composer again with Cmd+I (macOS) or Ctrl+I (Windows).
- Prompt Cursor: "Add a loading spinner that displays after the form is submitted and hides when the response comes back. Also disable the submit button while loading."
- Accept the changes.
- Verify that the submit button shows "Analyzing..." text and is disabled while loading is true.
- Verify that a spinning animation appears below the form during loading.
What should the loading state look like?
The button text should change from "Generate Interview Questions" to "Analyzing..." and become unclickable. Below the form, a spinning circle animation gives the user confidence that work is happening in the background.
- Save your files and check http://localhost:3000 in your browser.
- You should see a clean page with a title ("Product Design Interview Coach"), a company name field, a large textarea, and a blue submit button.
Page not loading or showing errors?
If you see a module-not-found error mentioning @/components/QuestionCard, make sure Cursor created the components/QuestionCard.tsx file in your project root. If it is missing, prompt Cursor: "Create a QuestionCard component at components/QuestionCard.tsx that accepts question, companyName, and analysis props."
If the page is blank, open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and check for error messages. Help me debug why my page.tsx is not rendering in the browser.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both app/page.tsx and components/QuestionCard.tsx.
ⓧ I'd like to double check the full code
Here is the expected code for both files. Your Cursor-generated code may differ slightly in formatting or class names, but the key structure and state management should match.
"use client";
import { useState } from "react";
import QuestionCard from "@/components/QuestionCard";
interface Question {
id: number;
category: string;
question: string;
targetSkill: string;
}
interface Analysis {
keySkills: string[];
designValues: string[];
seniorityLevel: string;
interviewFocusAreas: string[];
}
export default function Home() {
const [jobDescription, setJobDescription] = useState("");
const [companyName, setCompanyName] = useState("");
const [loading, setLoading] = useState(false);
const [questions, setQuestions] = useState<Question[]>([]);
const [analysis, setAnalysis] = useState<Analysis | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setQuestions([]);
setAnalysis(null);
try {
const analyzeRes = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobDescription, companyName }),
});
const analysisData = await analyzeRes.json();
setAnalysis(analysisData.analysis);
const questionsRes = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analysis: analysisData.analysis, companyName }),
});
const questionsData = await questionsRes.json();
setQuestions(questionsData.questions);
} catch (error) {
console.error("Error generating questions:", error);
} finally {
setLoading(false);
}
}
const categories = ["behavioral", "case study", "portfolio review"];
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Product Design Interview Coach
</h1>
<p className="text-gray-600 mb-8">
Paste a job description to get tailored interview questions and AI feedback.
</p>
<form onSubmit={handleSubmit} className="space-y-4 mb-8">
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
Company Name
</label>
<input
id="company"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="e.g. Spotify"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label htmlFor="jd" className="block text-sm font-medium text-gray-700 mb-1">
Job Description
</label>
<textarea
id="jd"
value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)}
placeholder="Paste the full job description here..."
rows={10}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Analyzing..." : "Generate Interview Questions"}
</button>
</form>
{loading && (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{analysis && questions.length > 0 && (
<div className="space-y-8">
<div className="bg-white p-6 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold mb-3">JD Analysis</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium text-gray-700">Key Skills</p>
<ul className="list-disc list-inside text-gray-600">
{analysis.keySkills.map((skill, i) => (
<li key={i}>{skill}</li>
))}
</ul>
</div>
<div>
<p className="font-medium text-gray-700">Design Values</p>
<ul className="list-disc list-inside text-gray-600">
{analysis.designValues.map((value, i) => (
<li key={i}>{value}</li>
))}
</ul>
</div>
</div>
</div>
{categories.map((category) => {
const categoryQuestions = questions.filter(
(q) => q.category.toLowerCase() === category
);
if (categoryQuestions.length === 0) return null;
return (
<div key={category}>
<h2 className="text-xl font-semibold capitalize mb-3">{category}</h2>
<div className="space-y-3">
{categoryQuestions.map((q) => (
<QuestionCard key={q.id} question={q} companyName={companyName} analysis={analysis} />
))}
</div>
</div>
);
})}
</div>
)}
</div>
</main>
);
}
"use client";
import { useState } from "react";
interface Question {
id: number;
category: string;
question: string;
targetSkill: string;
}
interface Analysis {
keySkills: string[];
designValues: string[];
seniorityLevel: string;
interviewFocusAreas: string[];
}
interface Props {
question: Question;
companyName: string;
analysis: Analysis;
}
export default function QuestionCard({ question, companyName, analysis }: Props) {
const [expanded, setExpanded] = useState(false);
const [answer, setAnswer] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [score, setScore] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left"
>
<div className="flex justify-between items-start">
<p className="font-medium text-gray-900">{question.question}</p>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded ml-3 whitespace-nowrap">
{question.targetSkill}
</span>
</div>
</button>
{expanded && (
<div className="mt-4 space-y-4">
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Type your answer here..."
rows={8}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
<button
onClick={() => {}}
disabled={loading || answer.trim().length < 50}
className="bg-green-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Evaluating..." : "Get Feedback"}
</button>
</div>
)}
</div>
);
}
Your input page is ready and waiting for data. Next up, you'll build the API route that actually analyzes a job description with AI.
Create the JD Analyzer API Route
Your input form is ready, but when you click Generate Interview Questions, the app has nowhere to send the data. The form already calls /api/analyze, but that route does not exist yet.
In this step, you will create the backend brains of the app. A Route Handler will receive the job description, send it to the Groq LLM for analysis, and return structured insights about what the hiring team values.
In this step, get ready to:
- Create a Route Handler that accepts job descriptions and calls the Groq API.
- Craft a system prompt that extracts hiring signals from a JD.
- Test the full flow by submitting a real job description.
Prompt Cursor to create the route handler
Next.js Route Handlers let you define server-side API endpoints inside the app/ directory. Your front-end form already fetches /api/analyze, so you need a file at app/api/analyze/route.ts to handle that request.
- Open Cursor's Composer by pressing Cmd+I (macOS) or Ctrl+I (Windows).
- Describe what you want Cursor to build. A good prompt includes the file path, what the route accepts, what API it calls, and what it returns. Try something like:
Create a Route Handler at app/api/analyze/route.ts that:
- Accepts a POST request with jobDescription and companyName in the body
- Uses the openai npm package to call Groq's API (baseURL https://api.groq.com/openai/v1, API key from process.env.GROQ_API_KEY)
- Uses the model llama-3.3-70b-versatile
- Has a system prompt that instructs the LLM to analyze the job description and return a JSON object with: keySkills (array of 4-6 specific skills), designValues (array of 3-5 design values), seniorityLevel (string), and interviewFocusAreas (array of 3-4 areas)
- The system prompt should tell the LLM to reference specific phrases from the JD to ground its analysis
- Tell the LLM to return ONLY valid JSON with no markdown formatting
- Set temperature to 0.3 for consistent output
- Parse the JSON response and wrap it in { analysis: parsedResult }
- Include a try/catch for JSON parsing with a fallback object
- Return the result using NextResponse.json()
- Review what Cursor generates. The file should match this structure:
import OpenAI from "openai";
import { NextResponse } from "next/server";
// Initialize the OpenAI client pointed at Groq's API
const client = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
export async function POST(request: Request) {
const { jobDescription, companyName } = await request.json();
const completion = await client.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are an expert product design hiring consultant. Analyze the following job description and extract structured insights about what the hiring team values.
Return a JSON object with exactly these fields:
- keySkills: array of 4-6 specific skills the JD emphasizes (e.g., "prototyping in Figma", "conducting user research", "data-informed design decisions")
- designValues: array of 3-5 design values or principles the company seems to prioritize (e.g., "accessibility-first", "systems thinking", "rapid iteration")
- seniorityLevel: string indicating the level (e.g., "Senior", "Staff", "Mid-level")
- interviewFocusAreas: array of 3-4 areas the interview will likely focus on based on the JD language
Reference specific phrases from the JD to ground your analysis. Do not make generic observations. Every insight should be traceable to something in the JD.
Return ONLY valid JSON, no markdown formatting or code blocks.`,
},
{
role: "user",
content: `Company: ${companyName}\n\nJob Description:\n${jobDescription}`,
},
],
temperature: 0.3,
max_tokens: 1000,
});
// Parse the LLM response as JSON
const content = completion.choices[0].message.content || "{}";
let analysis;
try {
analysis = JSON.parse(content);
} catch {
// Fallback if the LLM returns malformed JSON
analysis = {
keySkills: ["Unable to parse"],
designValues: ["Unable to parse"],
seniorityLevel: "Unknown",
interviewFocusAreas: ["Unable to parse"],
};
}
return NextResponse.json({ analysis });
}
What does this code do?
The OpenAI client is initialized with Groq's base URL instead of OpenAI's. Groq's API is fully compatible with the OpenAI SDK, so you use the same openai npm package you already installed.
The POST function extracts the job description and company name from the request body, sends them to the LLM with a detailed system prompt, then parses the JSON response. If parsing fails, a fallback object prevents the app from crashing.
- If Cursor's output does not match the code above, copy the code above and paste it into app/api/analyze/route.ts directly.
- Save app/api/analyze/route.ts.
Cursor not generating the file?
Make sure you are using Composer (Cmd+I / Ctrl+I) and not inline Chat. Composer can create new files. If it still does not create the file, manually create the folders app/api/analyze/ and add route.ts inside.
Help me create a Route Handler file at app/api/analyze/route.ts in Cursor
Understand the system prompt
The system prompt is the creative core of this app. It tells the LLM exactly how to analyze a job description and what structure to return.
Why does the system prompt matter so much?
In AI-powered apps, the system prompt is your product logic. The code structure is mostly plumbing. The prompt determines whether the output is generic fluff or genuinely useful insights.
This prompt has four key design decisions:
- It defines an exact output schema (keySkills, designValues, seniorityLevel, interviewFocusAreas) so the front end can render the data predictably.
- It includes concrete examples in each field description, which anchors the LLM's output quality.
- It demands grounding ("Reference specific phrases from the JD") to prevent the model from producing generic advice.
- It uses a low temperature (0.3) because you want consistent, factual extraction rather than creative variation.
Why use the OpenAI SDK for Groq?
Groq's API follows the OpenAI-compatible format. You use the same openai npm package with a different baseURL and API key. This means everything you learn here transfers directly to OpenAI, Together, or any other OpenAI-compatible provider.
Test the route with a real job description
The best way to validate your system prompt is to throw a real job description at it. Generic test data produces generic results that hide prompt weaknesses.
- Open your browser to http://localhost:3000.
- Find a real product design job description online (LinkedIn, a company careers page, or any job board).
- Type a company name into the Company Name field.
- Paste the full job description into the Job Description textarea.
- Click Generate Interview Questions.
You should see the loading spinner appear, then a JD Analysis card displaying key skills and design values extracted from the posting.
What should I look for in the output?
A good analysis references specific language from the JD you pasted. If you see vague skills like "design thinking" or "collaboration" without any connection to the actual JD text, your system prompt needs refinement.
The questions section will be empty for now since the /api/questions route does not exist yet. That is expected.
Iterate on your prompt
If the output feels too generic, open app/api/analyze/route.ts and add more specific instructions to the system prompt. Try adding phrases like "Name the exact JD bullet point each skill maps to" or "Distinguish between must-haves and nice-to-haves."
Seeing an error instead of results?
- Check that your .env.local file has GROQ_API_KEY set to a valid key from console.groq.com/keys.
- Make sure your dev server is still running. If you stopped it, run npm run dev again.
- If you see a rate limit error, wait 60 seconds and try again. The free tier allows 30 requests per minute.
Help me debug my Groq API route handler
✔️ Awesome, I've got everything!
Great. Double check you have saved app/api/analyze/route.ts and that your analysis card shows meaningful results when you submit a JD.
ⓧ I'd like to double check the full code
import OpenAI from "openai";
import { NextResponse } from "next/server";
const client = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
export async function POST(request: Request) {
const { jobDescription, companyName } = await request.json();
const completion = await client.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are an expert product design hiring consultant. Analyze the following job description and extract structured insights about what the hiring team values.
Return a JSON object with exactly these fields:
- keySkills: array of 4-6 specific skills the JD emphasizes (e.g., "prototyping in Figma", "conducting user research", "data-informed design decisions")
- designValues: array of 3-5 design values or principles the company seems to prioritize (e.g., "accessibility-first", "systems thinking", "rapid iteration")
- seniorityLevel: string indicating the level (e.g., "Senior", "Staff", "Mid-level")
- interviewFocusAreas: array of 3-4 areas the interview will likely focus on based on the JD language
Reference specific phrases from the JD to ground your analysis. Do not make generic observations. Every insight should be traceable to something in the JD.
Return ONLY valid JSON, no markdown formatting or code blocks.`,
},
{
role: "user",
content: `Company: ${companyName}\n\nJob Description:\n${jobDescription}`,
},
],
temperature: 0.3,
max_tokens: 1000,
});
const content = completion.choices[0].message.content || "{}";
let analysis;
try {
analysis = JSON.parse(content);
} catch {
analysis = {
keySkills: ["Unable to parse"],
designValues: ["Unable to parse"],
seniorityLevel: "Unknown",
interviewFocusAreas: ["Unable to parse"],
};
}
return NextResponse.json({ analysis });
}
Your analyzer is live and extracting real insights from job descriptions. Next up, you will build the question generator that turns these insights into targeted interview practice questions.
Build the Question Generator
Your JD analyzer is extracting structured insights from job descriptions. The next piece of the puzzle is turning those insights into actual interview questions you can practice with.
In this step, you will prompt Cursor to build a second API route that takes the analysis output and generates six targeted interview questions across three categories. You will also complete the question card UI so the generated questions appear as interactive, expandable cards.
In this step, get ready to:
- Create the question generator API route.
- Craft a system prompt that produces targeted questions across three categories.
- Complete the question card UI to display expandable practice cards.
Prompt Cursor to create the questions route handler
The question generator receives the structured analysis from the previous route and uses it to produce questions tailored to the specific role. Each question targets a skill or value the JD signals.
- Open Cursor's chat (Cmd+Shift+I on macOS, Ctrl+Shift+I on Windows) or use Composer.
- Prompt Cursor to create a new Route Handler. Describe what you want: a POST route at app/api/questions/route.ts that accepts the JD analysis object and company name, calls the Groq API using the OpenAI SDK, and returns a JSON array of 6 interview questions.
- Review what Cursor generates. Make sure it matches this structure:
import OpenAI from "openai";
import { NextResponse } from "next/server";
// Initialize the Groq client using OpenAI-compatible SDK
const client = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
export async function POST(request: Request) {
const { analysis, companyName } = await request.json();
const completion = await client.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are a product design interview coach. Based on the JD analysis provided, generate 6 tailored interview questions for a product design role at the specified company.
Generate exactly:
- 2 behavioral questions
- 2 case study questions
- 2 portfolio review questions
Return a JSON array of objects, each with:
- id: number (1-6)
- category: string ("behavioral", "case study", or "portfolio review")
- question: string
- targetSkill: string
Return ONLY valid JSON, no markdown formatting or code blocks.`,
},
{
role: "user",
content: `Company: ${companyName}\n\nJD Analysis:\n${JSON.stringify(analysis, null, 2)}`,
},
],
temperature: 0.7,
max_tokens: 1500,
});
// Parse the LLM response as JSON, falling back to empty array
const content = completion.choices[0].message.content || "[]";
let questions;
try {
questions = JSON.parse(content);
} catch {
questions = [];
}
return NextResponse.json({ questions });
}
What does this code do?
This route handler follows the same pattern as your analyze route but with a different purpose and different prompt settings.
- It accepts the analysis object (skills, values, focus areas) and companyName from the request body.
- The temperature: 0.7 setting is higher than the analyzer's 0.3 because you want creative variety in question generation.
- The try/catch block handles cases where the LLM returns malformed JSON, gracefully falling back to an empty array.
- Save app/api/questions/route.ts.
Cursor generated something different?
If Cursor's output uses a different model name or base URL, update it to match the code above. The model must be llama-3.3-70b-versatile and the base URL must be https://api.groq.com/openai/v1.
If Cursor put the file in the wrong location, move it so the path is exactly app/api/questions/route.ts.
help me fix my questions route handler
Understand the system prompt design
The system prompt is the most important part of this route. It determines the quality and relevance of every question the app generates.
Why does the prompt structure matter?
The prompt tells the LLM to generate exactly 2 questions per category (behavioral, case study, portfolio review). This ensures balanced interview coverage. Each question also includes a targetSkill field that maps back to the JD analysis, so the learner knows which competency each question is testing.
By passing the full analysis object in the user message, the LLM can reference specific skills and values when crafting questions. This is what makes the questions tailored rather than generic.
Iterate on your prompt
If the generated questions feel too generic, try adding more instructions to the system prompt. For example, you could tell it to make case studies reference realistic scenarios related to the company's product domain, or to ensure behavioral questions probe collaboration and conflict resolution.
Complete the QuestionCard UI
The home page already calls the questions endpoint and renders QuestionCard components grouped by category. Now you need to flesh out the card so learners can click to expand it, type an answer, and eventually get feedback.
- Prompt Cursor to update components/QuestionCard.tsx with an expand/collapse toggle, an answer textarea with helpful placeholder text, and a Get Feedback button. The button does not need to call an API yet.
- Review the output. Make sure it matches this structure:
"use client";
import { useState } from "react";
interface Question {
id: number;
category: string;
question: string;
targetSkill: string;
}
interface Analysis {
keySkills: string[];
designValues: string[];
seniorityLevel: string;
interviewFocusAreas: string[];
}
interface Props {
question: Question;
companyName: string;
analysis: Analysis;
}
export default function QuestionCard({ question, companyName, analysis }: Props) {
const [expanded, setExpanded] = useState(false);
const [answer, setAnswer] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [score, setScore] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
{/* Clickable header toggles the practice area */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left"
>
<div className="flex justify-between items-start">
<p className="font-medium text-gray-900">{question.question}</p>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded ml-3 whitespace-nowrap">
{question.targetSkill}
</span>
</div>
</button>
{/* Expanded practice area with answer input */}
{expanded && (
<div className="mt-4 space-y-4">
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Type your answer here... Aim for 2-3 minutes of speaking time (roughly 300-500 words)."
rows={8}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
<button
onClick={() => {}}
disabled={loading || answer.trim().length < 50}
className="bg-green-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Evaluating..." : "Get Feedback"}
</button>
</div>
)}
</div>
);
}
What does this component do?
Each card displays the question text and the JD skill it targets. Clicking the card expands it to reveal a textarea for practicing your answer.
The Get Feedback button is disabled until the answer is at least 50 characters. This prevents evaluating responses that are too short to assess meaningfully. You will wire this button to the evaluate API in the next step.
- Save components/QuestionCard.tsx.
- Open your browser at http://localhost:3000. Paste a job description, enter a company name, and click Generate Interview Questions.
- You should see the JD Analysis panel appear, followed by question cards grouped under "behavioral", "case study", and "portfolio review" headings. Clicking a card expands it to show the answer textarea.
Questions not appearing?
Check your terminal for errors. If you see a 404 on /api/questions, make sure the file is saved at exactly app/api/questions/route.ts (not inside a subfolder or with a typo).
If questions come back empty, open the browser console and check the network response. The LLM may have returned malformed JSON. Try submitting again, as occasional parse failures are normal with free-tier usage.
help me debug why my question cards are not showing up
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the full code for the two files you created or updated in this step.
import OpenAI from "openai";
import { NextResponse } from "next/server";
const client = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
export async function POST(request: Request) {
const { analysis, companyName } = await request.json();
const completion = await client.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are a product design interview coach. Based on the JD analysis provided, generate 6 tailored interview questions for a product design role at the specified company.
Generate exactly:
- 2 behavioral questions
- 2 case study questions
- 2 portfolio review questions
Return a JSON array of objects, each with:
- id: number (1-6)
- category: string ("behavioral", "case study", or "portfolio review")
- question: string
- targetSkill: string
Return ONLY valid JSON, no markdown formatting or code blocks.`,
},
{
role: "user",
content: `Company: ${companyName}\n\nJD Analysis:\n${JSON.stringify(analysis, null, 2)}`,
},
],
temperature: 0.7,
max_tokens: 1500,
});
const content = completion.choices[0].message.content || "[]";
let questions;
try {
questions = JSON.parse(content);
} catch {
questions = [];
}
return NextResponse.json({ questions });
}
"use client";
import { useState } from "react";
interface Question {
id: number;
category: string;
question: string;
targetSkill: string;
}
interface Analysis {
keySkills: string[];
designValues: string[];
seniorityLevel: string;
interviewFocusAreas: string[];
}
interface Props {
question: Question;
companyName: string;
analysis: Analysis;
}
export default function QuestionCard({ question, companyName, analysis }: Props) {
const [expanded, setExpanded] = useState(false);
const [answer, setAnswer] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [score, setScore] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left"
>
<div className="flex justify-between items-start">
<p className="font-medium text-gray-900">{question.question}</p>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded ml-3 whitespace-nowrap">
{question.targetSkill}
</span>
</div>
</button>
{expanded && (
<div className="mt-4 space-y-4">
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Type your answer here... Aim for 2-3 minutes of speaking time (roughly 300-500 words)."
rows={8}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
<button
onClick={() => {}}
disabled={loading || answer.trim().length < 50}
className="bg-green-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Evaluating..." : "Get Feedback"}
</button>
</div>
)}
</div>
);
}
Your question generator is live and the full flow from JD input to categorized question cards is working. Next up, you will build the answer evaluator that gives you specific, actionable feedback on your practice responses.
Build the Practice and Feedback Interface
You have an app that analyzes job descriptions and generates tailored questions. The missing piece is the feedback loop. Right now, clicking a question expands a textarea and a button that does nothing.
In this step, you will create the evaluator API route and wire it to the practice interface. This is where the system prompt matters most. A good evaluator prompt turns generic "nice job" responses into actionable coaching tied to what the JD actually asks for.
In this step, get ready to:
- Create the evaluator Route Handler with a detailed scoring system prompt.
- Wire the QuestionCard component to call the evaluator and display feedback.
- Test the full end-to-end flow from JD input to scored feedback.
Create the evaluator Route Handler
The evaluator is where your app becomes genuinely useful for interview prep. The system prompt instructs the LLM to role-play as a hiring manager and evaluate answers against a specific rubric.
- Open Cursor's Composer by pressing Cmd+I (macOS) or Ctrl+I (Windows).
- Prompt Cursor to create a new Route Handler at app/api/evaluate/route.ts that accepts a POST request with the question, answer, category, target skill, company name, and JD analysis, then calls the Groq API and returns a score (1-5) with detailed feedback.
- The key instruction for your prompt: tell Cursor the system prompt should make the LLM evaluate against problem framing, user empathy, structured thinking, communication clarity, and specificity.
After Cursor generates the file, verify it looks like this:
import OpenAI from "openai";
import { NextResponse } from "next/server";
const client = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
export async function POST(request: Request) {
const { question, answer, category, targetSkill, companyName, analysis } =
await request.json();
const completion = await client.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are a senior product design hiring manager at ${companyName}. You are evaluating a candidate's answer to an interview question.
Evaluate the answer against:
1. The specific skill being tested: "${targetSkill}"
2. The company's design values: ${JSON.stringify(analysis.designValues)}
3. Standard product design interview rubrics:
- Problem framing: Did they clarify the problem before jumping to solutions?
- User empathy: Did they center the user's needs and context?
- Structured thinking: Was their response organized and logical?
- Communication clarity: Was the answer concise and easy to follow?
- Specificity: Did they use concrete examples rather than vague generalities?
Provide:
1. A score from 1-5 (1=poor, 2=below average, 3=adequate, 4=strong, 5=exceptional)
2. 2-3 specific things they did well (reference exact phrases from their answer)
3. 2-3 specific improvements with concrete suggestions for how to strengthen the answer
4. One example of how a top candidate might frame part of their answer differently
Be direct and constructive. Avoid generic praise. Reference the specific JD requirements when relevant.
Return a JSON object with:
- score: number (1-5)
- feedback: string (your full evaluation as readable text with line breaks)
Return ONLY valid JSON, no markdown formatting or code blocks.`,
},
{
role: "user",
content: `Question (${category}): ${question}\n\nCandidate's Answer:\n${answer}`,
},
],
temperature: 0.4,
max_tokens: 1500,
});
const content = completion.choices[0].message.content || "{}";
let result;
try {
result = JSON.parse(content);
} catch {
result = { score: 3, feedback: "Unable to parse evaluation. Please try again." };
}
return NextResponse.json(result);
}
What makes this system prompt effective?
The prompt assigns a persona ("senior product design hiring manager at [company]") so the LLM grounds its evaluation in what that specific company cares about.
It injects the JD analysis dynamically with ${JSON.stringify(analysis.designValues)} so every evaluation is tailored to the actual role. The five rubric dimensions (problem framing, user empathy, structured thinking, communication clarity, specificity) give the LLM a concrete framework instead of vague "was this good?" instructions.
Temperature is set to 0.4 for consistent scoring with some flexibility in how the feedback is phrased.
- Save app/api/evaluate/route.ts.
Cursor generated something different?
Cursor may structure the system prompt differently or use different variable names. The important parts to check: it imports from openai, uses the Groq base URL https://api.groq.com/openai/v1, model llama-3.3-70b-versatile, and returns JSON with score and feedback fields. If those match, you are good.
Help me debug my evaluator route
Wire the QuestionCard to call the evaluator
The QuestionCard component already has the answer textarea and a Get Feedback button. The button currently does nothing. You need to connect it to the new evaluator endpoint.
- Prompt Cursor to update components/QuestionCard.tsx: add a handleGetFeedback function that POSTs the question, answer, category, target skill, company name, and analysis to /api/evaluate, then displays the score and feedback below the textarea.
- Tell Cursor to color-code the score: green for 4 or higher, yellow for 3, and red for below 3.
After Cursor generates the changes, verify your QuestionCard.tsx includes the handleGetFeedback function and the feedback display section:
async function handleGetFeedback() {
setLoading(true);
setFeedback(null);
setScore(null);
try {
const res = await fetch("/api/evaluate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: question.question,
answer,
category: question.category,
targetSkill: question.targetSkill,
companyName,
analysis,
}),
});
const data = await res.json();
setFeedback(data.feedback);
setScore(data.score);
} catch (error) {
console.error("Error getting feedback:", error);
} finally {
setLoading(false);
}
}
What does this function do?
The handleGetFeedback function sends everything the evaluator needs in one request: the question text, your answer, the question's category, the skill being tested, the company name, and the full JD analysis. This gives the LLM all the context to evaluate your answer against what the specific role demands.
- Verify that the button is wired to call handleGetFeedback. In components/QuestionCard.tsx, find the button and confirm it says onClick={handleGetFeedback}.
- Confirm the feedback display section exists below the button. It should show a color-coded score and the feedback text:
{score !== null && feedback && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-700">Score:</span>
<span className={`text-lg font-bold ${score >= 4 ? "text-green-600" : score >= 3 ? "text-yellow-600" : "text-red-600"}`}>
{score}/5
</span>
</div>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{feedback}</p>
</div>
)}
How does the score color-coding work?
The ternary expression checks the score value: 4 or 5 renders in green (strong/exceptional), 3 renders in yellow (adequate), and 1-2 renders in red (needs improvement). This gives you an instant visual signal before reading the detailed feedback.
- Save components/QuestionCard.tsx.
Button still does nothing when clicked?
Check that the button's onClick is set to handleGetFeedback and not () => {}. Also confirm your answer is at least 50 characters long. The button stays disabled until you write enough text.
Why isn't my Get Feedback button working?
Test the full end-to-end flow
Your app now has all three system prompts wired together in a chain: JD analysis feeds question generation, which feeds the practice interface, which feeds evaluation. Time to test the complete loop.
- Open http://localhost:3000 in your browser.
- Paste a real product design job description into the textarea. You can find one on LinkedIn or any job board.
- Enter the company name and click Generate Interview Questions.
- Click any generated question to expand it.
- Write a practice answer (at least 50 characters) and click Get Feedback.
- You should see a color-coded score (1-5) and detailed feedback appear below your answer within a few seconds.
Is the feedback too generic?
If the evaluator gives vague praise like "good answer" without referencing your specific words, the system prompt might need iteration. Try adding the instruction "Reference specific phrases from the candidate's answer" to your evaluator prompt. In a vibe coding workflow, you iterate on prompts until the output meets your quality bar.
Getting an error when requesting feedback?
Open your browser's DevTools console (press F12) and check for errors. If you see a 500 status, look at your terminal where npm run dev is running for the server-side error. Common causes: the GROQ_API_KEY in .env.local is missing or invalid, or the route file has a syntax error.
Help me fix my evaluate API route error
✔️ Awesome, I've got everything!
Double check you have saved both app/api/evaluate/route.ts and components/QuestionCard.tsx.
ⓧ I'd like to double check the full code
import OpenAI from "openai";
import { NextResponse } from "next/server";
const client = new OpenAI({
apiKey: process.env.GROQ_API_KEY,
baseURL: "https://api.groq.com/openai/v1",
});
export async function POST(request: Request) {
const { question, answer, category, targetSkill, companyName, analysis } =
await request.json();
const completion = await client.chat.completions.create({
model: "llama-3.3-70b-versatile",
messages: [
{
role: "system",
content: `You are a senior product design hiring manager at ${companyName}. You are evaluating a candidate's answer to an interview question.
Evaluate the answer against:
1. The specific skill being tested: "${targetSkill}"
2. The company's design values: ${JSON.stringify(analysis.designValues)}
3. Standard product design interview rubrics:
- Problem framing: Did they clarify the problem before jumping to solutions?
- User empathy: Did they center the user's needs and context?
- Structured thinking: Was their response organized and logical?
- Communication clarity: Was the answer concise and easy to follow?
- Specificity: Did they use concrete examples rather than vague generalities?
Provide:
1. A score from 1-5 (1=poor, 2=below average, 3=adequate, 4=strong, 5=exceptional)
2. 2-3 specific things they did well (reference exact phrases from their answer)
3. 2-3 specific improvements with concrete suggestions for how to strengthen the answer
4. One example of how a top candidate might frame part of their answer differently
Be direct and constructive. Avoid generic praise. Reference the specific JD requirements when relevant.
Return a JSON object with:
- score: number (1-5)
- feedback: string (your full evaluation as readable text with line breaks)
Return ONLY valid JSON, no markdown formatting or code blocks.`,
},
{
role: "user",
content: `Question (${category}): ${question}\n\nCandidate's Answer:\n${answer}`,
},
],
temperature: 0.4,
max_tokens: 1500,
});
const content = completion.choices[0].message.content || "{}";
let result;
try {
result = JSON.parse(content);
} catch {
result = { score: 3, feedback: "Unable to parse evaluation. Please try again." };
}
return NextResponse.json(result);
}
"use client";
import { useState } from "react";
interface Question {
id: number;
category: string;
question: string;
targetSkill: string;
}
interface Analysis {
keySkills: string[];
designValues: string[];
seniorityLevel: string;
interviewFocusAreas: string[];
}
interface Props {
question: Question;
companyName: string;
analysis: Analysis;
}
export default function QuestionCard({ question, companyName, analysis }: Props) {
const [expanded, setExpanded] = useState(false);
const [answer, setAnswer] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [score, setScore] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
async function handleGetFeedback() {
setLoading(true);
setFeedback(null);
setScore(null);
try {
const res = await fetch("/api/evaluate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question: question.question,
answer,
category: question.category,
targetSkill: question.targetSkill,
companyName,
analysis,
}),
});
const data = await res.json();
setFeedback(data.feedback);
setScore(data.score);
} catch (error) {
console.error("Error getting feedback:", error);
} finally {
setLoading(false);
}
}
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left"
>
<div className="flex justify-between items-start">
<p className="font-medium text-gray-900">{question.question}</p>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded ml-3 whitespace-nowrap">
{question.targetSkill}
</span>
</div>
</button>
{expanded && (
<div className="mt-4 space-y-4">
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Type your answer here... Aim for 2-3 minutes of speaking time (roughly 300-500 words)."
rows={8}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
<button
onClick={handleGetFeedback}
disabled={loading || answer.trim().length < 50}
className="bg-green-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Evaluating..." : "Get Feedback"}
</button>
{score !== null && feedback && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-700">Score:</span>
<span className={`text-lg font-bold ${score >= 4 ? "text-green-600" : score >= 3 ? "text-yellow-600" : "text-red-600"}`}>
{score}/5
</span>
</div>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{feedback}</p>
</div>
)}
</div>
)}
</div>
);
}
Your interview coach is fully functional. You have a complete pipeline: paste a JD, get targeted questions, practice answering, and receive structured feedback scored against what the hiring team actually cares about. Next up, the Secret Mission challenges you to add session history so you can track your improvement over time.
Secret mission
Add Session History
Your interview coach works great for a single session. But real improvement happens over time. Can you build a session history feature that tracks your practice attempts and shows your progress across sessions?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses the Groq free tier, so there are no ongoing costs.
Resources you used:
- The design-interview-coach project folder on your Desktop (includes all source code and node_modules).
- Your Groq API key (stored in .env.local and on the Groq console).
Keep everything running
No action needed. Choose this if you are still actively building or plan to use the interview coach for real prep sessions.
- Your project folder stays on your Desktop. Run npm run dev from inside the folder any time you want to practice.
- Your Groq API key remains active. The free tier has no charges, so leaving it active costs nothing.
Pause - I'll come back to this later
Stop the development server to free up your terminal. Your files and API key stay intact for next time.
- In the terminal where the dev server is running, press Ctrl+C to stop it.
- Your project folder and .env.local file remain on disk. When you are ready to resume, open the folder in Cursor and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and revoke your API key so nothing is left behind.
- Stop the dev server by pressing Ctrl+C in the terminal.
- Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/design-interview-coach
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\design-interview-coach
- Revoke your Groq API key. Go to https://console.groq.com/keys, find the key you created for this project, and click the delete button next to it.
Nice Work!
Nice Work!
You just vibe coded a full AI-powered interview prep tool from scratch. From a blank terminal to a working product design coach, you built the entire thing through Cursor prompts and smart system prompt design.
You learned how to:
- Built a Next.js app entirely through Cursor prompts, using vibe coding to ship a full-stack application without writing boilerplate by hand.
- Created three distinct system prompts (JD analyzer, question generator, answer evaluator) that chain together to produce tailored, actionable interview feedback.
- Integrated the Groq API for fast, free LLM inference using the OpenAI-compatible SDK pattern that transfers to any provider.
- Secret Mission: Added session history with progress tracking to see yourself improve over time.
Ready to quiz yourself?