AI Resume Screening Engine
Build an AI-powered resume screener that ranks candidates using Gemini.
Introduction
30 Second Summary
Hiring teams often sift through dozens of resumes for a single role. What if you could build a tool that reads each resume, pulls out the important details, and ranks candidates automatically?
In this project, you will build an AI-powered resume screening application using Next.js and Google's Gemini API. You'll upload resumes, extract candidate data with AI, score them against a configurable rubric, and display ranked results in a dashboard.
What You'll Build
A full-stack Next.js application that accepts PDF and DOCX resume uploads, extracts structured candidate data via Gemini AI, scores each candidate against weighted job criteria, and displays a ranked candidate dashboard.
By the end of this project, you'll have:
- A file upload pipeline that accepts PDF and DOCX resumes, stores them in Supabase Storage, and extracts their text content.
- An AI-powered extraction system that uses Gemini to parse unstructured resume text into validated, structured JSON data.
- A scoring engine that evaluates candidates against configurable job criteria with weighted skills, experience, and education.
- A ranked candidate dashboard with filtering capabilities to sort and view top candidates at a glance.
- Secret Mission: Add multi-job-posting support so you can screen candidates against different roles with different criteria.
Are there any prerequisites?
You should be familiar with Next.js and React, plus have a basic understanding of REST APIs and working with LLMs.
You'll also need Node.js v20+ installed, a free Gemini API key from Google AI Studio, and a free Supabase account.
Before We Start
Before We Start...
Set Up the Project and Configuration
Before building any AI features, you need a solid foundation. This means a working Next.js project with the right dependencies, a database to store candidate information, and utility files that connect your app to Gemini and Supabase.
In this step, you will scaffold the project, configure environment variables, set up your database table and file storage, and create the client utilities that the rest of the app depends on.
In this step, get ready to:
- Scaffold a Next.js project and install all dependencies.
- Configure Supabase database and file storage.
- Create utility files and verify both connections work.
Scaffold the Next.js project and install dependencies
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest resume-screener --yes
What does --yes do?
The --yes flag skips all the interactive prompts and uses the recommended defaults: TypeScript, Tailwind CSS, ESLint, App Router, Turbopack, and the @/* import alias. This gives you a modern Next.js setup without answering questions one by one.
- Move into your new project folder by running:
cd resume-screener
- Install the additional packages your project needs by running:
npm install @google/genai @supabase/supabase-js pdf-parse mammoth zod
What are these packages?
- @google/genai connects your app to the Gemini API for AI-powered text extraction.
- @supabase/supabase-js is the client library for interacting with your Supabase database and storage.
- pdf-parse extracts text content from PDF files.
- mammoth extracts text content from DOCX files.
- zod validates the JSON structure returned by Gemini to catch malformed responses.
- Confirm your packages installed successfully by running:
ls node_modules/@google/genai node_modules/@supabase/supabase-js
You should see directory contents listed for both packages without any errors.
Seeing install errors?
Make sure you are inside the resume-screener folder before running npm install. Run pwd to check your current directory. If you see a path that does not end in resume-screener, run cd ~/Desktop/resume-screener and try the install again.
help me debug npm install errors in my Next.js project
Now create the environment variables file that stores your API credentials.
- Create a new file called .env.local in the root of your resume-screener folder and add the following content:
GEMINI_API_KEY=your-gemini-api-key-here
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url-here
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key-here
- Replace your-gemini-api-key-here with the Gemini API key you obtained from Google AI Studio earlier.
- Leave the Supabase values as placeholders for now. You will update them in the next substep after creating your Supabase project.
Why .env.local?
Next.js automatically loads variables from .env.local into your app. Variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Variables without the prefix (like GEMINI_API_KEY) are only available on the server side, keeping your API key secure.
Set up Supabase database and storage
Your app needs a database to store extracted candidate data and a storage bucket for the uploaded resume files.
- Open your browser and go to supabase.com/dashboard.
- Sign in to your Supabase account.
- Click New Project.
- Enter a project name (for example, resume-screener).
- Set a database password. Save this somewhere safe.
- Choose a region close to you.
- Click Create new project.
- Wait for the project to finish provisioning. This takes about 1-2 minutes.
Once your project is ready, grab the credentials you need for your environment variables.
- In the left sidebar, click Settings.
- Click API in the settings menu.
- Copy the Project URL value.
- Switch back to your code editor and replace your-supabase-project-url-here in .env.local with the URL you just copied.
- Back in the Supabase dashboard, copy the anon public key value.
- Replace your-supabase-anon-key-here in .env.local with the anon key you just copied.
- Save .env.local.
Now create the database table that will store candidate information.
- In the Supabase dashboard left sidebar, click SQL Editor.
- Paste the following SQL and click Run:
-- Create the candidates table to store extracted resume data
CREATE TABLE candidates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
skills JSONB,
education JSONB,
experience JSONB,
total_years_experience INTEGER,
raw_text TEXT,
file_name TEXT,
score INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Disable RLS for development (allows full access with the anon key)
ALTER TABLE candidates DISABLE ROW LEVEL SECURITY;
What does this SQL do?
This creates a candidates table with columns for everything the AI will extract from resumes: name, email, phone, skills, education, and experience. The JSONB columns store complex data like arrays of skills and structured education history.
Disabling Row Level Security (RLS) lets your app read and write freely using the anon key. For a production app you would add proper security policies, but for this learning project it keeps things simple.
You should see a "Success" message after running the SQL.
Next, create a storage bucket for uploaded resume files.
- In the left sidebar, click Storage.
- Click New bucket.
- Enter resumes as the bucket name.
- Toggle Public bucket to on.
- Click Create bucket.
The bucket is created, but uploads still need a storage policy to work. Add one now.
- Go back to the SQL Editor in the left sidebar.
- Paste the following SQL and click Run:
-- Allow uploads to the resumes bucket
CREATE POLICY "Allow uploads to resumes" ON storage.objects
FOR INSERT WITH CHECK (bucket_id = 'resumes');
Why do I need a storage policy?
Even though the bucket is public (meaning files can be read by anyone), Supabase still blocks uploads by default. This policy tells Supabase to allow INSERT operations (file uploads) on the resumes bucket.
You should see another "Success" message.
SQL not running?
Make sure you clear the SQL Editor before pasting new queries. If you see a "relation already exists" error for the candidates table, that means it was already created successfully. You can continue to the next step.
help me fix SQL Editor errors in Supabase
Create the Supabase and Gemini utility files
Your app needs reusable client utilities so every API route can talk to Supabase and Gemini without repeating setup code.
- In your code editor, create a new folder called lib inside the src folder of your project.
- Create a new file at src/lib/supabase.ts and add the following code:
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
What does this code do?
This creates a single Supabase client instance that reads your project URL and anon key from the environment variables. The ! after each process.env call tells TypeScript that the value will always be defined (since you set it in .env.local).
- Save src/lib/supabase.ts.
- Create a new file at src/lib/gemini.ts and add the following code:
import { GoogleGenAI } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
export async function extractCandidateData(resumeText: string) {
const prompt = `You are a resume parser. Extract the following structured data from this resume text and return ONLY valid JSON with no additional text or markdown formatting.
Return this exact JSON structure:
{
"name": "Full Name",
"email": "email@example.com",
"phone": "phone number or null",
"skills": ["skill1", "skill2"],
"education": [
{
"degree": "Degree Name",
"institution": "University Name",
"year": "Graduation Year or null"
}
],
"experience": [
{
"title": "Job Title",
"company": "Company Name",
"years": 2,
"description": "Brief description"
}
],
"totalYearsExperience": 5
}
Resume text:
${resumeText}`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
});
const text = response.text ?? '';
// Extract the JSON object from the response (Gemini may include extra text)
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('Failed to extract JSON from Gemini response');
}
return JSON.parse(jsonMatch[0]);
}
What does this code do?
This file initializes the Gemini client and exports an extractCandidateData function. The function sends a detailed prompt to Gemini asking it to extract structured candidate data from raw resume text and return it as JSON.
The regex /\{[\s\S]*\}/ at the end extracts the JSON object from Gemini's response. This handles cases where the model includes extra explanation text alongside the JSON.
- Save src/lib/gemini.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
Here is src/lib/gemini.ts:
import { GoogleGenAI } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
export async function extractCandidateData(resumeText: string) {
const prompt = `You are a resume parser. Extract the following structured data from this resume text and return ONLY valid JSON with no additional text or markdown formatting.
Return this exact JSON structure:
{
"name": "Full Name",
"email": "email@example.com",
"phone": "phone number or null",
"skills": ["skill1", "skill2"],
"education": [
{
"degree": "Degree Name",
"institution": "University Name",
"year": "Graduation Year or null"
}
],
"experience": [
{
"title": "Job Title",
"company": "Company Name",
"years": 2,
"description": "Brief description"
}
],
"totalYearsExperience": 5
}
Resume text:
${resumeText}`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: prompt,
});
const text = response.text ?? '';
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('Failed to extract JSON from Gemini response');
}
return JSON.parse(jsonMatch[0]);
}
And .env.local in the project root:
GEMINI_API_KEY=your-gemini-api-key-here
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url-here
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key-here
Time to verify everything works together.
- Start your development server by running:
npm run dev
- Open your browser and go to http://localhost:3000.
You should see the default Next.js welcome page load without any errors in the terminal.
Seeing errors in the terminal?
If you see "MODULE_NOT_FOUND" errors, make sure you ran npm install from inside the resume-screener folder. If you see environment variable errors, check that .env.local is in the project root (the same folder as package.json) and that you have saved the file.
help me fix my Next.js dev server startup errors
Your foundation is ready. You have a running Next.js app connected to Supabase and configured with Gemini. Next up, you will build the resume upload and text extraction pipeline.
Build Resume Upload and Text Extraction
Your project is configured and both Supabase and Gemini are ready to go. Now you need a way to get resume data into your system.
In this step, you will build the upload pipeline. This means accepting a resume file from the browser, storing it in Supabase Storage, and extracting the raw text so it can later be sent to Gemini for analysis.
In this step, get ready to:
- Build a file upload API route that stores resumes and extracts text.
- Build an upload UI component with file input and status messages.
- Test the full pipeline end-to-end with a sample resume.
Build the file upload API route
The upload route needs to handle three responsibilities: accept the file from the browser, store it in Supabase Storage for safekeeping, and extract the raw text content so you can process it later.
- Create a new file at src/app/api/upload/route.ts by creating the folders api and upload inside src/app/.
- Paste the following code into src/app/api/upload/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import pdf from 'pdf-parse';
import mammoth from 'mammoth';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('resume') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Only allow PDF and DOCX uploads
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Only PDF and DOCX files are accepted' },
{ status: 400 }
);
}
// Upload to Supabase Storage with a unique timestamped name
const fileName = `${Date.now()}-${file.name}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const { error: uploadError } = await supabase.storage
.from('resumes')
.upload(fileName, buffer, { contentType: file.type });
if (uploadError) {
return NextResponse.json({ error: 'Upload failed: ' + uploadError.message }, { status: 500 });
}
// Extract text based on file type
let extractedText = '';
if (file.type === 'application/pdf') {
const pdfData = await pdf(buffer);
extractedText = pdfData.text;
} else {
const result = await mammoth.extractRawText({ buffer });
extractedText = result.value;
}
return NextResponse.json({
success: true,
fileName,
extractedText,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
What does this code do?
- The route reads the incoming file from formData and checks that it is either a PDF or DOCX. Any other file type gets rejected with a 400 error.
- It creates a unique filename using Date.now() to avoid collisions, then uploads the raw buffer to your Supabase resumes storage bucket.
- For PDFs, pdf-parse extracts the text. For DOCX files, mammoth uses extractRawText to pull plain text without HTML overhead.
- The response returns both the stored fileName and the extractedText so the next step in the pipeline can use them.
- Save src/app/api/upload/route.ts.
- Check that your dev server is still running without errors in the terminal.
Seeing a module not found error for pdf-parse?
If you see a 404 or module resolution error when the route loads, you may need to add pdf-parse to your Next.js server external packages. Open next.config.ts and add serverExternalPackages: ["pdf-parse"] inside the config object.
If TypeScript reports missing types for pdf-parse, install the type definitions by running npm install --save-dev @types/pdf-parse in your terminal.
help me fix a pdf-parse module error in my Next.js API route
Build the upload UI component
With the API route ready, you need a front-end interface where users can select a resume file and trigger the upload. You will replace the default Next.js homepage with an upload component.
- Open src/app/page.tsx in your editor.
- Replace all the existing content with the following code:
'use client';
import { useState } from 'react';
export default function Home() {
const [uploading, setUploading] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setStatusMessage('Uploading and extracting text...');
try {
// Upload file and extract text
const formData = new FormData();
formData.append('resume', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
setStatusMessage(`Error: ${uploadData.error}`);
return;
}
setStatusMessage('Resume processed successfully!');
} catch (error) {
setStatusMessage('An unexpected error occurred.');
console.error(error);
} finally {
setUploading(false);
}
}
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">AI Resume Screening Engine</h1>
{/* Upload Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Upload Resume</h2>
<div className="flex items-center gap-4">
<label className="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">
{uploading ? 'Processing...' : 'Choose PDF or DOCX'}
<input
type="file"
accept=".pdf,.docx"
onChange={handleUpload}
disabled={uploading}
className="hidden"
/>
</label>
</div>
{statusMessage && (
<p className="mt-3 text-sm text-gray-600">{statusMessage}</p>
)}
</div>
</div>
</main>
);
}
What does this code do?
- The 'use client' directive marks this as a client component so React hooks like useState work in the browser.
- Two state variables track upload progress: uploading disables the button while processing, and statusMessage shows feedback to the user.
- The handleUpload function creates a FormData object, appends the file under the key resume (matching what the API route expects), and sends it to /api/upload.
- The hidden file input is wrapped inside a styled label so the button text changes to "Processing..." while the upload is in progress.
- Save src/app/page.tsx.
- Open your browser to http://localhost:3000. You should see a white page with the heading "AI Resume Screening Engine" and a blue "Choose PDF or DOCX" button.
Page not updating?
Make sure you saved both files. If the page still shows the default Next.js landing, check that you replaced the entire contents of src/app/page.tsx and not a different file.
If you see a hydration error in the console, confirm that 'use client' is the very first line of the file with no blank lines above it.
help me debug why my Next.js page is not rendering the upload UI
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on to test the pipeline.
ⓧ I'd like to double check the full code
Here is the complete upload API route. Make sure yours matches exactly:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import pdf from 'pdf-parse';
import mammoth from 'mammoth';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('resume') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Only PDF and DOCX files are accepted' },
{ status: 400 }
);
}
const fileName = `${Date.now()}-${file.name}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const { error: uploadError } = await supabase.storage
.from('resumes')
.upload(fileName, buffer, { contentType: file.type });
if (uploadError) {
return NextResponse.json({ error: 'Upload failed: ' + uploadError.message }, { status: 500 });
}
let extractedText = '';
if (file.type === 'application/pdf') {
const pdfData = await pdf(buffer);
extractedText = pdfData.text;
} else {
const result = await mammoth.extractRawText({ buffer });
extractedText = result.value;
}
return NextResponse.json({
success: true,
fileName,
extractedText,
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Test the pipeline end-to-end
Now you will upload a real resume to confirm that text extraction works and the file lands in Supabase Storage.
- Find any PDF or DOCX resume on your computer. If you do not have one, create a simple DOCX in Google Docs or Word with a few lines of text (name, skills, experience) and download it.
- In your browser at http://localhost:3000, click the Choose PDF or DOCX button.
- Select your resume file.
- Watch the status message change to "Uploading and extracting text..." and then to "Resume processed successfully!" once done.
How do I verify the file is in Supabase?
Open your Supabase project dashboard in a new browser tab. Navigate to Storage in the left sidebar, then click on the resumes bucket. You should see your uploaded file with a timestamped name like 1720612345678-resume.pdf.
This confirms the full pipeline works: file accepted, stored in the cloud, and text extracted ready for AI processing.
Seeing "Upload failed" or an internal server error?
Check that your resumes bucket exists in Supabase Storage. Open your Supabase dashboard, go to Storage, and confirm the bucket name is exactly resumes.
Make sure your Supabase storage policies allow uploads. In the resumes bucket settings, verify that INSERT permissions are enabled for the anon role.
If you see a CORS or network error, confirm that NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in your .env.local file are correct and that you restarted the dev server after editing them.
help me debug why my resume upload to Supabase Storage is failing
Your upload pipeline is working. Files go in, text comes out. Next up, you will send that extracted text to Gemini so the AI can pull structured candidate data from it.
Extract Structured Data with Gemini AI
Your upload pipeline can pull raw text out of resumes, but raw text alone is not useful for comparing candidates. To rank applicants, you need structured data: names, skills, experience years, and education in a consistent format.
In this step, you will send extracted resume text to Gemini for structured data extraction, validate the AI's response with Zod schemas, and store clean candidate records in Supabase.
In this step, get ready to:
- Define Zod validation schemas for candidate data extraction.
- Build the extraction and candidates API routes.
- Test the full pipeline with a sample resume.
Define Zod schemas for candidate data
LLMs produce unpredictable output. Even with a strict prompt, Gemini might return unexpected field types or missing properties. Zod schemas define the exact shape you expect so you can catch malformed responses before they reach the database.
- Create a new file called src/lib/schemas.ts and add the following validation schemas:
import * as z from 'zod';
// Schema for a single education entry
export const educationSchema = z.object({
degree: z.string(),
institution: z.string(),
year: z.string().nullable(),
});
// Schema for a single experience entry
export const experienceSchema = z.object({
title: z.string(),
company: z.string(),
years: z.number(),
description: z.string(),
});
// Schema for the full candidate data returned by Gemini
export const candidateDataSchema = z.object({
name: z.string(),
email: z.string().email().or(z.string()),
phone: z.string().nullable(),
skills: z.array(z.string()),
education: z.array(educationSchema),
experience: z.array(experienceSchema),
totalYearsExperience: z.number(),
});
export type CandidateData = z.infer<typeof candidateDataSchema>;
// Schema for job scoring criteria (used in the next step)
export const jobCriteriaSchema = z.object({
requiredSkills: z.array(
z.object({
skill: z.string(),
weight: z.number().min(1).max(10),
})
),
minimumExperienceYears: z.number(),
preferredEducation: z.array(z.string()),
experienceWeight: z.number().min(1).max(10),
educationWeight: z.number().min(1).max(10),
});
export type JobCriteria = z.infer<typeof jobCriteriaSchema>;
What does this code do?
- educationSchema and experienceSchema define the shape of nested arrays inside the candidate data. Each education entry has a degree, institution, and optional year.
- candidateDataSchema is the main schema that validates the full JSON object Gemini returns. The .email().or(z.string()) fallback handles cases where Gemini extracts something that looks like an email but does not perfectly validate.
- jobCriteriaSchema defines the structure for scoring criteria. You will use this in the next step to evaluate candidates against a rubric.
- z.infer<typeof candidateDataSchema> generates a TypeScript type from the schema, so you get type safety everywhere the data flows.
- Save src/lib/schemas.ts.
Seeing import errors?
Make sure you installed zod in Step 1. If you missed it, run npm install zod in your terminal. Zod 4 uses the import * as z from 'zod' syntax, not import { z } from 'zod'.
Help me fix Zod import errors in my Next.js project.
Build the extraction API route
The extraction route is the bridge between raw resume text and structured database records. It calls Gemini, validates the response, and inserts the clean data into Supabase.
- Create a new file at src/app/api/extract/route.ts and add the following code:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { extractCandidateData } from '@/lib/gemini';
import { candidateDataSchema } from '@/lib/schemas';
export async function POST(request: NextRequest) {
try {
const { extractedText, fileName } = await request.json();
if (!extractedText) {
return NextResponse.json({ error: 'No text provided' }, { status: 400 });
}
// Send resume text to Gemini for structured extraction
const rawData = await extractCandidateData(extractedText);
// Validate the AI response against our schema
const parseResult = candidateDataSchema.safeParse(rawData);
if (!parseResult.success) {
return NextResponse.json(
{ error: 'Failed to validate extracted data', details: parseResult.error.issues },
{ status: 422 }
);
}
const candidateData = parseResult.data;
// Store structured candidate data in Supabase
const { data, error } = await supabase
.from('candidates')
.insert({
name: candidateData.name,
email: candidateData.email,
phone: candidateData.phone,
skills: candidateData.skills,
education: candidateData.education,
experience: candidateData.experience,
total_years_experience: candidateData.totalYearsExperience,
raw_text: extractedText,
file_name: fileName,
score: 0,
})
.select()
.single();
if (error) {
return NextResponse.json({ error: 'Database insert failed: ' + error.message }, { status: 500 });
}
return NextResponse.json({ success: true, candidate: data });
} catch (error) {
console.error('Extraction error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
What does this code do?
- The route accepts extractedText and fileName from the upload step. It passes the text to extractCandidateData() which calls the Gemini API.
- candidateDataSchema.safeParse(rawData) validates Gemini's response without throwing. If validation fails, the route returns a 422 status with the specific issues so you can debug.
- On success, the validated data maps into Supabase column names. Notice totalYearsExperience (camelCase from Gemini) becomes total_years_experience (snake_case for the database).
- Save src/app/api/extract/route.ts.
Next, you need a simple API route that returns all stored candidates so the frontend can display them.
- Create a new file at src/app/api/candidates/route.ts and add the following code:
import { NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function GET() {
// Fetch all candidates, ordered by score (highest first)
const { data, error } = await supabase
.from('candidates')
.select('*')
.order('score', { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ candidates: data });
}
What does this code do?
This GET route fetches all candidates from Supabase, sorted by score in descending order. The frontend will call this to populate the candidate list after each upload.
- Save src/app/api/candidates/route.ts.
Now update your page to chain the extraction call after upload completes.
- In src/app/page.tsx, find your existing handleUpload function and replace it with this updated version that also calls /api/extract:
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setStatusMessage('Uploading and extracting text...');
try {
// Step 1: Upload and extract text
const formData = new FormData();
formData.append('resume', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
setStatusMessage(`Error: ${uploadData.error}`);
return;
}
setStatusMessage('Extracting candidate data with AI...');
// Step 2: Extract structured data with Gemini
const extractRes = await fetch('/api/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
extractedText: uploadData.extractedText,
fileName: uploadData.fileName,
}),
});
const extractData = await extractRes.json();
if (!extractRes.ok) {
setStatusMessage(`Error: ${extractData.error}`);
return;
}
setStatusMessage('Resume processed successfully!');
fetchCandidates();
} catch (error) {
setStatusMessage('An unexpected error occurred.');
console.error(error);
} finally {
setUploading(false);
}
}
What changed in handleUpload?
After the upload succeeds, the function now makes a second fetch to /api/extract with the extracted text and file name. This triggers Gemini extraction and database storage in one step.
The fetchCandidates() call at the end refreshes the candidate list on the page. Make sure you also have a fetchCandidates function that calls /api/candidates and updates your component state.
- Add a fetchCandidates function to your page component that fetches from /api/candidates:
async function fetchCandidates() {
const response = await fetch('/api/candidates');
if (response.ok) {
const data = await response.json();
setCandidates(data.candidates ?? []);
}
}
- Save src/app/page.tsx.
Seeing a 422 error from the extract route?
A 422 response means Gemini returned JSON that did not match your Zod schema. Check the details field in the error response to see which fields failed validation. Common causes: Gemini returning a string for totalYearsExperience instead of a number, or wrapping JSON in markdown code fences.
Help me debug a 422 validation error from my Gemini extraction route.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete code for each new file:
import * as z from 'zod';
export const educationSchema = z.object({
degree: z.string(),
institution: z.string(),
year: z.string().nullable(),
});
export const experienceSchema = z.object({
title: z.string(),
company: z.string(),
years: z.number(),
description: z.string(),
});
export const candidateDataSchema = z.object({
name: z.string(),
email: z.string().email().or(z.string()),
phone: z.string().nullable(),
skills: z.array(z.string()),
education: z.array(educationSchema),
experience: z.array(experienceSchema),
totalYearsExperience: z.number(),
});
export type CandidateData = z.infer<typeof candidateDataSchema>;
export const jobCriteriaSchema = z.object({
requiredSkills: z.array(
z.object({
skill: z.string(),
weight: z.number().min(1).max(10),
})
),
minimumExperienceYears: z.number(),
preferredEducation: z.array(z.string()),
experienceWeight: z.number().min(1).max(10),
educationWeight: z.number().min(1).max(10),
});
export type JobCriteria = z.infer<typeof jobCriteriaSchema>;
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { extractCandidateData } from '@/lib/gemini';
import { candidateDataSchema } from '@/lib/schemas';
export async function POST(request: NextRequest) {
try {
const { extractedText, fileName } = await request.json();
if (!extractedText) {
return NextResponse.json({ error: 'No text provided' }, { status: 400 });
}
const rawData = await extractCandidateData(extractedText);
const parseResult = candidateDataSchema.safeParse(rawData);
if (!parseResult.success) {
return NextResponse.json(
{ error: 'Failed to validate extracted data', details: parseResult.error.issues },
{ status: 422 }
);
}
const candidateData = parseResult.data;
const { data, error } = await supabase
.from('candidates')
.insert({
name: candidateData.name,
email: candidateData.email,
phone: candidateData.phone,
skills: candidateData.skills,
education: candidateData.education,
experience: candidateData.experience,
total_years_experience: candidateData.totalYearsExperience,
raw_text: extractedText,
file_name: fileName,
score: 0,
})
.select()
.single();
if (error) {
return NextResponse.json({ error: 'Database insert failed: ' + error.message }, { status: 500 });
}
return NextResponse.json({ success: true, candidate: data });
} catch (error) {
console.error('Extraction error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
import { NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function GET() {
const { data, error } = await supabase
.from('candidates')
.select('*')
.order('score', { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ candidates: data });
}
Test with a sample resume
Time to verify the full pipeline works end-to-end.
- Make sure your dev server is running. If it is not, start it by running:
npm run dev
- Open http://localhost:3000 in your browser.
- Upload a sample resume (PDF or DOCX). If you do not have one handy, create a simple DOCX with your name, email, a few skills, and a job title.
- Watch the status message update from "Uploading and extracting text..." to "Extracting candidate data with AI..." to "Resume processed successfully!"
- Open your Supabase dashboard and navigate to the candidates table under Table Editor.
- Confirm that a new row exists with structured data in the name, skills, education, and experience columns.
Not seeing a new row in Supabase?
- Check your browser's DevTools Network tab for the /api/extract request. A 422 means Zod validation failed. A 500 could mean the Supabase insert failed due to a column mismatch.
- Verify your candidates table has a file_name column (TEXT type) and a total_years_experience column (INTEGER type).
- Make sure your GEMINI_API_KEY in .env.local is valid. An invalid key returns a generic 500 error.
Help me debug why my resume data is not appearing in Supabase after upload.
Your extraction pipeline is working. Resumes go in as unstructured text and come out as validated, structured database records. Next up, you will build a scoring engine that evaluates each candidate against configurable job criteria.
Build the Scoring Engine and Candidate Dashboard
Your upload pipeline extracts structured candidate data from resumes. Now you need a way to evaluate those candidates against a specific job's requirements.
In this step, you will build a scoring engine that compares each candidate's skills, experience, and education against a weighted rubric. Then you will create a dashboard to visualize ranked results with filtering.
In this step, get ready to:
- Build a scoring function that evaluates candidates against a configurable job criteria rubric.
- Create a scoring API route that processes and ranks all candidates.
- Build a candidate dashboard with a ranked table and score-based filtering.
Build the scoring function
The scoring engine needs to compare a candidate's extracted data against a set of weighted criteria. Each skill match, experience threshold, and education preference contributes a different amount to the final score.
- Create a new file at src/lib/scoring.ts and add the following scoring function:
import { CandidateData, JobCriteria } from './schemas';
export function scoreCandidate(candidate: CandidateData, criteria: JobCriteria): number {
let totalScore = 0;
let maxPossibleScore = 0;
// Score skills
for (const requiredSkill of criteria.requiredSkills) {
maxPossibleScore += requiredSkill.weight * 10;
const hasSkill = candidate.skills.some(
(skill) => skill.toLowerCase().includes(requiredSkill.skill.toLowerCase())
);
if (hasSkill) {
totalScore += requiredSkill.weight * 10;
}
}
// Score experience
maxPossibleScore += criteria.experienceWeight * 10;
if (candidate.totalYearsExperience >= criteria.minimumExperienceYears) {
totalScore += criteria.experienceWeight * 10;
} else if (candidate.totalYearsExperience > 0) {
const ratio = candidate.totalYearsExperience / criteria.minimumExperienceYears;
totalScore += Math.round(criteria.experienceWeight * 10 * ratio);
}
// Score education
maxPossibleScore += criteria.educationWeight * 10;
const hasPreferredEducation = candidate.education.some((edu) =>
criteria.preferredEducation.some(
(pref) => edu.degree.toLowerCase().includes(pref.toLowerCase())
)
);
if (hasPreferredEducation) {
totalScore += criteria.educationWeight * 10;
}
// Return percentage score
return maxPossibleScore > 0 ? Math.round((totalScore / maxPossibleScore) * 100) : 0;
}
What does this code do?
The scoreCandidate function evaluates a candidate against three dimensions, each contributing weighted points to a maximum possible score.
- Skills scoring loops through each required skill. If the candidate has a matching skill, they earn that skill's weight multiplied by 10.
- Experience scoring awards full points if the candidate meets the minimum years. Candidates with some but insufficient experience earn a proportional score based on the ratio.
- Education scoring checks if any of the candidate's degrees match the preferred education list.
- The final return converts the raw score into a 0-100 percentage by dividing total earned points by max possible points.
- Save src/lib/scoring.ts.
Why weighted scoring instead of a simple checklist?
A simple yes/no checklist treats all skills equally. Weighted scoring lets you prioritize what matters most for a specific role. A senior React position might weight React at 8 but CSS at 3, producing more meaningful rankings.
Seeing a type error?
Make sure the import path matches your file structure exactly. The CandidateData and JobCriteria types are exported from src/lib/schemas.ts which you created in the previous step.
Why isn't my scoring function working?
Create the scoring API route
The scoring function works on a single candidate. You need an API route that fetches all candidates from the database, scores each one against the provided criteria, and updates their scores.
- Create a new file at src/app/api/score/route.ts and add the following code:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { jobCriteriaSchema, CandidateData } from '@/lib/schemas';
import { scoreCandidate } from '@/lib/scoring';
export async function POST(request: NextRequest) {
try {
const { criteria } = await request.json();
// Validate criteria
const parseResult = jobCriteriaSchema.safeParse(criteria);
if (!parseResult.success) {
return NextResponse.json(
{ error: 'Invalid criteria', details: parseResult.error.issues },
{ status: 400 }
);
}
const validatedCriteria = parseResult.data;
// Fetch all candidates
const { data: candidates, error } = await supabase
.from('candidates')
.select('*');
if (error) {
return NextResponse.json({ error: 'Failed to fetch candidates' }, { status: 500 });
}
// Score each candidate
const scoredCandidates = [];
for (const candidate of candidates ?? []) {
const candidateData: CandidateData = {
name: candidate.name,
email: candidate.email,
phone: candidate.phone,
skills: candidate.skills,
education: candidate.education,
experience: candidate.experience,
totalYearsExperience: candidate.total_years_experience,
};
const score = scoreCandidate(candidateData, validatedCriteria);
// Update score in database
await supabase
.from('candidates')
.update({ score })
.eq('id', candidate.id);
scoredCandidates.push({ ...candidate, score });
}
// Sort by score descending
scoredCandidates.sort((a, b) => b.score - a.score);
return NextResponse.json({ success: true, candidates: scoredCandidates });
} catch (error) {
console.error('Scoring error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
What does this route do?
- Validates the incoming criteria JSON against jobCriteriaSchema using Zod to ensure the request is well-formed.
- Fetches every candidate from the candidates table in Supabase.
- Maps each database row into the CandidateData shape, scores it, and writes the new score back to the database.
- Returns the full candidate list sorted by score (highest first) so the frontend can display ranked results immediately.
- Save src/app/api/score/route.ts.
Import errors for scoreCandidate?
Confirm that src/lib/scoring.ts exports the function with the export keyword. Also check that the file is saved without typos in the filename.
Help me fix my score API route imports.
Build the candidate dashboard
The backend is ready. Now you need a full dashboard that ties everything together: uploading resumes, triggering scoring, filtering results, and displaying ranked candidates in a table.
- Open src/app/page.tsx and replace its entire contents with the following code:
'use client';
import { useState, useEffect } from 'react';
interface Candidate {
id: string;
name: string;
email: string;
phone: string | null;
skills: string[];
education: { degree: string; institution: string; year: string | null }[];
experience: { title: string; company: string; years: number; description: string }[];
total_years_experience: number;
score: number;
created_at: string;
}
const DEFAULT_CRITERIA = {
requiredSkills: [
{ skill: 'JavaScript', weight: 8 },
{ skill: 'React', weight: 7 },
{ skill: 'Node.js', weight: 6 },
{ skill: 'TypeScript', weight: 5 },
],
minimumExperienceYears: 3,
preferredEducation: ['Computer Science', 'Software Engineering', 'Information Technology'],
experienceWeight: 7,
educationWeight: 4,
};
export default function Home() {
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [uploading, setUploading] = useState(false);
const [scoring, setScoring] = useState(false);
const [filterMinScore, setFilterMinScore] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
useEffect(() => {
fetchCandidates();
}, []);
async function fetchCandidates() {
const response = await fetch('/api/candidates');
if (response.ok) {
const data = await response.json();
setCandidates(data.candidates ?? []);
}
}
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setStatusMessage('Uploading and extracting text...');
try {
// Step 1: Upload and extract text
const formData = new FormData();
formData.append('resume', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
setStatusMessage(`Error: ${uploadData.error}`);
return;
}
setStatusMessage('Extracting candidate data with AI...');
// Step 2: Extract structured data with Gemini
const extractRes = await fetch('/api/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
extractedText: uploadData.extractedText,
fileName: uploadData.fileName,
}),
});
const extractData = await extractRes.json();
if (!extractRes.ok) {
setStatusMessage(`Error: ${extractData.error}`);
return;
}
setStatusMessage('Resume processed successfully!');
fetchCandidates();
} catch (error) {
setStatusMessage('An unexpected error occurred.');
console.error(error);
} finally {
setUploading(false);
}
}
async function handleScoreAll() {
setScoring(true);
setStatusMessage('Scoring candidates...');
try {
const response = await fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criteria: DEFAULT_CRITERIA }),
});
const data = await response.json();
if (response.ok) {
setCandidates(data.candidates);
setStatusMessage('Scoring complete!');
} else {
setStatusMessage(`Error: ${data.error}`);
}
} catch (error) {
setStatusMessage('Scoring failed.');
console.error(error);
} finally {
setScoring(false);
}
}
const filteredCandidates = candidates.filter((c) => c.score >= filterMinScore);
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">AI Resume Screening Engine</h1>
{/* Upload Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Upload Resume</h2>
<div className="flex items-center gap-4">
<label className="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">
{uploading ? 'Processing...' : 'Choose PDF or DOCX'}
<input
type="file"
accept=".pdf,.docx"
onChange={handleUpload}
disabled={uploading}
className="hidden"
/>
</label>
<button
onClick={handleScoreAll}
disabled={scoring || candidates.length === 0}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
{scoring ? 'Scoring...' : 'Score All Candidates'}
</button>
</div>
{statusMessage && (
<p className="mt-3 text-sm text-gray-600">{statusMessage}</p>
)}
</div>
{/* Filter Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Filter Candidates</h2>
<div className="flex items-center gap-4">
<label className="text-sm text-gray-600">Minimum Score:</label>
<input
type="range"
min="0"
max="100"
value={filterMinScore}
onChange={(e) => setFilterMinScore(Number(e.target.value))}
className="w-48"
/>
<span className="text-sm font-medium">{filterMinScore}%</span>
<span className="text-sm text-gray-500">
({filteredCandidates.length} of {candidates.length} shown)
</span>
</div>
</div>
{/* Candidates Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Rank</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Email</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Skills</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Experience</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Score</th>
</tr>
</thead>
<tbody>
{filteredCandidates.map((candidate, index) => (
<tr key={candidate.id} className="border-t hover:bg-gray-50">
<td className="px-4 py-3 text-sm">{index + 1}</td>
<td className="px-4 py-3 text-sm font-medium">{candidate.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{candidate.email}</td>
<td className="px-4 py-3 text-sm">
<div className="flex flex-wrap gap-1">
{candidate.skills.slice(0, 4).map((skill) => (
<span
key={skill}
className="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded"
>
{skill}
</span>
))}
{candidate.skills.length > 4 && (
<span className="text-xs text-gray-500">
+{candidate.skills.length - 4} more
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm">{candidate.total_years_experience} yrs</td>
<td className="px-4 py-3 text-sm">
<span
className={`font-bold ${
candidate.score >= 70
? 'text-green-600'
: candidate.score >= 40
? 'text-yellow-600'
: 'text-red-600'
}`}
>
{candidate.score}%
</span>
</td>
</tr>
))}
{filteredCandidates.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No candidates yet. Upload resumes to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Criteria Display */}
<div className="bg-white rounded-lg shadow p-6 mt-6">
<h2 className="text-xl font-semibold mb-4">Current Scoring Criteria</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Required Skills</h3>
<ul className="space-y-1">
{DEFAULT_CRITERIA.requiredSkills.map((s) => (
<li key={s.skill} className="text-sm">
{s.skill} <span className="text-gray-400">(weight: {s.weight})</span>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Min Experience</h3>
<p className="text-sm">{DEFAULT_CRITERIA.minimumExperienceYears} years</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Preferred Education</h3>
<ul className="space-y-1">
{DEFAULT_CRITERIA.preferredEducation.map((edu) => (
<li key={edu} className="text-sm">{edu}</li>
))}
</ul>
</div>
</div>
</div>
</div>
</main>
);
}
What does this dashboard do?
This single component handles the entire user-facing experience.
- The DEFAULT_CRITERIA constant defines a sample job rubric with weighted skills, minimum experience, and preferred education. You can change these values to match any role.
- The handleUpload function orchestrates two API calls in sequence: first uploading and extracting text, then sending that text to Gemini for structured data extraction.
- The handleScoreAll function sends the criteria to your scoring route and updates the table with ranked results.
- The filterMinScore state drives a range slider that hides candidates below a threshold score, making it easy to focus on top matches.
- Save src/app/page.tsx.
- Return to your browser at http://localhost:3000. You should see the full dashboard with an "Upload Resume" button, a "Score All Candidates" button, a filter slider, an empty candidates table, and a "Current Scoring Criteria" panel at the bottom.
Page not rendering or showing errors?
- Check your terminal for compilation errors. A missing closing brace or mismatched JSX tag is the most common cause.
- Make sure 'use client' is the very first line of the file, before any imports.
- Verify that the /api/candidates route file still exists at src/app/api/candidates/route.ts from the previous step.
My dashboard page is not rendering correctly. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before testing.
ⓧ I'd like to double check the full code
Here is the full code for each new file in this step.
import { CandidateData, JobCriteria } from './schemas';
export function scoreCandidate(candidate: CandidateData, criteria: JobCriteria): number {
let totalScore = 0;
let maxPossibleScore = 0;
// Score skills
for (const requiredSkill of criteria.requiredSkills) {
maxPossibleScore += requiredSkill.weight * 10;
const hasSkill = candidate.skills.some(
(skill) => skill.toLowerCase().includes(requiredSkill.skill.toLowerCase())
);
if (hasSkill) {
totalScore += requiredSkill.weight * 10;
}
}
// Score experience
maxPossibleScore += criteria.experienceWeight * 10;
if (candidate.totalYearsExperience >= criteria.minimumExperienceYears) {
totalScore += criteria.experienceWeight * 10;
} else if (candidate.totalYearsExperience > 0) {
const ratio = candidate.totalYearsExperience / criteria.minimumExperienceYears;
totalScore += Math.round(criteria.experienceWeight * 10 * ratio);
}
// Score education
maxPossibleScore += criteria.educationWeight * 10;
const hasPreferredEducation = candidate.education.some((edu) =>
criteria.preferredEducation.some(
(pref) => edu.degree.toLowerCase().includes(pref.toLowerCase())
)
);
if (hasPreferredEducation) {
totalScore += criteria.educationWeight * 10;
}
// Return percentage score
return maxPossibleScore > 0 ? Math.round((totalScore / maxPossibleScore) * 100) : 0;
}
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { jobCriteriaSchema, CandidateData } from '@/lib/schemas';
import { scoreCandidate } from '@/lib/scoring';
export async function POST(request: NextRequest) {
try {
const { criteria } = await request.json();
// Validate criteria
const parseResult = jobCriteriaSchema.safeParse(criteria);
if (!parseResult.success) {
return NextResponse.json(
{ error: 'Invalid criteria', details: parseResult.error.issues },
{ status: 400 }
);
}
const validatedCriteria = parseResult.data;
// Fetch all candidates
const { data: candidates, error } = await supabase
.from('candidates')
.select('*');
if (error) {
return NextResponse.json({ error: 'Failed to fetch candidates' }, { status: 500 });
}
// Score each candidate
const scoredCandidates = [];
for (const candidate of candidates ?? []) {
const candidateData: CandidateData = {
name: candidate.name,
email: candidate.email,
phone: candidate.phone,
skills: candidate.skills,
education: candidate.education,
experience: candidate.experience,
totalYearsExperience: candidate.total_years_experience,
};
const score = scoreCandidate(candidateData, validatedCriteria);
// Update score in database
await supabase
.from('candidates')
.update({ score })
.eq('id', candidate.id);
scoredCandidates.push({ ...candidate, score });
}
// Sort by score descending
scoredCandidates.sort((a, b) => b.score - a.score);
return NextResponse.json({ success: true, candidates: scoredCandidates });
} catch (error) {
console.error('Scoring error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
'use client';
import { useState, useEffect } from 'react';
interface Candidate {
id: string;
name: string;
email: string;
phone: string | null;
skills: string[];
education: { degree: string; institution: string; year: string | null }[];
experience: { title: string; company: string; years: number; description: string }[];
total_years_experience: number;
score: number;
created_at: string;
}
const DEFAULT_CRITERIA = {
requiredSkills: [
{ skill: 'JavaScript', weight: 8 },
{ skill: 'React', weight: 7 },
{ skill: 'Node.js', weight: 6 },
{ skill: 'TypeScript', weight: 5 },
],
minimumExperienceYears: 3,
preferredEducation: ['Computer Science', 'Software Engineering', 'Information Technology'],
experienceWeight: 7,
educationWeight: 4,
};
export default function Home() {
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [uploading, setUploading] = useState(false);
const [scoring, setScoring] = useState(false);
const [filterMinScore, setFilterMinScore] = useState(0);
const [statusMessage, setStatusMessage] = useState('');
useEffect(() => {
fetchCandidates();
}, []);
async function fetchCandidates() {
const response = await fetch('/api/candidates');
if (response.ok) {
const data = await response.json();
setCandidates(data.candidates ?? []);
}
}
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setStatusMessage('Uploading and extracting text...');
try {
// Step 1: Upload and extract text
const formData = new FormData();
formData.append('resume', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (!uploadRes.ok) {
setStatusMessage(`Error: ${uploadData.error}`);
return;
}
setStatusMessage('Extracting candidate data with AI...');
// Step 2: Extract structured data with Gemini
const extractRes = await fetch('/api/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
extractedText: uploadData.extractedText,
fileName: uploadData.fileName,
}),
});
const extractData = await extractRes.json();
if (!extractRes.ok) {
setStatusMessage(`Error: ${extractData.error}`);
return;
}
setStatusMessage('Resume processed successfully!');
fetchCandidates();
} catch (error) {
setStatusMessage('An unexpected error occurred.');
console.error(error);
} finally {
setUploading(false);
}
}
async function handleScoreAll() {
setScoring(true);
setStatusMessage('Scoring candidates...');
try {
const response = await fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criteria: DEFAULT_CRITERIA }),
});
const data = await response.json();
if (response.ok) {
setCandidates(data.candidates);
setStatusMessage('Scoring complete!');
} else {
setStatusMessage(`Error: ${data.error}`);
}
} catch (error) {
setStatusMessage('Scoring failed.');
console.error(error);
} finally {
setScoring(false);
}
}
const filteredCandidates = candidates.filter((c) => c.score >= filterMinScore);
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-8">AI Resume Screening Engine</h1>
{/* Upload Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Upload Resume</h2>
<div className="flex items-center gap-4">
<label className="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">
{uploading ? 'Processing...' : 'Choose PDF or DOCX'}
<input
type="file"
accept=".pdf,.docx"
onChange={handleUpload}
disabled={uploading}
className="hidden"
/>
</label>
<button
onClick={handleScoreAll}
disabled={scoring || candidates.length === 0}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
{scoring ? 'Scoring...' : 'Score All Candidates'}
</button>
</div>
{statusMessage && (
<p className="mt-3 text-sm text-gray-600">{statusMessage}</p>
)}
</div>
{/* Filter Section */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Filter Candidates</h2>
<div className="flex items-center gap-4">
<label className="text-sm text-gray-600">Minimum Score:</label>
<input
type="range"
min="0"
max="100"
value={filterMinScore}
onChange={(e) => setFilterMinScore(Number(e.target.value))}
className="w-48"
/>
<span className="text-sm font-medium">{filterMinScore}%</span>
<span className="text-sm text-gray-500">
({filteredCandidates.length} of {candidates.length} shown)
</span>
</div>
</div>
{/* Candidates Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Rank</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Email</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Skills</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Experience</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">Score</th>
</tr>
</thead>
<tbody>
{filteredCandidates.map((candidate, index) => (
<tr key={candidate.id} className="border-t hover:bg-gray-50">
<td className="px-4 py-3 text-sm">{index + 1}</td>
<td className="px-4 py-3 text-sm font-medium">{candidate.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{candidate.email}</td>
<td className="px-4 py-3 text-sm">
<div className="flex flex-wrap gap-1">
{candidate.skills.slice(0, 4).map((skill) => (
<span
key={skill}
className="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded"
>
{skill}
</span>
))}
{candidate.skills.length > 4 && (
<span className="text-xs text-gray-500">
+{candidate.skills.length - 4} more
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm">{candidate.total_years_experience} yrs</td>
<td className="px-4 py-3 text-sm">
<span
className={`font-bold ${
candidate.score >= 70
? 'text-green-600'
: candidate.score >= 40
? 'text-yellow-600'
: 'text-red-600'
}`}
>
{candidate.score}%
</span>
</td>
</tr>
))}
{filteredCandidates.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No candidates yet. Upload resumes to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Criteria Display */}
<div className="bg-white rounded-lg shadow p-6 mt-6">
<h2 className="text-xl font-semibold mb-4">Current Scoring Criteria</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Required Skills</h3>
<ul className="space-y-1">
{DEFAULT_CRITERIA.requiredSkills.map((s) => (
<li key={s.skill} className="text-sm">
{s.skill} <span className="text-gray-400">(weight: {s.weight})</span>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Min Experience</h3>
<p className="text-sm">{DEFAULT_CRITERIA.minimumExperienceYears} years</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Preferred Education</h3>
<ul className="space-y-1">
{DEFAULT_CRITERIA.preferredEducation.map((edu) => (
<li key={edu} className="text-sm">{edu}</li>
))}
</ul>
</div>
</div>
</div>
</div>
</main>
);
}
Now test the full pipeline end-to-end to confirm everything works together.
- Upload a sample PDF or DOCX resume using the Choose PDF or DOCX button.
- Wait for the status message to show "Resume processed successfully!" and confirm the candidate appears in the table.
- Click Score All Candidates to run the scoring engine.
- Verify that the score column updates with a percentage value and the status shows "Scoring complete!".
- Drag the minimum score slider to the right and confirm that candidates below the threshold disappear from the table.
No resume handy for testing?
Create a simple .docx file in any word processor with a name, email, a few skills like "JavaScript" and "React", and a brief work history. This gives you enough structured content for Gemini to parse.
Score shows 0% for all candidates?
- Check that your resume contains skills that match the default criteria (JavaScript, React, Node.js, TypeScript). The matching is case-insensitive but requires a substring match.
- Open your Supabase dashboard and check the candidates table. Verify the skills column contains an array of strings, not an empty array.
- If skills are empty, the Gemini extraction step may have failed to parse the resume. Try uploading a more clearly formatted resume.
Why are all my candidate scores showing 0%?
Your AI resume screening engine is fully functional. Candidates are uploaded, parsed by Gemini, scored against configurable criteria, and ranked in a filterable dashboard. Next up, you will extend the system to handle multiple job postings with different screening criteria.
Secret mission
Add Multi-Job-Posting Support
Right now your screening engine evaluates everyone against a single hardcoded rubric. Real hiring teams screen the same candidate pool against multiple open roles. Can you extend the system so each job posting has its own criteria and scores?
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 uses Supabase (free tier) and the Gemini API (free tier), so there are no ongoing costs for normal usage.
Resources you used:
- Supabase project (PostgreSQL database with candidates, job_postings, and candidate_scores tables, plus resumes storage bucket).
- Gemini API key (from Google AI Studio).
- Local Next.js project folder on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with the resume screening engine.
- Both Supabase and the Gemini API are on free tiers. No charges will accrue for normal usage.
- Supabase free-tier projects auto-pause after 1 week of inactivity. You can resume them anytime from your dashboard.
- Your Gemini API key remains active but rate-limited on the free tier.
Pause - I'll come back to this later
Shut down the local dev server to free up memory. Your cloud resources stay safe with no cost.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
- Your Supabase project will auto-pause after 1 week of inactivity. All data remains intact and you can resume it anytime.
- Your Gemini API key stays valid. No action needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible for the cloud resources.
- Delete your Supabase project. In the Supabase dashboard, navigate to your project's Settings > General > Delete project. Click Delete Project, enter your project name to confirm, then confirm in the dialog. This removes all database tables and storage files permanently.
- Delete your Gemini API key. Open the Google Cloud API Credentials page, find the API key you used for this project, click the Actions icon, and select Delete API key. Confirm in the Delete credential modal.
- Delete the local project folder. Run the appropriate command for your operating system:
macOS
cd ~/Desktop
rm -rf resume-screening
Windows
cd ~\Desktop
Remove-Item -Recurse -Force resume-screening
- Delete the .env.local file if you stored it anywhere outside the project folder.
Nice Work!
Nice Work!
Nice work! You just built a full-stack AI-powered resume screening engine from scratch using Next.js, Gemini, and Supabase.
You've learned how to:
- Built a full-stack resume parsing pipeline that extracts text from PDF and DOCX files using pdf-parse and mammoth, then stores files in Supabase Storage.
- Used the Gemini API to extract structured candidate data from unstructured resume text, with Zod schema validation ensuring reliable JSON output.
- Created a configurable scoring engine with weighted criteria evaluation and a ranked candidate dashboard with score-based filtering.
- Secret Mission: Added multi-job-posting support so you can screen the same candidate pool against different roles with different criteria.
Ready to quiz yourself?