AI Resume Analyzer
Build a full-stack app that analyzes resumes with Google Gemini AI.
Introduction
30 Second Summary
Most job applications never reach a human reviewer. Automated screening systems filter resumes before a recruiter ever sees them, and knowing how those systems work gives you a serious edge.
In this project, you will build a web application that analyzes PDF resumes using AI. Upload a resume, and the app returns an ATS compatibility score with specific recommendations for improvement.
What You'll Build
A local web application with a Node.js backend that accepts PDF uploads, extracts text, sends it to Google Gemini for analysis, and displays a structured report with scores, keyword gaps, and recommendations.
By the end of this project, you'll have:
- An Express.js backend that handles PDF file uploads and extracts text content.
- A Google Gemini API integration that performs AI-powered resume analysis and returns structured JSON feedback.
- A professional frontend with drag-and-drop file upload and a dynamic results display showing ATS scores, strengths, weaknesses, and recommendations.
- Secret Mission: Add job description keyword matching that highlights exactly which keywords from a job posting are missing from the resume.
Are there any prerequisites?
None. This guide walks you through installing every tool you need, including Node.js and a free Google Gemini API key. No credit card is required.
Before We Start
Every job application you send passes through an invisible gatekeeper. Most companies use software called an Applicant Tracking System to scan resumes before a human ever sees them.
In this project, you will build an AI Resume Analyzer that reads a PDF resume and uses Google Gemini to grade it against ATS standards. By the end, you will have a fully working web app that scores resumes, spots missing keywords, and suggests improvements.
In this step, get ready to:
- Install Node.js and verify it works.
- Create the project folder and install dependencies.
- Get a free Gemini API key and configure the project.
Install Node.js
Node.js is a JavaScript runtime that lets you run JavaScript outside of a browser. Your backend server will run on Node.js.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open a terminal inside VS Code by clicking Terminal in the top menu bar.
- Click New Terminal.
- Check if Node.js is already installed by running this command:
node --version
Look at the output in your terminal and pick the tab that matches what you see.
✔️ I see version 24 or higher
Your terminal shows something like v24.18.0. Node.js is ready to go.
- Confirm npm is also available by running:
npm --version
You should see a version number like v11.16.0 or higher. Both tools are ready.
ⓧ I see an older version
Your terminal shows a version number lower than v24. You need to upgrade to the latest LTS version.
- Go to https://nodejs.org in your browser.
- Download the v24 LTS installer for your operating system.
- Run the installer and follow the on-screen prompts. Accept the default settings.
- Close your VS Code terminal and open a new one (Terminal then New Terminal).
- Run node --version again. You should now see v24.x.x.
Why do I need a new terminal?
Your old terminal session still has the old version cached in its PATH. Opening a new terminal picks up the freshly installed Node.js version.
ⓧ Command not found
Node.js is not installed on your machine yet. Let's install it now.
- Go to https://nodejs.org in your browser.
- Download the v24 LTS installer for your operating system.
- Run the installer and follow the on-screen prompts. Accept the default settings.
- Close your VS Code terminal and open a new one (Terminal then New Terminal).
- Run node --version to confirm. You should see v24.x.x.
- Run npm --version to confirm npm was installed alongside Node.js. You should see v11.16.0 or higher.
Still seeing command not found?
Make sure you opened a brand new terminal after installing. If it still does not work, restart VS Code entirely and try again.
Help me troubleshoot my Node.js installation.
What is Node.js?
Node.js lets you run JavaScript on your computer, not just in a browser. It comes bundled with npm (Node Package Manager), which you will use to install libraries for the project.
Create the project and install dependencies
Now you will create a project folder, initialize it as a Node.js project, and install the libraries your app needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ai-resume-analyzer
cd ai-resume-analyzer
- Open this folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the ai-resume-analyzer folder, and click Open.
- Open a new terminal in VS Code (Terminal then New Terminal).
- Initialize a new Node.js project by running:
npm init -y
What does npm init -y do?
This command creates a package.json file that tracks your project's name, version, and dependencies. The -y flag accepts all default settings so you do not have to answer prompts one by one.
- Install all the libraries your project needs by running:
npm install express@4.19.2 multer@2.1.1 pdf-parse@2.4.5 @google/genai cors
What are these libraries?
express is a web framework that makes it easy to build a server. multer handles file uploads. pdf-parse extracts text from PDF files.
@google/genai is Google's official SDK for the Gemini API. cors handles cross-origin requests between your frontend and backend.
You should see output ending with a message about packages being added. Now create the files and folders your project needs.
- Create the project directories by running:
mkdir public uploads
- Create the project files by running:
macOS
touch server.js public/index.html public/style.css public/app.js
Windows
New-Item server.js, public/index.html, public/style.css, public/app.js
- Confirm all files and folders were created by running:
ls
You should see node_modules, package.json, package-lock.json, public, server.js, and uploads listed in the output.
Missing files or folders?
Make sure your terminal is inside the ai-resume-analyzer folder. Run pwd (macOS) or cd (Windows) to check your current location. If you are in the wrong folder, use cd ~/Desktop/ai-resume-analyzer to get back.
Help me find my project folder and fix my file structure.
Get a Gemini API key
Your app needs an API key to talk to Google Gemini. The free tier has no billing, so you cannot be charged.
- Go to https://aistudio.google.com/apikey in your browser.
- Sign in with a Google account.
- Click Create API key.
- Copy the key that appears. You will need it in the next step.
Keep your API key private
Never paste your API key directly into code files that get pushed to GitHub. You will store it in a .env file that stays on your machine only.
- In the VS Code explorer sidebar, right-click in the empty space below your files.
- Click New File.
- Name the file .env and press Enter.
- Paste the following into the file, replacing the placeholder with the key you just copied:
GEMINI_API_KEY=your-api-key-here
What is a .env file?
A .env file stores environment variables that your code can read at runtime. This keeps sensitive values like API keys out of your source code.
- Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Create another new file in the VS Code explorer sidebar. Name it .gitignore.
- Paste the following into the .gitignore file:
node_modules/
uploads/
.env
What does .gitignore do?
When you push your project to GitHub later, Git will skip any file or folder listed here. This keeps your API key, uploaded resumes, and bulky node_modules folder out of your repository.
- Save .gitignore by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Verify everything works by running this command in your terminal:
node -e "console.log('Node.js is ready')"
You should see Node.js is ready printed in your terminal.
Not seeing the message?
Double-check that you are inside the ai-resume-analyzer folder and that Node.js installed correctly. Run node --version to confirm it returns a version number.
Help me troubleshoot my Node.js setup for this project.
Your project is scaffolded and ready to build on. Next up, you will write the Express server that accepts PDF uploads and extracts their text.
Build the Express Server with PDF Upload
Your project folder is ready with all the dependencies installed. Now it is time to build the backend that powers the resume analyzer.
A web application needs a server to receive requests, process data, and send responses. In this step, you will create an Express server that accepts PDF file uploads, extracts the text from them, and returns the results as JSON.
In this step, get ready to:
- Set up Express with middleware and file upload handling.
- Add PDF text extraction with error handling and file cleanup.
- Start and test the server with a real PDF upload.
Set up Express with multer for file uploads
Every web server needs a way to receive incoming requests and send back responses. Express handles that routing for you. On top of that, you need a way to handle file uploads, since users will be uploading PDF resumes.
- In VS Code, open server.js from the Explorer sidebar.
- Add the import statements, Express app initialization, and multer file upload configuration by copying this code into server.js:
// Import project dependencies
const express = require('express');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const { GoogleGenAI } = require('@google/genai');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
// Create the Express app and set the port
const app = express();
const PORT = 3000;
// Enable cross-origin requests, JSON parsing, and serve frontend files
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// Configure multer to accept PDF uploads with a 5MB size limit
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'));
}
}
});
What does this code do?
The top section imports all the packages you installed in the previous step. Some of these (like GoogleGenAI) will be used later when you connect the AI analysis.
- app.use(cors()) allows cross-origin requests, which is standard when a frontend and backend communicate.
- app.use(express.static('public')) tells Express to serve your HTML, CSS, and JavaScript files from the public/ folder.
- The multer configuration saves uploaded files to the uploads/ folder, limits file size to 5MB, and only accepts PDFs.
What is multer?
Multer is the official Express.js middleware for handling file uploads. When a user sends a file to your server, multer saves it to disk and attaches the file's metadata to the request object so your route handler can process it.
- Save server.js.
You will not see a visible change yet. The next chunk adds the route that processes uploaded files.
Seeing red underlines in VS Code?
- Make sure you ran npm install in the previous step. The packages must be installed for the imports to resolve.
- Check that you are editing server.js in the root of your ai-resume-analyzer folder, not inside public/.
Help me fix import errors in my Express server.
Add PDF text extraction
With the server and upload handler in place, you need a route that actually does something with the uploaded PDF. This route will extract the text from the resume and return it as a JSON response.
- Add the POST route that handles PDF uploads and extracts text by copying this code below the multer configuration in server.js:
// Handle resume upload and extract text
app.post('/api/analyze', upload.single('resume'), async (req, res) => {
// Check that a file was actually uploaded
if (!req.file) {
return res.status(400).json({ error: 'Please upload a PDF file' });
}
try {
// Read the uploaded PDF and extract its text content
const dataBuffer = fs.readFileSync(req.file.path);
const pdfData = await pdfParse(dataBuffer);
const resumeText = pdfData.text;
// Reject PDFs that contain no readable text
if (!resumeText.trim()) {
throw new Error('Could not extract text from PDF. The file may be image-based.');
}
// Send the extracted text back as JSON
res.json({ text: resumeText });
} catch (error) {
console.error('Analysis error:', error);
res.status(500).json({ error: error.message || 'An error occurred during analysis' });
} finally {
// Always delete the uploaded file after processing
if (req.file) {
fs.unlink(req.file.path, () => {});
}
}
});
What does this route do?
- upload.single('resume') tells multer to expect a single file from a form field called resume. The file is saved to the uploads/ folder before the route handler runs.
- pdfParse(dataBuffer) reads the raw PDF bytes and extracts all the text content from the document.
- The finally block uses fs.unlink to delete the uploaded file from disk after processing. This prevents uploaded resumes from piling up in your uploads/ folder.
- Save server.js.
You will not see a change yet. The next chunk adds error handling and starts the server.
Seeing a syntax error in the code?
- Make sure there is no extra or missing closing brace at the end. The route handler has nested try, catch, and finally blocks that each need matching braces.
- Confirm you pasted this code below the multer configuration block, not inside it.
Help me debug a syntax error in my Express route handler.
- Add the error handling middleware and server startup code by copying this below the POST route in server.js:
// Handle multer-specific errors (file too large, wrong file type)
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 5MB.' });
}
return res.status(400).json({ error: err.message });
}
if (err.message === 'Only PDF files are allowed') {
return res.status(400).json({ error: err.message });
}
next(err);
});
// Start the server
app.listen(PORT, () => {
console.log(`AI Resume Analyzer running at http://localhost:${PORT}`);
});
What does this code do?
- The error-handling middleware catches errors that multer throws before your route handler runs. If someone uploads a file larger than 5MB, multer triggers a LIMIT_FILE_SIZE error. If someone uploads a non-PDF, it catches the custom error message.
- app.listen(PORT) starts the server on port 3000 and prints a confirmation message to the terminal.
- Save server.js.
- Switch to your VS Code terminal. If you closed it, open a new one with Ctrl+` (Windows) or Cmd+` (macOS).
- Start the server by running this command:
node server.js
You should see AI Resume Analyzer running at http://localhost:3000 printed in the terminal.
Server not starting?
- If you see a syntax error, scroll up in the terminal output to find the line number. Double-check that all braces and parentheses are matched in server.js.
- If you see an error about the port being in use, another process is already running on port 3000. Close other terminal sessions that might be running a server, then try again.
- Make sure your terminal is in the ai-resume-analyzer folder. If not, run cd ai-resume-analyzer first.
Help me fix my Express server startup error.
✔️ Awesome, I've got everything!
Great. Double check that you have saved server.js and that the server is running in your terminal.
ⓧ I'd like to double check the full code
Here is the complete server.js file so far. Compare it with yours line by line.
const express = require('express');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const { GoogleGenAI } = require('@google/genai');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = 3000;
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'));
}
}
});
app.post('/api/analyze', upload.single('resume'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Please upload a PDF file' });
}
try {
const dataBuffer = fs.readFileSync(req.file.path);
const pdfData = await pdfParse(dataBuffer);
const resumeText = pdfData.text;
if (!resumeText.trim()) {
throw new Error('Could not extract text from PDF. The file may be image-based.');
}
res.json({ text: resumeText });
} catch (error) {
console.error('Analysis error:', error);
res.status(500).json({ error: error.message || 'An error occurred during analysis' });
} finally {
if (req.file) {
fs.unlink(req.file.path, () => {});
}
}
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 5MB.' });
}
return res.status(400).json({ error: err.message });
}
if (err.message === 'Only PDF files are allowed') {
return res.status(400).json({ error: err.message });
}
next(err);
});
app.listen(PORT, () => {
console.log(`AI Resume Analyzer running at http://localhost:${PORT}`);
});
Test the upload endpoint
The server is running, but you have not confirmed that the PDF upload and text extraction actually work. A quick test with a real PDF will verify the entire flow before you build on top of it.
- Open http://localhost:3000 in Google Chrome.
You will see a blank white page. This is expected because public/index.html is still empty. The server is working correctly behind the scenes.
- Find any PDF file on your computer to use as a test. A resume, report, or any text-based PDF will work.
- Open a second terminal in VS Code by clicking the + icon in the terminal panel. Keep the first terminal running the server.
- Test the upload endpoint by running this command:
curl -X POST -F "resume=@[[RESUME_PATH="path/to/any-resume.pdf"]]" http://localhost:3000/api/analyze
What does this command do?
- curl sends an HTTP request from the terminal. The -X POST flag makes it a POST request.
- -F "resume=@..." attaches a file to the request. The @ symbol tells curl to read from a file path instead of sending the text literally.
Using PowerShell on Windows?
In PowerShell, curl is an alias for a different command. Use curl.exe instead to run the real curl program.
You should see a JSON response containing the extracted text from your PDF. It will look something like {"text": "John Doe\nSoftware Engineer..."} with the actual text content from your document.
Getting an error from curl?
- If you see a "connection refused" error, make sure the server is still running in your first terminal. You should see the AI Resume Analyzer running at http://localhost:3000 message.
- If the response says the file could not be found, double-check the file path after the @ symbol. Use the full path to your PDF file.
- If the response says the PDF has no text, your PDF may be image-based (a scanned document). Try a different PDF that contains selectable text.
Help me troubleshoot my curl request to the Express server.
Your server can accept PDF uploads and pull out the text. Next up, you will connect Google Gemini to analyze that text and return a structured ATS report with scores and recommendations.
Integrate the Gemini API for Resume Analysis
Your server can accept PDF uploads and extract the text inside them. That is the raw material. Now you need something that can actually read that text and give meaningful feedback.
In this step, you will connect your server to Google Gemini's AI model. You will write a prompt that tells Gemini to analyze resume text like an ATS expert, then parse its response into structured JSON your frontend can display later.
In this step, get ready to:
- Initialize the Gemini AI client in your server.
- Build a prompt that returns structured resume analysis.
- Call the Gemini API, parse the response, and return it as JSON.
Set up the Gemini client
The @google/genai package you installed in Step 1 provides the GoogleGenAI class. Your server.js already imports it at the top of the file. Now you need to create an instance of the client so your route handler can use it.
- In server.js, scroll down to the multer configuration block. Find the closing }); of the upload variable.
- Add this line directly below it to initialize the Gemini client:
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
What does this line do?
This creates a new Gemini client using the API key you saved in your .env file. The process.env.GEMINI_API_KEY reads the key from your environment variables at runtime.
You will load this .env file using the --env-file flag when you start the server later in this step.
You will not see a change yet. The next substep builds the prompt that uses this client.
Create the analysis prompt
The quality of an AI response depends almost entirely on the prompt you send. You need to tell Gemini exactly what role to play, what format to return, and what fields to include.
- In server.js, find this line inside the try block of your /api/analyze route:
res.json({ text: resumeText });
- Delete that line and replace it with the following code that reads the optional job description and builds the system prompt:
// Read the optional job description from the request body
const jobDescription = req.body.jobDescription || '';
// Build the system prompt that tells Gemini how to analyze the resume
const systemPrompt = `You are an expert ATS (Applicant Tracking System) analyst and career coach. Analyze the provided resume and return your analysis as a JSON object with this exact structure:
{
"atsScore": <number 0-100>,
"summary": "<brief overall assessment>",
"strengths": ["<strength 1>", "<strength 2>", ...],
"weaknesses": ["<weakness 1>", "<weakness 2>", ...],
"missingKeywords": ["<keyword 1>", "<keyword 2>", ...],
"recommendations": [
{"section": "<resume section>", "suggestion": "<specific improvement>"},
...
]
}
Return ONLY valid JSON, no markdown, no code fences, no additional text.`;
// Build the user message with the resume text and optional job description
let userMessage = `Analyze this resume:\n\n${resumeText}`;
if (jobDescription.trim()) {
userMessage += `\n\nCompare against this job description:\n\n${jobDescription}`;
}
What does this code do?
The jobDescription line reads an optional job description from the request. If the user pastes one in the frontend later, the AI will use it for targeted analysis.
The systemPrompt is the key to getting useful output. It tells Gemini to act as an ATS expert and return a specific JSON structure with six fields: atsScore, summary, strengths, weaknesses, missingKeywords, and recommendations. Without this strict format instruction, the AI would return freeform text that is much harder to display in a UI.
The userMessage bundles the resume text and the job description (if provided) into a single message for the AI to process.
You will not see a visible change yet. The next substep sends this prompt to the Gemini API.
Parse and return the AI response
With the prompt ready, you can now call the Gemini API, clean up its response, and send the structured analysis back to the client.
- In server.js, add the following code directly below the userMessage block you just added (still inside the try block):
// Send the prompt to Gemini and get the AI response
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: userMessage,
config: {
systemInstruction: systemPrompt,
},
});
// Clean up the response text by stripping any markdown code fences
let analysisText = response.text;
analysisText = analysisText.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
// Parse the cleaned text into a JSON object and send it back
const analysis = JSON.parse(analysisText);
res.json(analysis);
What does this code do?
The ai.models.generateContent() call sends your prompt to the gemini-2.5-flash model. The systemInstruction field sets the system-level behavior (the ATS expert role), while contents contains the actual resume text to analyze.
Even though the prompt says "no code fences," AI models sometimes wrap JSON in markdown ```json blocks anyway. The replace() calls strip those fences so JSON.parse() can process the raw text cleanly.
You also need to update the error handler inside the catch block to handle cases where the AI returns text that cannot be parsed as JSON.
- In server.js, find the existing catch block. It currently looks like this:
} catch (error) {
console.error('Analysis error:', error);
res.status(500).json({ error: error.message || 'An error occurred during analysis' });
}
- Replace it with this updated version that adds a specific check for JSON parsing failures:
} catch (error) {
console.error('Analysis error:', error);
// Check if the error came from JSON.parse failing on the AI response
if (error instanceof SyntaxError) {
res.status(500).json({ error: 'Failed to parse AI response. Please try again.' });
} else {
res.status(500).json({ error: error.message || 'An error occurred during analysis' });
}
}
Why check for SyntaxError?
When JSON.parse() fails, it throws a SyntaxError. This happens when the AI returns something that is not valid JSON despite the prompt instructions. By catching this specific error, you can show a user-friendly message instead of a raw stack trace.
- Save server.js.
- In your terminal, stop the running server by pressing Ctrl+C.
- Restart the server with the --env-file flag so Node.js loads your API key from the .env file:
node --env-file=.env server.js
What does --env-file do?
The --env-file=.env flag is a built-in Node.js feature that loads environment variables from your .env file and makes them available on process.env. This way your API key stays out of your code and out of version control.
You should see the message AI Resume Analyzer running at http://localhost:3000 in your terminal.
- Test the endpoint by opening a new terminal tab and uploading a PDF resume with this command:
curl -X POST -F "resume=@[[RESUME_PDF="path/to/your-resume.pdf"]]" http://localhost:3000/api/analyze
After a few seconds, you should see a JSON response in your terminal with fields like atsScore, strengths, weaknesses, and recommendations.
No PDF handy?
You can search for "sample resume PDF" online and download any free example. Any text-based PDF will work for testing.
Seeing an error in the terminal?
If you see a message about an invalid API key, make sure you replaced your-api-key-here in your .env file with the actual key you copied from Google AI Studio. Check for extra spaces or line breaks around the key.
If the server did not start at all, make sure you stopped the previous server with Ctrl+C before restarting. Only one process can use port 3000 at a time.
If curl is not recognized on Windows, open PowerShell instead of Command Prompt. PowerShell includes curl by default.
Help me debug my Gemini API connection
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js.
ⓧ I'd like to double check the full code
const express = require('express');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const { GoogleGenAI } = require('@google/genai');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = 3000;
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') {
cb(null, true);
} else {
cb(new Error('Only PDF files are allowed'));
}
}
});
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
app.post('/api/analyze', upload.single('resume'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Please upload a PDF file' });
}
try {
const dataBuffer = fs.readFileSync(req.file.path);
const pdfData = await pdfParse(dataBuffer);
const resumeText = pdfData.text;
if (!resumeText.trim()) {
throw new Error('Could not extract text from PDF. The file may be image-based.');
}
const jobDescription = req.body.jobDescription || '';
const systemPrompt = `You are an expert ATS (Applicant Tracking System) analyst and career coach. Analyze the provided resume and return your analysis as a JSON object with this exact structure:
{
"atsScore": <number 0-100>,
"summary": "<brief overall assessment>",
"strengths": ["<strength 1>", "<strength 2>", ...],
"weaknesses": ["<weakness 1>", "<weakness 2>", ...],
"missingKeywords": ["<keyword 1>", "<keyword 2>", ...],
"recommendations": [
{"section": "<resume section>", "suggestion": "<specific improvement>"},
...
]
}
Return ONLY valid JSON, no markdown, no code fences, no additional text.`;
let userMessage = `Analyze this resume:\n\n${resumeText}`;
if (jobDescription.trim()) {
userMessage += `\n\nCompare against this job description:\n\n${jobDescription}`;
}
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: userMessage,
config: {
systemInstruction: systemPrompt,
},
});
let analysisText = response.text;
analysisText = analysisText.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const analysis = JSON.parse(analysisText);
res.json(analysis);
} catch (error) {
console.error('Analysis error:', error);
if (error instanceof SyntaxError) {
res.status(500).json({ error: 'Failed to parse AI response. Please try again.' });
} else {
res.status(500).json({ error: error.message || 'An error occurred during analysis' });
}
} finally {
if (req.file) {
fs.unlink(req.file.path, () => {});
}
}
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 5MB.' });
}
return res.status(400).json({ error: err.message });
}
if (err.message === 'Only PDF files are allowed') {
return res.status(400).json({ error: err.message });
}
next(err);
});
app.listen(PORT, () => {
console.log(`AI Resume Analyzer running at http://localhost:${PORT}`);
});
Your server now takes a raw PDF, extracts the text, sends it to an AI model, and returns a structured analysis. Next up, you will build the frontend so users can upload resumes and see the results in a polished UI.
Build the Frontend UI
Your backend is fully wired up. The Express server accepts PDF uploads, extracts their text, sends it to Gemini for analysis, and returns structured JSON with an ATS score, strengths, weaknesses, and recommendations.
Right now, the only way to test the API is through the terminal or browser console. In this step, you will build a polished frontend with drag-and-drop file upload and a results display that renders the AI analysis in a readable format.
In this step, get ready to:
- Build the HTML page with an upload form, loading indicator, and results sections.
- Style the interface with a modern card layout and color-coded ATS scores.
- Wire up the frontend JavaScript to handle file uploads and display AI analysis results.
Create the HTML structure and styles
The frontend needs a page layout with three states: an upload form, a loading spinner, and a results display. You will build the HTML structure first, then style it with CSS.
- In VS Code, click on public/index.html in the Explorer sidebar to open it.
- Copy the following HTML into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Resume Analyzer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>AI Resume Analyzer</h1>
<p>Upload your resume and get instant ATS feedback powered by AI</p>
</header>
<main>
<!-- Upload card: the form the user sees first -->
<div class="upload-card" id="uploadCard">
<div class="drop-zone" id="dropZone">
<div class="drop-zone-icon">📄</div>
<p class="drop-zone-text">Drag and drop your PDF resume here</p>
<p class="drop-zone-hint">or click to browse</p>
<input type="file" id="fileInput" accept=".pdf" hidden>
</div>
<p class="file-name" id="fileName"></p>
<!-- Optional job description for targeted analysis -->
<div class="job-description-section">
<label for="jobDescription">Job Description (optional)</label>
<textarea id="jobDescription" placeholder="Paste the job description here for targeted keyword analysis..."></textarea>
</div>
<button id="analyzeBtn" class="analyze-btn" disabled>Analyze Resume</button>
</div>
<!-- Loading spinner: shown while the AI processes the resume -->
<div class="loading" id="loading" hidden>
<div class="spinner"></div>
<p>Analyzing your resume with AI...</p>
</div>
<!-- Results container: shown after analysis completes -->
<div class="results" id="results" hidden>
<div class="score-card" id="scoreCard">
<h2>ATS Compatibility Score</h2>
<div class="score-number" id="scoreNumber"></div>
<p class="score-summary" id="scoreSummary"></p>
</div>
<div class="result-section" id="strengthsSection">
<h3>Strengths</h3>
<ul id="strengthsList"></ul>
</div>
<div class="result-section" id="weaknessesSection">
<h3>Weaknesses</h3>
<ul id="weaknessesList"></ul>
</div>
<div class="result-section" id="keywordsSection">
<h3>Missing Keywords</h3>
<div class="keywords-container" id="keywordsList"></div>
</div>
<div class="result-section" id="recommendationsSection">
<h3>Recommendations</h3>
<div id="recommendationsList"></div>
</div>
<button id="resetBtn" class="reset-btn">Try Another Resume</button>
</div>
</main>
<script src="app.js"></script>
</body>
</html>
What does this HTML do?
The page has three main sections that toggle visibility as the user moves through the flow:
- uploadCard holds the drop zone for dragging in a PDF, a textarea for an optional job description, and the Analyze Resume button (disabled until a file is selected).
- loading contains a spinner that appears while Gemini processes the resume. The hidden attribute keeps it invisible until JavaScript removes it.
- results holds empty containers for the ATS score, strengths, weaknesses, missing keywords, and recommendations. JavaScript will fill these with data from the API response.
- Save index.html.
- Refresh your browser at http://localhost:3000. You should see the page title, a drop zone area, a textarea, and a disabled Analyze Resume button. Everything is unstyled for now.
Seeing a blank page?
- Make sure you saved index.html inside the public folder, not the project root. Express serves static files from public/.
- Confirm your server is still running in the terminal. If it stopped, restart it.
Still stuck? Help me figure out why my index.html is not loading at localhost:3000.
✔️ Awesome, I've got everything!
Great. Make sure you have saved public/index.html.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Resume Analyzer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>AI Resume Analyzer</h1>
<p>Upload your resume and get instant ATS feedback powered by AI</p>
</header>
<main>
<div class="upload-card" id="uploadCard">
<div class="drop-zone" id="dropZone">
<div class="drop-zone-icon">📄</div>
<p class="drop-zone-text">Drag and drop your PDF resume here</p>
<p class="drop-zone-hint">or click to browse</p>
<input type="file" id="fileInput" accept=".pdf" hidden>
</div>
<p class="file-name" id="fileName"></p>
<div class="job-description-section">
<label for="jobDescription">Job Description (optional)</label>
<textarea id="jobDescription" placeholder="Paste the job description here for targeted keyword analysis..."></textarea>
</div>
<button id="analyzeBtn" class="analyze-btn" disabled>Analyze Resume</button>
</div>
<div class="loading" id="loading" hidden>
<div class="spinner"></div>
<p>Analyzing your resume with AI...</p>
</div>
<div class="results" id="results" hidden>
<div class="score-card" id="scoreCard">
<h2>ATS Compatibility Score</h2>
<div class="score-number" id="scoreNumber"></div>
<p class="score-summary" id="scoreSummary"></p>
</div>
<div class="result-section" id="strengthsSection">
<h3>Strengths</h3>
<ul id="strengthsList"></ul>
</div>
<div class="result-section" id="weaknessesSection">
<h3>Weaknesses</h3>
<ul id="weaknessesList"></ul>
</div>
<div class="result-section" id="keywordsSection">
<h3>Missing Keywords</h3>
<div class="keywords-container" id="keywordsList"></div>
</div>
<div class="result-section" id="recommendationsSection">
<h3>Recommendations</h3>
<div id="recommendationsList"></div>
</div>
<button id="resetBtn" class="reset-btn">Try Another Resume</button>
</div>
</main>
<script src="app.js"></script>
</body>
</html>
Now let's style this page. The CSS will give the app a professional look with a gradient header, card layout, and interactive states.
- Click on public/style.css in the Explorer sidebar to open it.
- Add the base layout and upload form styles by copying this CSS into the file:
/* Reset default browser spacing */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
/* Dark gradient header */
header {
background: linear-gradient(135deg, #1a1a2e, #16213e);
color: white;
text-align: center;
padding: 2rem 1rem;
}
header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
header p {
opacity: 0.8;
font-size: 1.1rem;
}
/* Center the main content */
main {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
/* White card for the upload form */
.upload-card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
/* Dashed border drop zone */
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 3rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color 0.3s, background 0.3s;
}
/* Highlight on hover or drag-over */
.drop-zone:hover,
.drop-zone.drag-over {
border-color: #4361ee;
background: #f0f4ff;
}
.drop-zone-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.drop-zone-text {
font-size: 1.1rem;
color: #333;
margin-bottom: 0.25rem;
}
.drop-zone-hint {
color: #888;
font-size: 0.9rem;
}
/* Selected file name display */
.file-name {
margin-top: 1rem;
color: #4361ee;
font-weight: 600;
min-height: 1.5rem;
}
/* Job description textarea section */
.job-description-section {
margin-top: 1.5rem;
}
.job-description-section label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #333;
}
.job-description-section textarea {
width: 100%;
height: 100px;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
}
.job-description-section textarea:focus {
outline: none;
border-color: #4361ee;
}
/* Analyze button */
.analyze-btn {
display: block;
width: 100%;
margin-top: 1.5rem;
padding: 1rem;
background: #4361ee;
color: white;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.analyze-btn:hover:not(:disabled) {
background: #3a56d4;
}
/* Gray out the button when disabled */
.analyze-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
What do these styles do?
- The header uses a dark diagonal gradient that gives the page a polished, professional look.
- The .drop-zone has a dashed border that turns blue when the user hovers or drags a file over it, signaling where to drop.
- The .analyze-btn:disabled rule grays out the button until a file is selected, preventing premature submissions.
- Save style.css and refresh your browser.
- You should see a dark gradient header, a white card with a dashed drop zone, a job description textarea, and a gray disabled Analyze Resume button.
Styles not applying?
- Check that your style.css file is saved inside the public/ folder, not the project root.
- Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear cached styles.
Need more help? My CSS styles are not showing up when I refresh localhost:3000.
Next, add the styles for the loading spinner, results sections, and score display.
- Add the following CSS below your existing styles in style.css:
/* Loading spinner */
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e0e0e0;
border-top-color: #4361ee;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Results fade-in animation */
.results {
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* Score card */
.score-card {
background: white;
border-radius: 12px;
padding: 2rem;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 1.5rem;
}
.score-card h2 {
margin-bottom: 1rem;
color: #333;
}
.score-number {
font-size: 4rem;
font-weight: 800;
line-height: 1;
margin-bottom: 0.5rem;
}
/* Color-coded score: green for 70+, yellow for 50-69, red for below 50 */
.score-number.high { color: #2ecc71; }
.score-number.medium { color: #f39c12; }
.score-number.low { color: #e74c3c; }
.score-summary {
color: #666;
font-size: 1rem;
line-height: 1.5;
}
/* Result section cards */
.result-section {
background: white;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.result-section h3 {
margin-bottom: 1rem;
color: #1a1a2e;
font-size: 1.2rem;
}
.result-section ul {
list-style: none;
padding: 0;
}
.result-section ul li {
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
line-height: 1.5;
}
.result-section ul li:last-child {
border-bottom: none;
}
.result-section ul li::before {
margin-right: 0.5rem;
}
/* Icons for strengths and weaknesses lists */
#strengthsList li::before { content: "\2705"; }
#weaknessesList li::before { content: "\26A0\FE0F"; }
/* Keyword pills */
.keywords-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.keyword-pill {
background: #fee2e2;
color: #dc2626;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
/* Recommendation items with left border accent */
.recommendation-item {
padding: 1rem;
background: #f8f9ff;
border-radius: 8px;
margin-bottom: 0.75rem;
border-left: 3px solid #4361ee;
}
.recommendation-item .rec-section {
font-weight: 700;
color: #4361ee;
font-size: 0.85rem;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.recommendation-item .rec-suggestion {
color: #444;
line-height: 1.5;
}
/* Try Another Resume button */
.reset-btn {
display: block;
width: 100%;
padding: 1rem;
background: transparent;
color: #4361ee;
border: 2px solid #4361ee;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
transition: background 0.3s, color 0.3s;
}
.reset-btn:hover {
background: #4361ee;
color: white;
}
What do these styles do?
- The .spinner uses a CSS animation to rotate a circle with a colored top border, creating a spinning effect.
- The .score-number class has three color variants: .high (green for 70+), .medium (yellow for 50-69), and .low (red for below 50). JavaScript will pick the right class based on the ATS score.
- The .keyword-pill creates small red rounded tags for missing keywords, making gaps easy to scan at a glance.
- Save style.css.
- You will not see a visible change yet because the loading and results sections are hidden by default. These styles will apply once JavaScript toggles those sections in the next substep.
✔️ Awesome, I've got everything!
Great. Make sure you have saved public/style.css with both sections of CSS.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
header {
background: linear-gradient(135deg, #1a1a2e, #16213e);
color: white;
text-align: center;
padding: 2rem 1rem;
}
header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
header p {
opacity: 0.8;
font-size: 1.1rem;
}
main {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
.upload-card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 3rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color 0.3s, background 0.3s;
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: #4361ee;
background: #f0f4ff;
}
.drop-zone-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.drop-zone-text {
font-size: 1.1rem;
color: #333;
margin-bottom: 0.25rem;
}
.drop-zone-hint {
color: #888;
font-size: 0.9rem;
}
.file-name {
margin-top: 1rem;
color: #4361ee;
font-weight: 600;
min-height: 1.5rem;
}
.job-description-section {
margin-top: 1.5rem;
}
.job-description-section label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #333;
}
.job-description-section textarea {
width: 100%;
height: 100px;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
}
.job-description-section textarea:focus {
outline: none;
border-color: #4361ee;
}
.analyze-btn {
display: block;
width: 100%;
margin-top: 1.5rem;
padding: 1rem;
background: #4361ee;
color: white;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.analyze-btn:hover:not(:disabled) {
background: #3a56d4;
}
.analyze-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 3rem;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e0e0e0;
border-top-color: #4361ee;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.results {
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.score-card {
background: white;
border-radius: 12px;
padding: 2rem;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 1.5rem;
}
.score-card h2 {
margin-bottom: 1rem;
color: #333;
}
.score-number {
font-size: 4rem;
font-weight: 800;
line-height: 1;
margin-bottom: 0.5rem;
}
.score-number.high { color: #2ecc71; }
.score-number.medium { color: #f39c12; }
.score-number.low { color: #e74c3c; }
.score-summary {
color: #666;
font-size: 1rem;
line-height: 1.5;
}
.result-section {
background: white;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.result-section h3 {
margin-bottom: 1rem;
color: #1a1a2e;
font-size: 1.2rem;
}
.result-section ul {
list-style: none;
padding: 0;
}
.result-section ul li {
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
line-height: 1.5;
}
.result-section ul li:last-child {
border-bottom: none;
}
.result-section ul li::before {
margin-right: 0.5rem;
}
#strengthsList li::before { content: "\2705"; }
#weaknessesList li::before { content: "\26A0\FE0F"; }
.keywords-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.keyword-pill {
background: #fee2e2;
color: #dc2626;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.recommendation-item {
padding: 1rem;
background: #f8f9ff;
border-radius: 8px;
margin-bottom: 0.75rem;
border-left: 3px solid #4361ee;
}
.recommendation-item .rec-section {
font-weight: 700;
color: #4361ee;
font-size: 0.85rem;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.recommendation-item .rec-suggestion {
color: #444;
line-height: 1.5;
}
.reset-btn {
display: block;
width: 100%;
padding: 1rem;
background: transparent;
color: #4361ee;
border: 2px solid #4361ee;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
transition: background 0.3s, color 0.3s;
}
.reset-btn:hover {
background: #4361ee;
color: white;
}
Add the frontend JavaScript
The HTML and CSS are in place, but nothing is interactive yet. The JavaScript will wire up the drag-and-drop file handling, send the PDF to your backend, and manage the UI state transitions between upload, loading, and results.
- Click on public/app.js in the Explorer sidebar to open it.
- Set up the DOM references and drag-and-drop file handling by copying this code into the file:
// Cache references to all the DOM elements we need
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileName = document.getElementById('fileName');
const analyzeBtn = document.getElementById('analyzeBtn');
const uploadCard = document.getElementById('uploadCard');
const loading = document.getElementById('loading');
const results = document.getElementById('results');
const resetBtn = document.getElementById('resetBtn');
const jobDescription = document.getElementById('jobDescription');
// Track the currently selected PDF file
let selectedFile = null;
// Clicking the drop zone opens the hidden file input
dropZone.addEventListener('click', () => fileInput.click());
// Highlight the drop zone when a file is dragged over it
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
// Remove the highlight when the drag leaves
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
// Handle a file dropped onto the zone
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.type === 'application/pdf') {
handleFile(file);
} else {
alert('Please upload a PDF file.');
}
});
// Handle a file chosen via the file picker
fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) {
handleFile(e.target.files[0]);
}
});
// Store the selected file and enable the analyze button
function handleFile(file) {
selectedFile = file;
fileName.textContent = file.name;
analyzeBtn.disabled = false;
}
What does this code do?
- The first block caches references to every DOM element the app interacts with. Caching avoids repeatedly searching the page for the same element.
- The dragover listener calls e.preventDefault() to tell the browser "I will handle this drop." Without it, the browser opens the PDF instead of passing it to your code.
- The handleFile function stores the file, displays its name, and enables the Analyze button. This is the shared path for both drag-and-drop and click-to-browse.
- Save app.js and refresh your browser.
- Click anywhere in the drop zone. A file picker should open. Select any PDF file and you should see its name appear below the drop zone in blue text. The Analyze Resume button should turn from gray to blue.
File picker not opening?
- Open the browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) and look for errors. A typo in an element ID will cause a "Cannot read properties of null" error.
- Check that the IDs in your HTML match the getElementById calls in app.js. IDs are case-sensitive.
Still stuck? My drop zone click handler is not opening the file picker. Help me debug.
The file selection works. Now add the handler that sends the PDF to your backend when the user clicks Analyze Resume.
- Add the analyze button click handler below your existing code in app.js:
// Send the PDF to the backend for AI analysis
analyzeBtn.addEventListener('click', async () => {
if (!selectedFile) return;
// Toggle UI: hide upload card, show spinner, hide old results
uploadCard.hidden = true;
loading.hidden = false;
results.hidden = true;
// Bundle the PDF and optional job description into FormData
const formData = new FormData();
formData.append('resume', selectedFile);
formData.append('jobDescription', jobDescription.value);
try {
const response = await fetch('/api/analyze', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Analysis failed');
}
displayResults(data);
} catch (error) {
alert('Error: ' + error.message);
uploadCard.hidden = false;
} finally {
loading.hidden = true;
}
});
What does this code do?
- FormData packages the PDF file and job description text into the same request body. The browser automatically sets the correct multipart/form-data content type that multer expects.
- The hidden property toggles the three UI states. Setting uploadCard.hidden = true removes it from view, while loading.hidden = false reveals the spinner.
- The finally block hides the spinner whether the request succeeds or fails. This prevents the spinner from getting stuck on screen.
- Save app.js. You cannot test the full flow yet because the displayResults function is written in the next substep.
Render the analysis results
The backend returns a JSON object with fields like atsScore, strengths, and recommendations. The displayResults function takes that object and builds the visible result cards dynamically.
- Add the displayResults function below your existing code in app.js:
// Build the results UI from the API response data
function displayResults(data) {
results.hidden = false;
// Set the ATS score number and apply a color class
const scoreNumber = document.getElementById('scoreNumber');
scoreNumber.textContent = data.atsScore;
scoreNumber.className = 'score-number';
if (data.atsScore >= 70) scoreNumber.classList.add('high');
else if (data.atsScore >= 50) scoreNumber.classList.add('medium');
else scoreNumber.classList.add('low');
// Display the summary text
document.getElementById('scoreSummary').textContent = data.summary;
// Populate the strengths list
const strengthsList = document.getElementById('strengthsList');
strengthsList.innerHTML = '';
data.strengths.forEach((s) => {
const li = document.createElement('li');
li.textContent = s;
strengthsList.appendChild(li);
});
// Populate the weaknesses list
const weaknessesList = document.getElementById('weaknessesList');
weaknessesList.innerHTML = '';
data.weaknesses.forEach((w) => {
const li = document.createElement('li');
li.textContent = w;
weaknessesList.appendChild(li);
});
// Create keyword pills for missing keywords
const keywordsList = document.getElementById('keywordsList');
keywordsList.innerHTML = '';
data.missingKeywords.forEach((k) => {
const span = document.createElement('span');
span.className = 'keyword-pill';
span.textContent = k;
keywordsList.appendChild(span);
});
// Build recommendation cards with section labels
const recommendationsList = document.getElementById('recommendationsList');
recommendationsList.innerHTML = '';
data.recommendations.forEach((r) => {
const div = document.createElement('div');
div.className = 'recommendation-item';
div.innerHTML =
'<div class="rec-section">' + r.section + '</div>' +
'<div class="rec-suggestion">' + r.suggestion + '</div>';
recommendationsList.appendChild(div);
});
}
What does this code do?
- The score number gets a CSS class (high, medium, or low) based on the ATS score value. This makes the number display in green, yellow, or red using the CSS you already wrote.
- Each section first clears its container with innerHTML = '' before adding new elements. This prevents old results from stacking on top of new ones if the user runs multiple analyses.
- Missing keywords become small red pill-shaped tags using the keyword-pill class, making gaps easy to scan.
- Save app.js and refresh your browser.
- Click the drop zone and select a PDF resume.
- Click Analyze Resume. You should see the upload card disappear, a loading spinner appear, and after a few seconds the results display with a color-coded ATS score, strengths, weaknesses, missing keyword pills, and recommendation cards.
Seeing an error alert instead of results?
- If the alert says "Failed to parse AI response", the Gemini API returned text that was not valid JSON. Click OK and try again. AI responses occasionally need a retry.
- If the alert mentions a network error, check that your server is still running in the terminal. If it stopped, restart it.
- Open the browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) to see the full error details.
Need help? My resume analysis is returning an error instead of results. Help me debug.
The last piece is a reset handler so users can analyze another resume without refreshing the page.
- Add the reset button handler below your existing code in app.js:
// Reset the UI so the user can upload a new resume
resetBtn.addEventListener('click', () => {
results.hidden = true;
uploadCard.hidden = false;
selectedFile = null;
fileInput.value = '';
fileName.textContent = '';
analyzeBtn.disabled = true;
jobDescription.value = '';
});
What does this code do?
The reset handler reverses every state change the analyze flow made. It hides the results, shows the upload card again, clears the selected file, empties the job description, and disables the analyze button until a new file is chosen.
- Save app.js and refresh your browser.
- Run the full flow again: upload a PDF, click Analyze Resume, and wait for results.
- Scroll down to the bottom of the results and click Try Another Resume. The results should disappear and the upload form should reappear with a blank state.
Reset button not clearing the form?
- Check that resetBtn at the top of your file uses getElementById('resetBtn') and that your HTML button has id="resetBtn".
- Open the browser console to check for any errors. A missing or mismatched ID will cause the handler to silently fail.
Still stuck? My Try Another Resume button is not resetting the form. Help me debug.
✔️ Awesome, I've got everything!
Great. Make sure you have saved public/app.js with all three sections of code.
ⓧ I'd like to double check the full code
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileName = document.getElementById('fileName');
const analyzeBtn = document.getElementById('analyzeBtn');
const uploadCard = document.getElementById('uploadCard');
const loading = document.getElementById('loading');
const results = document.getElementById('results');
const resetBtn = document.getElementById('resetBtn');
const jobDescription = document.getElementById('jobDescription');
let selectedFile = null;
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.type === 'application/pdf') {
handleFile(file);
} else {
alert('Please upload a PDF file.');
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files[0]) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
selectedFile = file;
fileName.textContent = file.name;
analyzeBtn.disabled = false;
}
analyzeBtn.addEventListener('click', async () => {
if (!selectedFile) return;
uploadCard.hidden = true;
loading.hidden = false;
results.hidden = true;
const formData = new FormData();
formData.append('resume', selectedFile);
formData.append('jobDescription', jobDescription.value);
try {
const response = await fetch('/api/analyze', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Analysis failed');
}
displayResults(data);
} catch (error) {
alert('Error: ' + error.message);
uploadCard.hidden = false;
} finally {
loading.hidden = true;
}
});
function displayResults(data) {
results.hidden = false;
const scoreNumber = document.getElementById('scoreNumber');
scoreNumber.textContent = data.atsScore;
scoreNumber.className = 'score-number';
if (data.atsScore >= 70) scoreNumber.classList.add('high');
else if (data.atsScore >= 50) scoreNumber.classList.add('medium');
else scoreNumber.classList.add('low');
document.getElementById('scoreSummary').textContent = data.summary;
const strengthsList = document.getElementById('strengthsList');
strengthsList.innerHTML = '';
data.strengths.forEach((s) => {
const li = document.createElement('li');
li.textContent = s;
strengthsList.appendChild(li);
});
const weaknessesList = document.getElementById('weaknessesList');
weaknessesList.innerHTML = '';
data.weaknesses.forEach((w) => {
const li = document.createElement('li');
li.textContent = w;
weaknessesList.appendChild(li);
});
const keywordsList = document.getElementById('keywordsList');
keywordsList.innerHTML = '';
data.missingKeywords.forEach((k) => {
const span = document.createElement('span');
span.className = 'keyword-pill';
span.textContent = k;
keywordsList.appendChild(span);
});
const recommendationsList = document.getElementById('recommendationsList');
recommendationsList.innerHTML = '';
data.recommendations.forEach((r) => {
const div = document.createElement('div');
div.className = 'recommendation-item';
div.innerHTML =
'<div class="rec-section">' + r.section + '</div>' +
'<div class="rec-suggestion">' + r.suggestion + '</div>';
recommendationsList.appendChild(div);
});
}
resetBtn.addEventListener('click', () => {
results.hidden = true;
uploadCard.hidden = false;
selectedFile = null;
fileInput.value = '';
fileName.textContent = '';
analyzeBtn.disabled = true;
jobDescription.value = '';
});
Secret mission
Job Description Keyword Matcher
Your analyzer gives great general feedback on resumes. But job seekers want to know something more specific: does my resume match this particular job posting? In this secret mission, you will upgrade the AI prompt to compare resumes against a pasted job description, then build a visual keyword match display that highlights exactly which keywords are present and which are missing.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your machine using the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources, pause them to come back later, or delete them entirely.
Resources you used:
- The ai-resume-analyzer project folder on your Desktop.
- A Gemini API key in Google AI Studio (free tier, no charges).
Keep everything running
No action needed. Choose this if you plan to keep using or extending the analyzer.
- Your project files stay in the ai-resume-analyzer folder on your Desktop.
- Start the app anytime by navigating to the project folder and running node --env-file=.env server.js.
- Node.js and your npm packages remain installed on your machine.
- Your Gemini API key stays active in Google AI Studio. It is free and incurs no charges.
Pause - I'll come back to this later
Stop the running server to free up your terminal. Your project files stay on your machine for next time.
- In the terminal where the server is running, press Ctrl+C to stop it.
- Your project files, API key, and installed packages all remain saved for when you come back.
- To restart later, open the ai-resume-analyzer folder in VS Code and run node --env-file=.env server.js in the terminal.
Delete - I don't want to use this again
Remove all project files from your machine and clean up completely.
- Stop the server if it is still running by pressing Ctrl+C in the terminal.
- Delete the project folder by running the command for your operating system:
macOS
rm -rf ~/Desktop/ai-resume-analyzer
Windows
Remove-Item -Recurse -Force ~\Desktop\ai-resume-analyzer
- Optionally, delete your Gemini API key by visiting Google AI Studio and removing the key you created for this project.
Nice Work!
Nice Work!
Nice work! You just built a fully functional AI Resume Analyzer that accepts PDF uploads, sends them to Google Gemini for analysis, and displays a structured ATS compatibility report.
You've learned how to:
- Built a Node.js + Express backend that handles PDF file uploads with multer and extracts text using pdf-parse.
- Integrated the Gemini API with crafted prompts that return structured JSON analysis including ATS scores, keyword gaps, and actionable recommendations.
- Built a polished frontend with drag-and-drop upload, a loading spinner, and dynamic results rendering using DOM manipulation.
- Secret Mission: Added targeted job description keyword matching with a visual gap analysis showing matched and missing keywords as color-coded pills.
Ready to quiz yourself?