AI Resume Screening System
Build a full-stack AI recruiter dashboard that scores and ranks candidates.
Introduction
30 Second Summary
Hiring managers spend hours reading through resumes trying to figure out which candidates actually have the right skills for a role. What if you could paste a resume into a tool and instantly see a match score with a breakdown of exactly which skills line up?
In this project, you will build a full-stack AI resume screening system that takes raw resume text, analyzes it with Google Gemini AI, and ranks candidates by how well they match a job posting's requirements.
What You'll Build
Imagine pasting a candidate's resume into your app and watching it instantly surface a match score, extracted skills, and a per-skill breakdown that tells you exactly where each candidate stands against the role.
By the end of this project, you'll have:
- A working AI resume parser where you paste raw text and watch it get broken down into structured skills, experience, and education data in seconds.
- An automated scoring pipeline that rates each candidate 0-100 against a job posting's required skills and reorders the list by best fit.
- A deployed recruiter dashboard live on Vercel where you can expand any candidate to see their full AI analysis and skill-gap breakdown.
- Secret Mission: a side-by-side candidate comparison view that highlights shared and unique skills between your top picks.
Are there any prerequisites?
None. This guide includes full setup instructions for every tool you need, including Node.js, Next.js, Convex, and a free Google Gemini API key.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Development Environment
Your AI resume screening system needs several tools working in harmony before you can build anything visible. You need a JavaScript runtime to execute code, a React framework to build the interface, a real-time backend to store data, and an AI service to analyze resumes.
In this step, you will install Node.js, scaffold a Next.js project with Shadcn UI, connect a Convex backend, and store your Google Gemini API key.
In this step, get ready to:
- Install Node.js and create a new Next.js project.
- Add Convex and Shadcn UI to the project.
- Wire up the Convex provider and store a Gemini API key.
Install Node.js and create the project
Node.js is the JavaScript runtime that powers everything in this project. Next.js, Convex, and Shadcn UI all depend on it.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check if Node.js is already installed by running:
node --version
✔️ I see a version number
Node.js is installed and ready to use. Move on to creating your project below.
ⓧ Command not found
- Download the Node.js LTS installer from nodejs.org.
- Run the installer and accept the defaults.
- Close and reopen your terminal, then run node --version again to confirm the installation.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Next.js app by running this command:
npx create-next-app@latest resume-screener
What prompts will I see?
The command will ask you several setup questions. When you see the first prompt asking whether to use the recommended defaults, select No, customize settings. Then answer each prompt as follows:
- Would you like to use TypeScript? Yes
- Which linter would you like to use? ESLint
- Would you like to use React Compiler? No
- Would you like to use Tailwind CSS? Yes
- Would you like your code inside a src/ directory? No
- Would you like to use App Router? Yes
- Would you like to customize the import alias? No (keeps the default @/*)
- Would you like to include AGENTS.md? No
Once the prompts are answered, the command installs dependencies and creates the resume-screener folder on your Desktop.
- Navigate into your new project by running:
cd resume-screener
- Confirm the project was created by running:
ls
You should see files like package.json, next.config.ts, and an app folder in the output.
Command not found or install failed?
- If you see npx: command not found, Node.js is not installed correctly. Go back to the Node.js install step above.
- If the install stalls, check your internet connection and try again.
Need help? Why is my create-next-app command failing?
Add Convex and Shadcn UI
Convex is a real-time backend that stores your data and runs serverless functions. Shadcn UI provides polished, accessible components you can paste directly into your project.
- Install the Convex package by running:
npm install convex
- Start the Convex development server by running:
npx convex dev
What happens on first run?
This command opens your browser and asks you to log in with GitHub. After logging in, it prompts you to create a new Convex project. Enter a name like resume-screener when prompted.
Once configured, Convex creates a convex/ folder in your project and a .env.local file containing your NEXT_PUBLIC_CONVEX_URL. It also asks about installing AI helper files. You can decline these. Keep this terminal running.
You should see Convex watching for file changes and displaying logs in this terminal. Leave it running and open a new terminal window.
- In your new terminal, navigate back into the project by running:
cd ~/Desktop/resume-screener
- Initialize Shadcn UI by running:
npx shadcn@latest init
What prompts will I see?
Shadcn asks about style, base color, and CSS variables. Press Enter to accept the defaults for each prompt.
- Add the UI components you will need by running:
npx shadcn@latest add button card input label textarea badge
You should see each component added to a components/ui/ folder in your project.
Shadcn init failed?
- Make sure you are inside the resume-screener folder before running the init command.
- Shadcn requires Tailwind CSS to be present. If you skipped Tailwind during the Next.js setup, recreate the project with Tailwind enabled.
Need help? Why is Shadcn UI init failing in my Next.js project?
Wire up the Convex provider and get your Gemini API key
For Convex to talk to your React components, you need a provider component that wraps your entire app. You also need a Gemini API key so the backend can call Google's AI later.
- Create a new file called app/ConvexClientProvider.tsx in your code editor and paste the following code:
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
// Create a Convex client using the deployment URL from .env.local
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
// Wrap children with the provider so all components can access Convex
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
What does this code do?
- It creates a single Convex client connected to your deployment URL (which npx convex dev saved in .env.local).
- It exports a provider component that makes the Convex client available to every React component in your app.
- The "use client" directive tells Next.js this runs in the browser, since Convex needs a live connection.
- Now open app/layout.tsx and replace its entire contents with:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ConvexClientProvider } from "./ConvexClientProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AI Resume Screener",
description: "AI-powered resume screening and candidate shortlisting system",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
What changed in this file?
- You imported and wrapped the app with ConvexClientProvider so every page and component can use Convex hooks.
- The metadata sets your app title to "AI Resume Screener" in the browser tab.
- Save both files.
- Start the Next.js dev server by running this in the same terminal:
npm run dev
Before you check the browser, what do you think you will see at localhost:3000?
Open http://localhost:3000 in your browser. You should see the default Next.js welcome page. This confirms your frontend and Convex provider are both working.
Page not loading?
- Make sure npx convex dev is still running in your other terminal. If it stopped, run it again.
- Check that .env.local exists in your project root and contains NEXT_PUBLIC_CONVEX_URL. Convex creates this automatically on first run.
- If you see a build error mentioning ConvexClientProvider, double-check that the file is at app/ConvexClientProvider.tsx (capital C, capital P).
Need help? My Next.js app is not loading after adding the Convex provider.
Now you need a Gemini API key so your backend can call Google's AI for resume analysis.
- Open aistudio.google.com in your browser and sign in with your Google account.
- Click Get API Key in the left sidebar.
- Click Create API Key and copy the generated key.
- Open the Convex Dashboard at dashboard.convex.dev.
- Select your resume-screener project.
- Go to Settings, then Environment Variables.
- Add a new variable with the name GEMINI_API_KEY and paste your copied key as the value.
- Click Save.
Why store the key in Convex, not in .env.local?
Convex backend functions (actions) read environment variables from the Convex Dashboard, not from your local .env files. Storing the key here means your serverless screening action can access it at runtime without exposing it to the browser.
You should now see GEMINI_API_KEY listed in your Convex Dashboard environment variables.
✔️ Awesome, I've got everything!
Great. Make sure both terminals are still running: npx convex dev in one, and npm run dev in the other.
ⓧ I'd like to double check the full code
Here is the complete code for the two files you created in this step. Compare them to yours:
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ConvexClientProvider } from "./ConvexClientProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AI Resume Screener",
description: "AI-powered resume screening and candidate shortlisting system",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
Your development environment is fully wired up. You have a running frontend, a live Convex backend, and a Gemini API key ready for AI screening. Next up, you will build the job posting and candidate input UI.
Build the Job Posting and Candidate Input UI
Your development environment is running and the backend is connected. But right now localhost:3000 still shows the default Next.js page.
Before wiring up any backend logic, you need a visible interface to interact with. Building the UI first proves your frontend stack (Next.js + Shadcn UI + Tailwind CSS) works correctly and gives you something tangible to see in the browser.
In this step, get ready to:
- Build a job posting form component with title, description, and skills fields.
- Build a candidate input form where recruiters paste resume text.
- Assemble both forms into a two-column page layout with a placeholder shortlist area.
Create the job posting form component
The job form lets a recruiter describe an open role and list the required skills. Each field is a controlled React input backed by component state.
- Create a new file components/job-form.tsx in your resume-screener project folder. Add the imports and component setup by pasting this code:
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function JobForm() {
// Track each form field value in state
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [skillsInput, setSkillsInput] = useState("");
// Placeholder handler - Convex mutation added in the next step
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
};
What does this code do?
The "use client" directive tells Next.js this component runs in the browser. This is required for useState hooks.
Each useState hook tracks one form field so React re-renders as the user types. The handleSubmit function is empty for now. You will connect it to Convex in the next step.
- Still in components/job-form.tsx, add the form JSX directly below the handleSubmit function:
// Render form fields inside a Card for professional styling
return (
<Card>
<CardHeader>
<CardTitle>Create Job Posting</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Job Title</Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Senior React Developer" required />
</div>
<div>
<Label htmlFor="description">Job Description</Label>
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Describe the role and responsibilities..." required />
</div>
<div>
<Label htmlFor="skills">Required Skills (comma-separated)</Label>
<Input id="skills" value={skillsInput} onChange={(e) => setSkillsInput(e.target.value)} placeholder="e.g. React, TypeScript, Node.js, REST APIs" required />
</div>
<Button type="submit" className="w-full">Create Job Posting</Button>
</form>
</CardContent>
</Card>
);
}
What does the form JSX do?
Each form field uses a Shadcn UI component (Input, Textarea) wrapped in a Card for clean styling. The value and onChange props connect each field to its state variable so React controls the input.
The skills field accepts a comma-separated string. You will split it into an array when connecting to the backend.
- Save components/job-form.tsx.
- Confirm the file exists by running this in your terminal:
ls components/job-form.tsx
You should see components/job-form.tsx printed in the output.
File not found?
Make sure you created the file inside the components/ folder at the root of your resume-screener project. This is the same folder where the ui/ subfolder lives (created by Shadcn).
help me find where to create component files in my Next.js project
✔️ Awesome, I've got everything!
Great. Double check you have saved components/job-form.tsx before moving on.
ⓧ I'd like to double check the full code
Your components/job-form.tsx should contain both chunks you added above, in order:
- Chunk 1: The "use client" directive, imports, the JobForm function declaration with three useState hooks, and the empty handleSubmit function.
- Chunk 2: The return statement with the Card wrapper, three form fields (title, description, skills), and the submit button. The closing } for the function is at the very end.
Scroll up and confirm each chunk is present in your file.
Create the candidate input component
The candidate form is where a recruiter pastes raw resume text for AI analysis. It follows the same controlled-input pattern as the job form but with fewer fields.
- Create a new file components/candidate-form.tsx and add the imports and component setup:
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function CandidateForm() {
// Track candidate name and pasted resume content
const [name, setName] = useState("");
const [resumeText, setResumeText] = useState("");
// Placeholder handler - Convex mutation added in the next step
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
};
What does this setup do?
This follows the same pattern as the job form. Two state variables track the candidate name and pasted resume text. The submit handler is empty until you connect Convex in the next step.
- Below the handleSubmit function in components/candidate-form.tsx, add the return statement with the form JSX:
// Render candidate input with a large textarea for pasting resumes
return (
<Card>
<CardHeader>
<CardTitle>Add Candidate</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Candidate Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. John Doe" required />
</div>
<div>
<Label htmlFor="resume">Resume Text</Label>
<Textarea id="resume" value={resumeText} onChange={(e) => setResumeText(e.target.value)} placeholder="Paste the candidate's resume content here..." className="min-h-[200px]" required />
</div>
<Button type="submit" className="w-full">Add & Screen Candidate</Button>
</form>
</CardContent>
</Card>
);
}
Why the tall textarea?
The min-h-[200px] class makes the textarea tall enough to comfortably paste multi-paragraph resume content. The placeholder text guides the recruiter on what to paste.
- Save components/candidate-form.tsx.
- Confirm both component files exist by running:
ls components/
You should see candidate-form.tsx, job-form.tsx, and the ui folder that Shadcn created.
Missing one of the files?
Both files must be in the components/ folder (not inside components/ui/). If you accidentally created one inside ui/, move it up one level.
help me find my missing component file
✔️ Awesome, I've got everything!
Great. Double check you have saved components/candidate-form.tsx before moving on.
ⓧ I'd like to double check the full code
Your components/candidate-form.tsx should contain both chunks you added above, in order:
- Chunk 1: The "use client" directive, imports, the CandidateForm function declaration with two useState hooks, and the empty handleSubmit function.
- Chunk 2: The return statement with a Card wrapper, two form fields (name and resume textarea with min-h-[200px]), and the submit button. The closing } ends the function.
Scroll up and confirm each chunk is present in your file.
Assemble the main page
The two forms exist but nothing renders them yet. You need to replace the default Next.js page with a layout that imports both components and arranges them in a responsive two-column grid.
- Open app/page.tsx and replace its entire contents with this code:
"use client";
import { JobForm } from "@/components/job-form";
import { CandidateForm } from "@/components/candidate-form";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50">
{/* App header with title and tagline */}
<header className="bg-white border-b px-6 py-4">
<h1 className="text-2xl font-bold">AI Resume Screener</h1>
<p className="text-gray-500 text-sm">AI-powered candidate screening and shortlisting</p>
</header>
{/* Two-column layout: forms on left, candidates on right */}
<div className="max-w-7xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<JobForm />
<CandidateForm />
</div>
<div>
<h2 className="text-lg font-semibold mb-4">Ranked Candidates</h2>
<p className="text-gray-500">No candidates yet.</p>
</div>
</div>
</main>
);
}
What does this layout do?
The page uses a responsive CSS grid. On large screens (lg:grid-cols-2), it splits into two columns. The left column stacks the job form and candidate form vertically. The right column is a placeholder for the ranked shortlist you will build later.
On smaller screens, everything stacks into a single column automatically.
- Save app/page.tsx.
Before you check the browser, what do you expect to see at localhost:3000?
- Open your browser to localhost:3000 (or refresh if it is already open).
You should see a styled job posting form on the left with fields for Job Title, Job Description, and Required Skills. Below it sits the candidate form with a large textarea. On the right, the heading "Ranked Candidates" appears with the text "No candidates yet."
Seeing a blank page or an error?
Check that your npm run dev terminal is still running. If you see an import error, confirm the file paths match exactly: @/components/job-form and @/components/candidate-form.
If you see unstyled plain HTML, make sure you kept the globals.css import in app/layout.tsx. Tailwind styles load through that file.
help me fix my page not rendering the forms
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is app/page.tsx in full:
"use client";
import { JobForm } from "@/components/job-form";
import { CandidateForm } from "@/components/candidate-form";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4">
<h1 className="text-2xl font-bold">AI Resume Screener</h1>
<p className="text-gray-500 text-sm">AI-powered candidate screening and shortlisting</p>
</header>
<div className="max-w-7xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<JobForm />
<CandidateForm />
</div>
<div>
<h2 className="text-lg font-semibold mb-4">Ranked Candidates</h2>
<p className="text-gray-500">No candidates yet.</p>
</div>
</div>
</main>
);
}
Your UI is looking sharp. You have two polished forms and a clear layout for where candidates will appear. Next up, you will connect these forms to Convex so that submitted data persists in a real-time database.
Store Jobs and Candidates in Convex
Your forms look great, but try filling one out and refreshing the page. Everything disappears because no data is being saved anywhere.
In this step, you will create a Convex database schema and backend functions that persist job postings and candidates. Once connected, your data survives page refreshes and is queryable in real time.
In this step, get ready to:
- Define a database schema for jobs and candidates.
- Create mutations to write data and queries to read it.
- Connect your React forms to the backend so data persists.
Define the database schema
Convex uses a schema file to know what tables and fields your database should have. This file lives in the convex/ folder and is automatically enforced after it syncs.
- Create a new file called convex/schema.ts and add the following code:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
jobs: defineTable({
title: v.string(),
description: v.string(),
skills: v.array(v.string()),
}),
candidates: defineTable({
jobId: v.id("jobs"),
name: v.string(),
resumeText: v.string(),
status: v.string(),
score: v.optional(v.number()),
skills: v.optional(v.array(v.string())),
experience: v.optional(v.string()),
education: v.optional(v.string()),
breakdown: v.optional(v.string()),
}),
});
What does this schema do?
The jobs table stores each job posting with a title, description, and array of required skills.
The candidates table stores each person applied to a job. The jobId field links a candidate to a specific job. The optional fields (score, skills, experience) will be filled in later by the AI screening step.
- Save convex/schema.ts.
- Switch to the terminal running npx convex dev. You should see it detect the new file, typecheck it, and push the schema to your dev deployment without errors.
Seeing a schema validation error?
Make sure every field type uses the v validator imported from convex/values. A common mistake is writing string instead of v.string().
Help me fix my Convex schema validation error
Create Convex mutations and queries
With the schema in place, you need functions to read from and write to those tables. Convex calls write operations mutations and read operations queries.
- Create a new file called convex/jobs.ts and add the create mutation and list query:
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
// Mutation to insert a new job posting
export const create = mutation({
args: {
title: v.string(),
description: v.string(),
skills: v.array(v.string()),
},
handler: async (ctx, args) => {
const jobId = await ctx.db.insert("jobs", {
title: args.title,
description: args.description,
skills: args.skills,
});
return jobId;
},
});
// Query to fetch all jobs
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("jobs").collect();
},
});
What does this code do?
The create mutation accepts a title, description, and skills array, inserts them into the jobs table, and returns the new job's ID.
The list query returns all jobs in the database. Convex queries are reactive, so the frontend automatically re-renders when data changes.
- Now add a get query below the list query in the same file. This fetches a single job by ID (needed later for the AI screening step):
// Query to fetch a single job by ID
export const get = query({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
- Save convex/jobs.ts. Check your Convex terminal to confirm it syncs without errors.
- Create a new file called convex/candidates.ts and add the add mutation:
import { mutation, query, internalMutation } from "./_generated/server";
import { v } from "convex/values";
// Mutation to add a new candidate for a specific job
export const add = mutation({
args: {
jobId: v.id("jobs"),
name: v.string(),
resumeText: v.string(),
},
handler: async (ctx, args) => {
const candidateId = await ctx.db.insert("candidates", {
jobId: args.jobId,
name: args.name,
resumeText: args.resumeText,
status: "pending",
});
return candidateId;
},
});
What does this code do?
The add mutation inserts a new candidate into the database with a status of "pending". The jobId field links this candidate to the specific job posting they are being screened for.
- Below the add mutation, add the listByJob query that fetches candidates for a given job sorted by score:
// Query to fetch all candidates for a job, ranked by score
export const listByJob = query({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
const candidates = await ctx.db
.query("candidates")
.filter((q) => q.eq(q.field("jobId"), args.jobId))
.collect();
return candidates.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
},
});
What does this code do?
This query filters candidates by jobId and sorts them by score in descending order. The ?? 0 handles candidates that have not been scored yet, treating them as zero so they appear at the bottom.
- Finally, add the updateWithScreening internal mutation below listByJob. This will be called by the AI screening action in the next step to store results:
// Internal mutation called by the AI screening action
export const updateWithScreening = internalMutation({
args: {
candidateId: v.id("candidates"),
score: v.number(),
skills: v.array(v.string()),
experience: v.string(),
education: v.string(),
breakdown: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.candidateId, {
score: args.score,
skills: args.skills,
experience: args.experience,
education: args.education,
breakdown: args.breakdown,
status: "screened",
});
},
});
What is an internal mutation?
An internalMutation can only be called from other server-side Convex functions, not from the client. This keeps the screening update secure. Only the AI screening action (which runs server-side) will be able to write scores to the database.
- Save convex/candidates.ts.
- Check your Convex terminal. You should see it typecheck and push all your new functions without errors.
Convex showing type errors?
Make sure you imported internalMutation from ./_generated/server in the import statement at the top of convex/candidates.ts. If you only imported mutation and query, the internalMutation function will be undefined.
Help me fix Convex type errors in my candidates.ts file
Connect the UI to Convex
The backend is ready. Now you need to wire your React components to call these mutations when the forms submit, and use queries to display persisted data.
- Replace the entire contents of components/job-form.tsx with the following imports, interface, and component logic:
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Props interface so the parent page knows when a job is created
interface JobFormProps {
onJobCreated: (jobId: Id<"jobs">) => void;
}
export function JobForm({ onJobCreated }: JobFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [skillsInput, setSkillsInput] = useState("");
// Hook that gives us a function to call the create mutation
const createJob = useMutation(api.jobs.create);
What changed here?
You added useMutation from convex/react to call your backend create mutation. The onJobCreated callback lets the parent page know which job was just created, so it can select it and show candidates for it.
- Continue in the same file. Replace the handleSubmit function and keep the return JSX the same as before:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Split comma-separated skills into a clean array
const skills = skillsInput.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
const jobId = await createJob({ title, description, skills });
onJobCreated(jobId);
setTitle("");
setDescription("");
setSkillsInput("");
};
- Keep the return ( statement and JSX exactly as they were before. The form template does not change.
- Save components/job-form.tsx.
- Now replace the entire contents of components/candidate-form.tsx with:
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// This component now requires a jobId to associate candidates with a job
interface CandidateFormProps {
jobId: Id<"jobs">;
}
- Continue in the same file with the component function:
export function CandidateForm({ jobId }: CandidateFormProps) {
const [name, setName] = useState("");
const [resumeText, setResumeText] = useState("");
// Hook to call the add mutation on submit
const addCandidate = useMutation(api.candidates.add);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await addCandidate({ jobId, name, resumeText });
setName("");
setResumeText("");
};
- Keep the return ( JSX the same as before (the Card with form fields for name and resume text).
- Save components/candidate-form.tsx.
Now open localhost:3000 in your browser. You will see an error because app/page.tsx still passes no props to these components. Time to fix that.
- Replace the entire contents of app/page.tsx with the following imports and state setup:
"use client";
import { useState } from "react";
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
import { JobForm } from "@/components/job-form";
import { CandidateForm } from "@/components/candidate-form";
import { Badge } from "@/components/ui/badge";
export default function Home() {
// Track which job is currently selected
const [selectedJobId, setSelectedJobId] = useState<Id<"jobs"> | null>(null);
// Reactive query: re-fetches automatically when jobs change
const jobs = useQuery(api.jobs.list);
// Skip the candidates query until a job is selected
const candidates = useQuery(
api.candidates.listByJob,
selectedJobId ? { jobId: selectedJobId } : "skip"
);
- Continue in the same file with the return JSX. Add the left column with the job form, candidate form, and job selector:
return (
<main className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4">
<h1 className="text-2xl font-bold">AI Resume Screener</h1>
<p className="text-gray-500 text-sm">AI-powered candidate screening and shortlisting</p>
</header>
<div className="max-w-7xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<JobForm onJobCreated={(jobId) => setSelectedJobId(jobId)} />
{selectedJobId && <CandidateForm jobId={selectedJobId} />}
{jobs && jobs.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-500">Your Jobs</h3>
{jobs.map((job) => (
<button key={job._id} onClick={() => setSelectedJobId(job._id)}
className={`block w-full text-left px-4 py-2 rounded-lg border ${
selectedJobId === job._id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:bg-gray-50"
}`}>
<span className="font-medium">{job.title}</span>
<span className="text-sm text-gray-500 ml-2">{job.skills.length} skills required</span>
</button>
))}
</div>
)}
</div>
- Complete the file by adding the right column that displays candidates with a Pending badge:
<div>
<h2 className="text-lg font-semibold mb-4">
{selectedJobId ? "Ranked Candidates" : "Select a job to view candidates"}
</h2>
{candidates && candidates.length > 0 ? (
<div className="space-y-2">
{candidates.map((candidate) => (
<div key={candidate._id} className="flex items-center justify-between px-4 py-2 bg-white rounded-lg border">
<span className="font-medium">{candidate.name}</span>
<Badge variant="secondary">Pending</Badge>
</div>
))}
</div>
) : selectedJobId ? (
<p className="text-gray-500">No candidates yet. Add a candidate using the form.</p>
) : null}
</div>
</div>
</main>
);
}
What changed in page.tsx?
The page now uses useQuery to fetch jobs and candidates reactively from Convex. It passes onJobCreated to JobForm so that submitting a job automatically selects it.
The "skip" argument on the candidates query tells Convex to not run that query until a job is selected. Once selected, it fetches candidates for that job and displays them as a list with Pending badges.
- Save app/page.tsx.
Before you test, what do you think will happen when you create a job and then refresh the page?
- Open localhost:3000 in your browser.
- Fill in the job form with a title like Senior React Developer, a short description, and skills like React, TypeScript, Node.js.
- Click Create Job Posting.
- You should see the job appear in the Your Jobs section and the candidate form appear.
- Enter a candidate name and paste any text into the resume field.
- Click Add & Screen Candidate.
- The candidate name appears on the right with a Pending badge.
- Refresh the page. Click your job in the Your Jobs section. Your candidate is still there.
Data now persists across refreshes because it is stored in Convex.
Form submits but nothing appears?
Make sure your npx convex dev terminal is still running. The frontend cannot reach the backend without it.
Also check that the import path ../convex/_generated/api resolves correctly. If you see a module-not-found error, confirm the convex/_generated/ folder exists (it is created automatically by npx convex dev).
Help me debug why my Convex mutations are not saving data
✔️ Awesome, I've got everything!
Great. Double check you have saved all files: convex/schema.ts, convex/jobs.ts, convex/candidates.ts, components/job-form.tsx, components/candidate-form.tsx, and app/page.tsx.
ⓧ I'd like to double check the full code
Here are the backend files in full. Confirm each matches what you have.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
jobs: defineTable({
title: v.string(),
description: v.string(),
skills: v.array(v.string()),
}),
candidates: defineTable({
jobId: v.id("jobs"),
name: v.string(),
resumeText: v.string(),
status: v.string(),
score: v.optional(v.number()),
skills: v.optional(v.array(v.string())),
experience: v.optional(v.string()),
education: v.optional(v.string()),
breakdown: v.optional(v.string()),
}),
});
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const create = mutation({
args: {
title: v.string(),
description: v.string(),
skills: v.array(v.string()),
},
handler: async (ctx, args) => {
const jobId = await ctx.db.insert("jobs", {
title: args.title,
description: args.description,
skills: args.skills,
});
return jobId;
},
});
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("jobs").collect();
},
});
export const get = query({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
For convex/candidates.ts, confirm you have these three exports in order: add (mutation), listByJob (query), and updateWithScreening (internalMutation). Scroll up to the code blocks above and compare each one against your file.
For the frontend files (job-form.tsx, candidate-form.tsx, page.tsx), confirm each file has the useMutation or useQuery hooks, the correct import paths from ../convex/_generated/api, and that props (onJobCreated, jobId) are passed between components.
Your app now has a real backend. Jobs and candidates persist, and the UI updates reactively whenever data changes in Convex. Next up, you will connect Google Gemini to automatically analyze each candidate's resume and score them against the job requirements.
Add AI Resume Screening with Gemini
Your app stores candidates and jobs, but every candidate just shows a "Pending" badge with no intelligence behind it.
This step adds the AI brain. You will wire up Google Gemini to analyze each resume against the job requirements, extract structured data, and assign a match score from 0 to 100.
In this step, get ready to:
- Create the AI screening action that calls the Gemini REST API.
- Trigger screening automatically when a candidate is added.
- Update the UI to display AI-generated scores and extracted skills.
Create the AI screening action
Convex actions cannot access the database directly. They use internal queries to read data. You need to add internal query functions so the screening action can fetch job and candidate records.
- Open convex/jobs.ts and update the import at the top to include internalQuery:
import { mutation, query, internalQuery } from "./_generated/server";
import { v } from "convex/values";
- Add the following getInternal function at the bottom of convex/jobs.ts, below your existing get query:
export const getInternal = internalQuery({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
What does this code do?
An internalQuery works like a regular query but can only be called from other server functions (actions and mutations), not from the client. The screening action will use this to read job details.
- Now open convex/candidates.ts and update the import to include internalQuery:
import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
- Add the following getInternal function at the bottom of convex/candidates.ts, below your existing updateWithScreening mutation:
export const getInternal = internalQuery({
args: { candidateId: v.id("candidates") },
handler: async (ctx, args) => {
return await ctx.db.get(args.candidateId);
},
});
- Save both files.
- Check your Convex terminal. You should see it sync the new functions without errors.
Seeing a type error in the Convex terminal?
Make sure internalQuery is added to the import from ./_generated/server in both files. A missing import will cause a compile error.
Help me fix this error
Now you can create the main screening action. This is the function that constructs a prompt, sends it to Gemini, and stores the results.
- Create a new file convex/screening.ts and paste the following code to set up the action and fetch the data it needs:
import { internalAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const screenCandidate = internalAction({
args: {
candidateId: v.id("candidates"),
jobId: v.id("jobs"),
},
handler: async (ctx, args) => {
// Fetch job and candidate data using internal queries
const job = await ctx.runQuery(internal.jobs.getInternal, { jobId: args.jobId });
const candidate = await ctx.runQuery(internal.candidates.getInternal, { candidateId: args.candidateId });
if (!job || !candidate) {
throw new Error("Job or candidate not found");
}
// Read the Gemini API key from environment
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable not set");
}
What does this code do?
- internalAction defines a server-side function that only other Convex functions can call (not the client).
- ctx.runQuery lets an action read from the database through the internal queries you just created.
- process.env.GEMINI_API_KEY reads the environment variable you stored in the Convex Dashboard earlier.
- Add the prompt construction directly below the API key check (still inside the handler):
// Construct the prompt for Gemini
const prompt = `You are an expert recruiter AI. Analyze this resume against the job requirements and return a JSON response.
Job Title: ${job.title}
Job Description: ${job.description}
Required Skills: ${job.skills.join(", ")}
Resume:
${candidate.resumeText}
Return ONLY valid JSON (no markdown, no code fences) with this exact structure:
{
"score": <number 0-100 representing overall match>,
"extractedSkills": [<array of skills found in the resume>],
"experience": "<one sentence summary of relevant experience>",
"education": "<one sentence summary of education>",
"breakdown": "<brief explanation of score: which required skills matched, which are missing>"
}`;
What does the prompt do?
The prompt injects the job title, description, required skills, and the candidate's resume text into a template. It tells Gemini to return structured JSON with a score, extracted skills, and a breakdown. The responseMimeType setting (added next) forces Gemini to output valid JSON without markdown code fences.
- Add the API call and response handling below the prompt (still inside the handler), then close the function:
// Call the Gemini REST API
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2, responseMimeType: "application/json" },
}),
}
);
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`);
}
// Parse the response and update the candidate record
const data = await response.json();
const text = data.candidates[0].content.parts[0].text;
const result = JSON.parse(text);
await ctx.runMutation(internal.candidates.updateWithScreening, {
candidateId: args.candidateId,
score: result.score,
skills: result.extractedSkills,
experience: result.experience,
education: result.education,
breakdown: result.breakdown,
});
},
});
What does this code do?
- fetch calls the Gemini REST API with the prompt. The temperature: 0.2 keeps responses consistent and factual.
- responseMimeType: "application/json" forces Gemini to return raw JSON (no markdown wrapping).
- The parsed result is passed to updateWithScreening which patches the candidate document with the AI analysis and sets the status to "screened".
- Save convex/screening.ts.
- Check the Convex terminal. You should see it pick up the new screening.screenCandidate function.
Convex terminal showing an error about screening.ts?
Make sure all three code chunks are inside the same handler function. The prompt and fetch code go inside the handler: async (ctx, args) => { ... } block, not after it. Check that the closing }); at the end closes both the internalAction and the handler.
Help me fix my screening.ts file
Trigger screening automatically when a candidate is added
The screening action exists but nothing calls it yet. You need to schedule it from the add mutation so every new candidate is automatically screened.
- In convex/candidates.ts, find the import line at the top. Add the internal import from the generated API:
import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
- In the add mutation handler, find the line:
return candidateId;
- Add the scheduler call directly above return candidateId; so the handler now looks like this:
const candidateId = await ctx.db.insert("candidates", {
jobId: args.jobId,
name: args.name,
resumeText: args.resumeText,
status: "pending",
});
// Schedule the AI screening action to run immediately
await ctx.scheduler.runAfter(0, internal.screening.screenCandidate, {
candidateId,
jobId: args.jobId,
});
return candidateId;
What does this code do?
ctx.scheduler.runAfter(0, ...) schedules the screening action to execute immediately after the mutation completes. The candidate gets inserted first (so the user sees instant feedback), then the AI analysis runs asynchronously in the background.
When the action finishes, it calls updateWithScreening which patches the candidate record. Because Convex queries are reactive, the UI updates automatically without a page refresh.
- Save convex/candidates.ts.
- Check the Convex terminal. It should sync without errors.
The backend pipeline is now complete. Time to test it before updating the UI.
- In your browser at localhost:3000, create a job posting with title React Developer, any description, and skills React, TypeScript, Node.js.
- Add a candidate with any name and paste a short resume like: "5 years React and TypeScript experience. Built e-commerce platforms with Node.js. BS Computer Science."
Before you check the result, what do you think will happen to the candidate record in the database?
- Open the Convex Dashboard at dashboard.convex.dev and click the candidates table in the Data tab.
You should see your candidate record now has a score field, skills array, and status: "screened". The AI pipeline is working.
Candidate still shows status pending in the dashboard?
Check the Logs tab in the Convex Dashboard for error messages. The most common causes are:
- Missing GEMINI_API_KEY environment variable. Go to Settings > Environment Variables in the Convex Dashboard and confirm it is set.
- Typo in the internal.screening.screenCandidate reference. The file must be named exactly screening.ts and the exported function must be screenCandidate.
Help me debug my Convex screening action
Update the UI to show scores and extracted skills
The backend is working, but the UI still shows a flat "Pending" badge for every candidate. You need a proper card component that displays the score, skills, and a collapsible detail section.
- Create a new file components/candidate-card.tsx and paste the component setup and score logic:
"use client";
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface CandidateCardProps {
candidate: {
_id: string;
name: string;
status: string;
score?: number;
skills?: string[];
experience?: string;
education?: string;
breakdown?: string;
};
rank: number;
}
export function CandidateCard({ candidate, rank }: CandidateCardProps) {
const [expanded, setExpanded] = useState(false);
// Determine score badge color based on value
const scoreColor =
candidate.score !== undefined
? candidate.score >= 70
? "bg-green-100 text-green-800"
: candidate.score >= 40
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
: "bg-gray-100 text-gray-800";
What does this code do?
- The CandidateCardProps interface defines the shape of data this component expects (matching what Convex returns).
- scoreColor picks a Tailwind color class based on the score: green for 70+, yellow for 40-69, red for below 40.
- expanded state controls whether the detailed breakdown is visible.
- Add the JSX return directly below the scoreColor variable to complete the component:
return (
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setExpanded(!expanded)}>
<CardContent className="pt-4">
<div className="flex items-center gap-4">
<span className="text-2xl font-bold text-gray-300">#{rank}</span>
<div className="flex-1">
<h3 className="font-semibold">{candidate.name}</h3>
{candidate.status === "pending" ? (
<p className="text-sm text-gray-500">Screening in progress...</p>
) : (
<div className="flex flex-wrap gap-1 mt-1">
{candidate.skills?.slice(0, 5).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">{skill}</Badge>
))}
{candidate.skills && candidate.skills.length > 5 && (
<Badge variant="secondary" className="text-xs">+{candidate.skills.length - 5} more</Badge>
)}
</div>
)}
</div>
{candidate.score !== undefined && (
<div className={`px-3 py-1 rounded-full font-bold ${scoreColor}`}>{candidate.score}/100</div>
)}
</div>
{expanded && candidate.status === "screened" && (
<div className="mt-4 pt-4 border-t space-y-2 text-sm">
<p><span className="font-medium">Experience:</span> {candidate.experience}</p>
<p><span className="font-medium">Education:</span> {candidate.education}</p>
<p><span className="font-medium">Analysis:</span> {candidate.breakdown}</p>
</div>
)}
</CardContent>
</Card>
);
}
What does this code do?
- The card shows a rank number, candidate name, and color-coded score badge.
- While status is "pending", it shows "Screening in progress..." instead of skills.
- Once screened, it displays up to 5 extracted skills as badges.
- Clicking the card toggles the expanded state, revealing experience, education, and AI analysis.
- Save components/candidate-card.tsx.
Now wire the new component into your main page.
- Open app/page.tsx and find the import section at the top. Replace the Badge import with the new CandidateCard import:
import { Badge } from "@/components/ui/badge";
- Replace that line with:
import { CandidateCard } from "@/components/candidate-card";
- Now find the candidate list rendering section. Locate this block:
{candidates && candidates.length > 0 ? (
<div className="space-y-2">
{candidates.map((candidate) => (
<div key={candidate._id} className="flex items-center justify-between px-4 py-2 bg-white rounded-lg border">
<span className="font-medium">{candidate.name}</span>
<Badge variant="secondary">Pending</Badge>
</div>
))}
</div>
- Replace it with the CandidateCard component:
{candidates && candidates.length > 0 ? (
<div className="space-y-4">
{candidates.map((candidate, index) => (
<CandidateCard key={candidate._id} candidate={candidate} rank={index + 1} />
))}
</div>
What changed?
- The flat div with a hardcoded "Pending" badge is replaced by the CandidateCard component.
- Each candidate gets a rank prop based on its array position (already sorted by score from the Convex query).
- The spacing changed from space-y-2 to space-y-4 to give the larger cards more breathing room.
- Save app/page.tsx.
Time to see the full AI pipeline in action. Before you test, predict: when you add a new candidate, what do you expect to see in the right column?
- In your browser at localhost:3000, select your job posting and add a new candidate. Paste any resume text that mentions some of the required skills.
You should see the candidate appear with "Screening in progress..." for a moment, then within a few seconds the card updates to show a color-coded score (like 78/100), extracted skill badges, and the rank number. Click the card to expand the full AI analysis.
Candidate stays on Screening in progress?
- Open the Convex Dashboard Logs tab and look for errors in the screening.screenCandidate action.
- If you see a 429 error, you have hit the Gemini free tier rate limit (10 requests per minute). Wait 60 seconds and try again.
- If you see "GEMINI_API_KEY environment variable not set", re-check that the key is saved in Convex Dashboard Settings > Environment Variables.
My candidate is not being screened by the AI
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: convex/jobs.ts, convex/candidates.ts, convex/screening.ts, and components/candidate-card.tsx.
ⓧ I'd like to double check the full code
Here are the files you created or modified in this step. Scroll up and confirm each section matches.
convex/jobs.ts:
import { mutation, query, internalQuery } from "./_generated/server";
import { v } from "convex/values";
export const create = mutation({
args: {
title: v.string(),
description: v.string(),
skills: v.array(v.string()),
},
handler: async (ctx, args) => {
const jobId = await ctx.db.insert("jobs", {
title: args.title,
description: args.description,
skills: args.skills,
});
return jobId;
},
});
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("jobs").collect();
},
});
export const get = query({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
export const getInternal = internalQuery({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
convex/candidates.ts:
import { mutation, query, internalMutation, internalQuery } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const add = mutation({
args: {
jobId: v.id("jobs"),
name: v.string(),
resumeText: v.string(),
},
handler: async (ctx, args) => {
const candidateId = await ctx.db.insert("candidates", {
jobId: args.jobId,
name: args.name,
resumeText: args.resumeText,
status: "pending",
});
await ctx.scheduler.runAfter(0, internal.screening.screenCandidate, {
candidateId,
jobId: args.jobId,
});
return candidateId;
},
});
export const listByJob = query({
args: { jobId: v.id("jobs") },
handler: async (ctx, args) => {
const candidates = await ctx.db
.query("candidates")
.filter((q) => q.eq(q.field("jobId"), args.jobId))
.collect();
return candidates.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
},
});
export const updateWithScreening = internalMutation({
args: {
candidateId: v.id("candidates"),
score: v.number(),
skills: v.array(v.string()),
experience: v.string(),
education: v.string(),
breakdown: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.candidateId, {
score: args.score,
skills: args.skills,
experience: args.experience,
education: args.education,
breakdown: args.breakdown,
status: "screened",
});
},
});
export const getInternal = internalQuery({
args: { candidateId: v.id("candidates") },
handler: async (ctx, args) => {
return await ctx.db.get(args.candidateId);
},
});
convex/screening.ts:
import { internalAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const screenCandidate = internalAction({
args: {
candidateId: v.id("candidates"),
jobId: v.id("jobs"),
},
handler: async (ctx, args) => {
const job = await ctx.runQuery(internal.jobs.getInternal, { jobId: args.jobId });
const candidate = await ctx.runQuery(internal.candidates.getInternal, { candidateId: args.candidateId });
if (!job || !candidate) {
throw new Error("Job or candidate not found");
}
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable not set");
}
const prompt = `You are an expert recruiter AI. Analyze this resume against the job requirements and return a JSON response.
Job Title: ${job.title}
Job Description: ${job.description}
Required Skills: ${job.skills.join(", ")}
Resume:
${candidate.resumeText}
Return ONLY valid JSON (no markdown, no code fences) with this exact structure:
{
"score": <number 0-100 representing overall match>,
"extractedSkills": [<array of skills found in the resume>],
"experience": "<one sentence summary of relevant experience>",
"education": "<one sentence summary of education>",
"breakdown": "<brief explanation of score: which required skills matched, which are missing>"
}`;
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.2, responseMimeType: "application/json" },
}),
}
);
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`);
}
const data = await response.json();
const text = data.candidates[0].content.parts[0].text;
const result = JSON.parse(text);
await ctx.runMutation(internal.candidates.updateWithScreening, {
candidateId: args.candidateId,
score: result.score,
skills: result.extractedSkills,
experience: result.experience,
education: result.education,
breakdown: result.breakdown,
});
},
});
components/candidate-card.tsx:
"use client";
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface CandidateCardProps {
candidate: {
_id: string;
name: string;
status: string;
score?: number;
skills?: string[];
experience?: string;
education?: string;
breakdown?: string;
};
rank: number;
}
export function CandidateCard({ candidate, rank }: CandidateCardProps) {
const [expanded, setExpanded] = useState(false);
const scoreColor =
candidate.score !== undefined
? candidate.score >= 70
? "bg-green-100 text-green-800"
: candidate.score >= 40
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
: "bg-gray-100 text-gray-800";
return (
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setExpanded(!expanded)}>
<CardContent className="pt-4">
<div className="flex items-center gap-4">
<span className="text-2xl font-bold text-gray-300">#{rank}</span>
<div className="flex-1">
<h3 className="font-semibold">{candidate.name}</h3>
{candidate.status === "pending" ? (
<p className="text-sm text-gray-500">Screening in progress...</p>
) : (
<div className="flex flex-wrap gap-1 mt-1">
{candidate.skills?.slice(0, 5).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">{skill}</Badge>
))}
{candidate.skills && candidate.skills.length > 5 && (
<Badge variant="secondary" className="text-xs">+{candidate.skills.length - 5} more</Badge>
)}
</div>
)}
</div>
{candidate.score !== undefined && (
<div className={`px-3 py-1 rounded-full font-bold ${scoreColor}`}>{candidate.score}/100</div>
)}
</div>
{expanded && candidate.status === "screened" && (
<div className="mt-4 pt-4 border-t space-y-2 text-sm">
<p><span className="font-medium">Experience:</span> {candidate.experience}</p>
<p><span className="font-medium">Education:</span> {candidate.education}</p>
<p><span className="font-medium">Analysis:</span> {candidate.breakdown}</p>
</div>
)}
</CardContent>
</Card>
);
}
app/page.tsx:
"use client";
import { useState } from "react";
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
import { JobForm } from "@/components/job-form";
import { CandidateForm } from "@/components/candidate-form";
import { CandidateCard } from "@/components/candidate-card";
export default function Home() {
const [selectedJobId, setSelectedJobId] = useState<Id<"jobs"> | null>(null);
const jobs = useQuery(api.jobs.list);
const candidates = useQuery(
api.candidates.listByJob,
selectedJobId ? { jobId: selectedJobId } : "skip"
);
return (
<main className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4">
<h1 className="text-2xl font-bold">AI Resume Screener</h1>
<p className="text-gray-500 text-sm">AI-powered candidate screening and shortlisting</p>
</header>
<div className="max-w-7xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<JobForm onJobCreated={(jobId) => setSelectedJobId(jobId)} />
{selectedJobId && <CandidateForm jobId={selectedJobId} />}
{jobs && jobs.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-500">Your Jobs</h3>
{jobs.map((job) => (
<button key={job._id} onClick={() => setSelectedJobId(job._id)}
className={`block w-full text-left px-4 py-2 rounded-lg border ${
selectedJobId === job._id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:bg-gray-50"
}`}>
<span className="font-medium">{job.title}</span>
<span className="text-sm text-gray-500 ml-2">{job.skills.length} skills required</span>
</button>
))}
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">
{selectedJobId ? "Ranked Candidates" : "Select a job to view candidates"}
</h2>
{candidates && candidates.length > 0 ? (
<div className="space-y-4">
{candidates.map((candidate, index) => (
<CandidateCard key={candidate._id} candidate={candidate} rank={index + 1} />
))}
</div>
) : selectedJobId ? (
<p className="text-gray-500">No candidates yet. Add a candidate using the form.</p>
) : null}
</div>
</div>
</main>
);
}
Your resume screener now has a working AI brain. Every candidate gets automatically analyzed and scored the moment they are added. Next up, you will polish the dashboard with proper ranking and deploy the app live.
Build the Ranked Shortlist Dashboard and Deploy
Your AI screening pipeline is working. Candidates get analyzed by Gemini and scored automatically, with results appearing in real time.
The dashboard header still shows a generic label, and the system only runs on your machine. In this step, you will polish the shortlist view to show job context and candidate count, then deploy the entire app to Vercel so anyone can access it.
In this step, get ready to:
- Verify the ranked shortlist works correctly with multiple candidates.
- Polish the dashboard header to show the job title and candidate count.
- Deploy the complete application to Vercel.
Test the ranked shortlist with multiple candidates
The candidate cards you built in the previous step already show rank numbers, color-coded scores, extracted skills, and expandable details. Let's confirm everything works end-to-end with a proper set of candidates.
- Switch back to your browser at localhost:3000.
- Create a job posting if you don't have one yet (e.g., title: Frontend Developer, skills: React, TypeScript, CSS, Testing).
- Add at least 3 candidates with different resume texts. Give each candidate varying levels of relevant experience so the scores will differ.
Sample resume text
Paste a few sentences describing skills and experience. For example: "5 years of React and TypeScript development. Built production SPAs with testing frameworks. BS in Computer Science." Vary the skills across candidates to see different scores.
Before you add the third candidate, what do you expect to see? Will it appear above or below the first two?
- After all 3 candidates are screened, verify the list reorders by score with the highest first.
- Click on any candidate card. The card expands to reveal the experience summary, education summary, and per-skill analysis breakdown.
What should I see?
Each candidate shows a rank number on the left, their name, skill badges, and a color-coded score on the right. Green means 70+, yellow means 40-69, and red means below 40. Candidates with status "pending" show "Screening in progress..." until the AI finishes.
Candidates stuck on pending?
Check the terminal running npx convex dev for errors. If you see a Gemini API error, confirm your GEMINI_API_KEY is set correctly in the Convex Dashboard environment variables.
help me debug why my candidates are stuck on pending
Polish the shortlist header
The right column currently says "Ranked Candidates" with no additional context. A recruiter wants to see which job the shortlist belongs to and how many candidates are in it.
- In app/page.tsx, find the line where candidates is declared with useQuery:
const candidates = useQuery(
api.candidates.listByJob,
selectedJobId ? { jobId: selectedJobId } : "skip"
);
- Add this new line directly below it to look up the selected job's details:
const selectedJob = jobs?.find((j) => j._id === selectedJobId);
What does this line do?
This finds the full job object from the jobs array that matches the currently selected ID. You need the job's title field to display it in the header.
- Now find the h2 heading in the right column. It currently looks like this:
<h2 className="text-lg font-semibold mb-4">
{selectedJobId ? "Ranked Candidates" : "Select a job to view candidates"}
</h2>
- Replace that entire h2 block with this updated version:
<h2 className="text-lg font-semibold mb-4">
{selectedJobId
? `Shortlisted Candidates${selectedJob ? ` — ${selectedJob.title}` : ""} (${candidates?.length ?? 0})`
: "Select a job to view candidates"}
</h2>
What changed?
The heading now says "Shortlisted Candidates" followed by the job title and a count of candidates in parentheses. This gives recruiters immediate context about which role they are screening for and how many candidates are in the pipeline.
- Save app/page.tsx.
- Refresh your browser. The right column heading should now display something like "Shortlisted Candidates — Frontend Developer (3)" instead of the generic "Ranked Candidates" text.
Header not updating?
Make sure you added the selectedJob line after the candidates useQuery but before the return statement. Also check that you have a job selected by clicking it in the left column.
help me fix my shortlist header
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx with both the selectedJob line and the updated h2 heading.
ⓧ I'd like to double check the full code
The full app/page.tsx is longer than 30 lines. Confirm these two changes are in place:
- After the candidates useQuery call, you have: const selectedJob = jobs?.find((j) => j._id === selectedJobId);.
- The h2 uses the template literal with Shortlisted Candidates, selectedJob.title, and candidates?.length ?? 0.
Deploy to Vercel
Your app is working locally. Time to deploy it so it is live on the internet and accessible to anyone with the URL.
- Stop the npm run dev server (press Ctrl+C). Keep the npx convex dev terminal running for now.
- Commit all your code to Git and push it to a GitHub repository by running these commands:
git add .
git commit -m "Complete AI resume screener app"
- Go to github.com/new in your browser and create a new repository named resume-screener. Leave it public and do not initialize with a README.
- Push your code to the new repository by running:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/resume-screener.git
git branch -M main
git push -u origin main
- Verify your code appears on GitHub by refreshing the repository page. You should see all your project files listed.
Push rejected or permission error?
If you see a permission error, make sure you are signed in to git with your GitHub credentials. Run gh auth login if you have the GitHub CLI, or set up a personal access token.
help me push my code to GitHub
Now you will import the repository into Vercel and configure it to deploy your Convex backend alongside the Next.js frontend.
- Go to vercel.com and sign up or sign in with your GitHub account.
- Click Add New... in the top-right area, then select Project.
- Find your resume-screener repository in the list and click Import.
Don't see your repo?
If the repository does not appear, click Configure GitHub App on the import screen. Grant Vercel access to the resume-screener repository, then return to the import screen and search again.
- On the configuration screen, expand the Build and Output Settings section.
- Toggle the Build Command override on and enter:
npx convex deploy --cmd 'npm run build'
Why override the build command?
This command tells Vercel to first deploy your Convex functions to production, then run the Next.js build. Without it, your frontend would build without a production Convex URL and the app would not connect to your backend.
- Now you need a production deploy key from Convex. Open a new browser tab and go to the Convex Dashboard.
- Select your resume-screener project, then navigate to Deployment Settings for the production deployment.
- In the Deploy keys section, click Generate a deploy key.
- Enable the deployment:deploy permission and click create. Copy the generated key.
Keep your deploy key safe
This key grants deployment access to your Convex backend. Do not commit it to your repository or share it publicly.
- Back in the Vercel project configuration screen, expand the Environment Variables section.
- Add a new variable with the name CONVEX_DEPLOY_KEY and paste your deploy key as the value.
- Click Deploy.
Vercel will now build and deploy your application. This takes 1-2 minutes.
Before the build finishes, what do you expect to see at the deployed URL? Will the data from your local development appear there?
- Once the deployment completes, click the Visit button to open your live app.
Your AI Resume Screener is now live at a .vercel.app URL. The app connects to your production Convex deployment, so you will start with an empty database. Create a new job and add a candidate to verify the full pipeline works in production.
Why is the production database empty?
Convex separates development and production deployments. Your local npx convex dev uses a dev deployment with its own database. The deployed Vercel app uses a production deployment with a fresh database. This prevents test data from polluting production.
Deployment failed?
Check the Vercel build logs for errors. The most common cause is a missing or incorrect CONVEX_DEPLOY_KEY. Ensure the key has the deployment:deploy permission enabled and is pasted without extra spaces.
help me fix my Vercel deployment
Your AI resume screening system is complete and deployed. You have a working full-stack app that anyone can access, powered by real-time AI analysis.
Secret mission
Add Candidate Comparison View
Recruiters often need to compare their top candidates side by side. In this bonus challenge, you will add a comparison feature that lets you select two candidates and view their scores, skills, and summaries in a head-to-head layout with visual skill-gap analysis.
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 on free tiers for all services, so there are no ongoing costs unless you exceed the generous limits.
Resources you used:
- Convex deployment (free tier: 1,000,000 function calls/month, 0.5 GB database storage).
- Vercel deployment (free tier: sufficient for personal projects).
- Google AI Studio API key (free tier: 10 requests/minute, 250 requests/day for Gemini 2.5 Flash).
- Local resume-screener project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to revisit this project soon.
- Your Convex and Vercel deployments stay live on their free tiers at no charge.
- Your Gemini API key remains active. Without billing enabled, exceeding the free-tier quota returns 429 errors but never generates a bill.
- Your local resume-screener folder stays on your Desktop.
Pause - I'll come back to this later
Free up local resources while keeping your cloud deployments intact. Both Convex and Vercel automatically scale to zero when not in use, so no action is needed on the cloud side.
- Stop the local dev server by pressing Ctrl+C in each terminal running npm run dev or npx convex dev.
- Your Vercel deployment and Convex backend remain available at their URLs with no ongoing cost.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible for cloud resources.
- Delete your Vercel project: go to the Vercel dashboard, select your project, click Settings in the sidebar, scroll to the bottom of the General page, click Delete in the Delete Project section, then confirm by entering the project name.
- Delete your Convex project: go to dashboard.convex.dev, click the triple-dot ⋮ menu on your project card, and select Delete. Confirm when prompted.
- Delete your Gemini API key: go to aistudio.google.com/apikey, locate your key in the list, and delete it.
- Remove the Convex environment variable: since you are deleting the Convex project entirely, the GEMINI_API_KEY environment variable is removed along with the project.
- Delete your local project folder by running these commands in your terminal:
cd ~/Desktop
rm -rf resume-screener
- On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force resume-screener
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see resume-screener in the output.
Nice Work!
Nice Work!
Nice work! You just built and deployed a full-stack AI resume screening system that turns raw resume text into ranked candidate shortlists.
You've learned how to:
- Integrate Google Gemini into a full-stack app to extract structured data (skills, experience, education) from unstructured resume text and score candidates on a 0-100 scale.
- Build a real-time Convex backend with reactive queries that automatically re-rank candidates the moment AI screening results arrive.
- Deploy a production Next.js application to Vercel with automatic CI/CD from GitHub, giving you a live URL anyone can visit.
- Secret Mission: Added a side-by-side candidate comparison view with visual score bars and shared/unique skill analysis.
Ready to quiz yourself?