Build an AI Resume Builder

Build a MERN-stack app with AI content generation and PDF export.

Introduction

30 Second Summary

Job hunting is stressful enough without spending hours crafting the perfect resume from scratch. Imagine having an AI assistant that writes professional summaries, checks if your resume will pass automated screening systems, and generates polished PDFs ready to send.

In this project, you will build a full-stack AI Resume Builder that generates professional resume content, scores resumes against job descriptions, and exports download-ready PDFs.

What You'll Build

A MERN-stack application with a React frontend, Node.js/Express.js backend, MongoDB database, and OpenAI integration for intelligent resume content generation.

By the end of this project, you'll have:

  • A working Express.js REST API with MongoDB for storing and managing resume data.
  • A React frontend with a multi-section resume form and live preview that updates as you type.
  • An ATS score checker powered by OpenAI that analyzes your resume against job descriptions and suggests improvements.
  • Secret Mission: A Cover Letter Generator that crafts personalized cover letters from your resume data and a target job description.

Are there any prerequisites?

You should have basic JavaScript and Node.js knowledge, along with familiarity with React concepts like components and state. No prior experience with MongoDB, OpenAI, or PDF generation is needed.

Before We Start

Every professional needs a strong resume, but writing one that passes automated screening systems is tricky. AI can help generate polished content, but you need a full application to tie together the editor, the AI, and the output.

Before you start building, you need a code editor, a JavaScript runtime, a cloud database, and an AI service. This step gets all of those tools installed and configured so you can hit the ground running.

In this step, get ready to:
  • Install VS Code and Node.js on your machine.
  • Create MongoDB Atlas and OpenAI accounts with the credentials you need.
  • Set up your project folder structure with server dependencies installed.
Install VS Code and Node.js

VS Code is the code editor you will use throughout this project. Node.js is the JavaScript runtime that powers your backend server and tooling.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.

VS Code not installed?

Download it from https://code.visualstudio.com. Run the installer and follow the prompts, then try again.

Next, check whether Node.js is already installed. This project requires version 20.19 or higher.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Check your Node.js version by running this command:
node --version

✔️ I see version 20.19 or higher

Node.js is ready. You can move on to the next substep.

ⓧ I see an older version

You have Node.js installed, but you need version 20.19 or higher. The current LTS is v24 which works perfectly for this project.

  • Go to https://nodejs.org/en/download and download the LTS installer for your operating system.
  • Run the installer and accept the default options.
  • Close and reopen your VS Code terminal, then run node --version again to confirm you see 20.19 or higher.

ⓧ Command not found

Node.js is not installed on your machine. You need to download it.

  • Go to https://nodejs.org/en/download and download the LTS installer for your operating system.
  • Run the installer and accept the default options.
  • Close and reopen your VS Code terminal, then run node --version again to confirm you see a version number.

Still seeing command not found?

On macOS, you may need to fully quit and reopen VS Code for the terminal to pick up the new installation path.

On Windows, make sure you checked the box to add Node to your PATH during installation. If you missed it, run the installer again and select Repair.

help me fix Node.js not being recognized in my terminal

Set up MongoDB Atlas and OpenAI

Your app needs a database to store resume data and an AI service to generate content. MongoDB Atlas provides a free cloud database. OpenAI provides the AI model that writes resume summaries and scores them.

  • Open https://www.mongodb.com/cloud/atlas/register in your browser.
  • Create a free account using your email or Google sign-in.
  • When prompted to deploy a cluster, select Free as the cluster type.
  • Choose a cloud provider and region close to you, then click Create Deployment.
  • Atlas will prompt you to create a database user. Enter a username and password you will remember. Write these down.
  • On the network access screen, click Add My Current IP Address to whitelist your machine.
  • Click Choose a connection method, then select Drivers.
  • Copy the connection string shown. It looks like mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/. Save this somewhere safe for the next substep.

What is MongoDB Atlas?

MongoDB Atlas is a cloud-hosted database service. Instead of installing a database on your laptop, Atlas runs it in the cloud and gives you a connection string to reach it. The free tier gives you 512 MB of storage, which is more than enough for this project.

Now set up your OpenAI account to get an API key.

  • Open https://platform.openai.com in your browser.
  • Sign up or log in with your account.
  • Navigate to API keys in the left sidebar (or visit https://platform.openai.com/api-keys).
  • Click Create new secret key. Give it a name like resume-builder and click Create secret key.
  • Copy the key immediately. You will not be able to see it again after closing this dialog. Save it somewhere safe.

OpenAI requires a payment method

OpenAI requires a payment method on file to use the API, even for small usage. This project uses the gpt-4o-mini model which costs approximately $0.01 to $0.05 for the entire project's API calls.

Create the project workspace

Your project needs a folder with two subdirectories: server for the backend API and client for the React frontend. You will initialize the server now and scaffold the client later with Vite.

  • 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-builder
cd ai-resume-builder
  • Create both subdirectories by running:
mkdir client server
  • Confirm the folders exist by running:
ls

You should see client and server in the output.

Now set up the server with its dependencies. Move into the server folder and create the package.json file.

  • Move into the server directory by running:
cd server
  • Create a package.json file in VS Code. In your terminal, run:
code package.json

What does this command do?

The code command opens a file directly in VS Code from the terminal. If the file does not exist yet, VS Code creates a new empty file with that name.

  • Paste the following into package.json:
{
  "name": "ai-resume-builder-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "express": "^5.1.0",
    "mongoose": "^9.6.3",
    "openai": "^4.77.0",
    "puppeteer": "^24.2.1"
  }
}

What is in this file?

This file tells Node.js about your project. The "type": "module" line enables modern import/export syntax instead of the older require().

The dependencies include Express (web server), Mongoose (MongoDB connector), the openai SDK, and Puppeteer (headless browser for PDF generation).

  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
  • Install all dependencies by running this command:
npm install

This takes a minute

Puppeteer downloads a bundled Chromium browser (~280 MB) as part of its install. This is normal and may take 1-2 minutes depending on your internet speed.

You should see a success message and a new node_modules folder appear in the server directory.

npm install failed?

Make sure you are inside the server directory (run pwd to check). You should see ai-resume-builder/server at the end of the path.

If you see permission errors on macOS, try prefixing the command with sudo.

help me fix npm install failing in my project

Finally, create the .env file that stores your secrets. This file keeps credentials out of your code.

  • Create a .env file in the server directory by running:
code .env
  • Paste the following into .env, replacing the placeholder values with your own credentials from the previous substep:
PORT=5000
MONGODB_URI=your-mongodb-atlas-connection-string-here
OPENAI_API_KEY=your-openai-api-key-here
JWT_SECRET=your-jwt-secret-here

What goes in each variable?

  • PORT: The port your server runs on. Keep it as 5000.
  • MONGODB_URI: The connection string you copied from Atlas. Replace <password> in the string with the database user password you created.
  • OPENAI_API_KEY: The secret key you copied from the OpenAI dashboard.
  • JWT_SECRET: Any random string you choose (for example, my-secret-key-12345). This is used for token signing.
  • Save .env with Cmd+S (macOS) or Ctrl+S (Windows).

Never commit your .env file

The .env file contains secrets. If you ever push this project to GitHub, add .env to a .gitignore file so it stays private.

  • Open the full ai-resume-builder folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the ai-resume-builder folder, then click Open.

In the Explorer sidebar on the left, you should see client and server folders. Inside server, you should see package.json, .env, node_modules, and package-lock.json.

Your workspace is ready. Next up, you will build the Express server and connect it to MongoDB.

Set Up Project Structure and Dependencies

Your project folder is ready with its client and server directories. Now you need to tell each side what packages it depends on and configure the credentials that connect everything together.

In this step you will initialize both halves of the application with their dependencies and wire up environment variables so the server can talk to MongoDB and OpenAI.

In this step, get ready to:
  • Initialize the server with Express, Mongoose, and Puppeteer dependencies.
  • Configure environment variables for database and API access.
  • Initialize the client with React, Vite, and routing dependencies.
Initialize the server package

The server is a Node.js application built with Express. It needs packages for the web framework, database connection, AI integration, and PDF generation.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Navigate into the server directory by running this command:
cd server
  • Create the package.json file by running:
npm init -y
  • Open the newly created server/package.json file in the VS Code editor by clicking it in the Explorer sidebar.
  • Replace the entire contents of the file with the following:
{
  "name": "ai-resume-builder-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "express": "^5.1.0",
    "mongoose": "^9.6.3",
    "openai": "^4.77.0",
    "puppeteer": "^24.2.1"
  }
}

What does this file do?

This package.json defines your server as an ES module project ("type": "module") so you can use modern import/export syntax.

The dependencies include express for the web server, mongoose for MongoDB connectivity, openai for AI features, and puppeteer for PDF generation. The cors package allows your frontend to communicate with the backend, and dotenv loads environment variables from a file.

  • Save server/package.json.
  • Install all the dependencies by running:
npm install

This install takes a minute

Puppeteer automatically downloads a copy of Chrome for Testing (~170MB on macOS, ~280MB on Windows). This is normal and enables high-fidelity PDF generation later in the project.

  • After the install finishes, confirm you see a node_modules folder and a package-lock.json file appear in the server directory in the Explorer sidebar.

Install failed or hanging?

If the install fails with a network error, check your internet connection and try running npm install again. Puppeteer's Chrome download can time out on slow connections.

If you see a permissions error on macOS or Linux, do not use sudo. Instead, check that your Node.js was installed via a version manager or the official installer.

Help me troubleshoot my npm install error.

✔️ Awesome, I've got everything!

Great. Make sure you saved server/package.json before moving on.

ⓧ I'd like to double check the full code

{
  "name": "ai-resume-builder-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "express": "^5.1.0",
    "mongoose": "^9.6.3",
    "openai": "^4.77.0",
    "puppeteer": "^24.2.1"
  }
}
Configure environment variables

Your server needs credentials for MongoDB and OpenAI, plus a secret for signing tokens. You store these in a .env file that stays on your machine and never gets committed to version control.

  • In the Explorer sidebar, right-click the server folder and select New File.
  • Name the file .env and press Enter.
  • Paste the following into the file, replacing the placeholder values with your actual credentials:
PORT=5000
MONGODB_URI=your-mongodb-atlas-connection-string-here
OPENAI_API_KEY=your-openai-api-key-here
JWT_SECRET=your-jwt-secret-here

What goes in each variable?

  • PORT sets which port your Express server listens on.
  • MONGODB_URI is the connection string you copied from MongoDB Atlas. It looks like mongodb+srv://username:password@cluster.mongodb.net/resumebuilder.
  • OPENAI_API_KEY is the API key from your OpenAI account.
  • JWT_SECRET can be any random string. For development, type something memorable like my-dev-secret-123.
  • Replace your-mongodb-atlas-connection-string-here with the connection string you obtained from MongoDB Atlas earlier.
  • Replace your-openai-api-key-here with your OpenAI API key.
  • Replace your-jwt-secret-here with any random string of your choice.
  • Save .env.

Not sure where to find your MongoDB connection string?

In MongoDB Atlas, click Connect on your cluster, select Drivers, and copy the connection string. Replace <password> with your database user password.

Help me find my MongoDB Atlas connection string.

✔️ Awesome, I've got everything!

Great. Your server environment is configured.

ⓧ I'd like to double check the full code

PORT=5000
MONGODB_URI=your-mongodb-atlas-connection-string-here
OPENAI_API_KEY=your-openai-api-key-here
JWT_SECRET=your-jwt-secret-here
Set up the client package

The frontend uses React with Vite as the build tool. You will create the package file, a Vite config, and the HTML entry point that loads your app.

  • In the terminal, navigate to the client directory by running:
cd ../client
  • Create a package.json file by running:
npm init -y
  • Open client/package.json in the editor and replace its entire contents with:
{
  "name": "ai-resume-builder-client",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "^1.7.9",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router-dom": "^7.1.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "vite": "^6.0.7"
  }
}

What does this file do?

The client uses react and react-dom for the UI layer, react-router-dom for page routing, and axios for making HTTP requests to your backend API.

Vite and the React plugin (@vitejs/plugin-react) are dev dependencies because they are only needed during development, not in the final production build.

  • Save client/package.json.
  • Now create the Vite configuration file. Right-click the client folder in the Explorer sidebar and select New File.
  • Name it vite.config.js and paste the following:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()]
});

What does this config do?

This tells Vite to use the React plugin, which enables JSX transformation and fast refresh during development. Every time you save a file, Vite updates the browser instantly without a full page reload.

  • Save vite.config.js.
  • Create another new file in the client folder called index.html and paste the following:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AI Resume Builder</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Why is index.html in the client root?

Vite uses index.html as the entry point for your app. The <div id="root"> element is where React mounts your entire component tree. The <script> tag points to your main React file, which you will create in a later step.

  • Save index.html.
  • Install all the client dependencies by running:
npm install
  • Confirm you see a node_modules folder and package-lock.json appear inside the client directory.

Seeing an ERESOLVE or peer dependency error?

If npm warns about peer dependency conflicts, try running npm install --legacy-peer-deps instead. This skips strict peer dependency checks that can conflict between React 19 and older packages.

Help me fix my client npm install error.

✔️ Awesome, I've got everything!

Great. Double check that all three client files are saved.

ⓧ I'd like to double check the full code

{
  "name": "ai-resume-builder-client",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "^1.7.9",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router-dom": "^7.1.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "vite": "^6.0.7"
  }
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()]
});
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>AI Resume Builder</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Both halves of your project are now initialized with their dependencies and configuration. Next up, you will build the Express server and connect it to MongoDB.

Build the Backend Server and Database Models

With your project structure and dependencies ready, it's time to build the backend brain of your resume builder.

Every full-stack app needs a server to handle requests, a database to store data, and API routes to connect the two. In this step, you'll wire up all three using Express, Mongoose, and MongoDB.

In this step, get ready to:
  • Create the Express server with middleware and database connection.
  • Define the MongoDB schema for resumes.
  • Build REST API routes for full CRUD operations on resumes.
Create the Express server with middleware

The server file is the entry point for your backend. It loads environment variables, connects to MongoDB, and mounts your API routes.

  • Create a src folder inside your server directory, then create two subfolders inside it: models and routes. Run these commands from the server directory:
mkdir src
mkdir src/models
mkdir src/routes
  • Confirm the folders were created by running:
ls src

You should see models and routes listed in the output.

  • Create the main server file by adding src/index.js inside your server directory with the following code:
import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import resumeRoutes from './routes/resumes.js';
import aiRoutes from './routes/ai.js';
import pdfRoutes from './routes/pdf.js';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 5000;

// Enable cross-origin requests from the React frontend
app.use(cors());
// Parse incoming JSON request bodies
app.use(express.json());

// Connect to MongoDB Atlas
mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch((err) => console.error('MongoDB connection error:', err));

// Mount route handlers
app.use('/api/resumes', resumeRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/pdf', pdfRoutes);

// Health check endpoint to verify the server is running
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

This file sets up your entire Express server in one place.

  • dotenv.config() loads your .env variables (MongoDB connection string, port) into process.env.
  • cors() allows your React frontend (running on a different port) to make requests to this server.
  • express.json() parses JSON bodies from incoming requests so you can access req.body.
  • mongoose.connect() establishes the connection to your MongoDB Atlas cluster.
  • The /api/health endpoint gives you a quick way to test if the server is alive.

The server imports route files that don't exist yet (ai.js and pdf.js). You'll create those in later steps. For now, you need placeholder files so the server can start.

  • Create placeholder route files for ai.js and pdf.js by running these commands:
echo "import express from 'express';
const router = express.Router();
export default router;" > src/routes/ai.js

echo "import express from 'express';
const router = express.Router();
export default router;" > src/routes/pdf.js

Why create placeholder files?

Node.js will throw an error if you try to import a file that doesn't exist. These empty routers let the server start cleanly now. You'll fill them with real logic in later steps.

Define the MongoDB schema for resumes

A Mongoose schema defines the shape of your data. It tells MongoDB what fields a resume document can have, which are required, and what types they use.

  • Create src/models/Resume.js inside your server directory with the following code:
import mongoose from 'mongoose';

const resumeSchema = new mongoose.Schema({
  // Contact details for the resume header
  personalInfo: {
    fullName: { type: String, required: true },
    email: { type: String, required: true },
    phone: { type: String },
    location: { type: String },
    linkedin: { type: String },
    website: { type: String }
  },
  // AI-generated or manually written professional summary
  summary: { type: String, default: '' },
  // Work history entries
  experience: [{
    title: { type: String },
    company: { type: String },
    startDate: { type: String },
    endDate: { type: String },
    description: { type: String }
  }],
  // Academic background
  education: [{
    degree: { type: String },
    institution: { type: String },
    graduationDate: { type: String },
    gpa: { type: String }
  }],
  // Technical and soft skills list
  skills: [{ type: String }],
  // Which visual template to use for PDF rendering
  templateId: { type: String, default: 'classic' }
}, { timestamps: true });

export default mongoose.model('Resume', resumeSchema);

What does this schema do?

Each field maps to a section of a real resume. The personalInfo object holds contact details, experience and education are arrays (because people have multiple jobs and degrees), and skills is a simple string array.

The { timestamps: true } option automatically adds createdAt and updatedAt fields to every document. This lets you sort resumes by most recently edited.

  • Save Resume.js.
Build REST API routes for resumes

REST API routes define how the frontend communicates with your database. You need endpoints to create, read, update, and delete resumes.

  • Create src/routes/resumes.js inside your server directory with the following code:
import express from 'express';
import Resume from '../models/Resume.js';

const router = express.Router();

// Create a new resume
router.post('/', async (req, res) => {
  try {
    const resume = new Resume(req.body);
    const saved = await resume.save();
    res.status(201).json(saved);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Get all resumes, sorted by most recently updated
router.get('/', async (req, res) => {
  try {
    const resumes = await Resume.find().sort({ updatedAt: -1 });
    res.json(resumes);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Get a single resume by ID
router.get('/:id', async (req, res) => {
  try {
    const resume = await Resume.findById(req.params.id);
    if (!resume) return res.status(404).json({ error: 'Resume not found' });
    res.json(resume);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Update an existing resume
router.put('/:id', async (req, res) => {
  try {
    const resume = await Resume.findByIdAndUpdate(req.params.id, req.body, { new: true });
    if (!resume) return res.status(404).json({ error: 'Resume not found' });
    res.json(resume);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Delete a resume
router.delete('/:id', async (req, res) => {
  try {
    const resume = await Resume.findByIdAndDelete(req.params.id);
    if (!resume) return res.status(404).json({ error: 'Resume not found' });
    res.json({ message: 'Resume deleted' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

What do these routes do?

  • POST / creates a new resume document from the request body and saves it to MongoDB.
  • GET / returns all resumes, sorted with the most recently updated first.
  • GET /:id fetches a single resume by its MongoDB document ID.
  • PUT /:id updates an existing resume. The { new: true } option tells Mongoose to return the updated document instead of the old one.
  • DELETE /:id removes a resume permanently.

Because these routes are mounted at /api/resumes in index.js, the full endpoint paths are POST /api/resumes, GET /api/resumes, and so on.

  • Save resumes.js.
  • Start the server by running this command from the server directory:
npm run dev

You should see Connected to MongoDB and Server running on port 5000 printed in your terminal.

What does npm run dev do?

The dev script in package.json runs node --watch src/index.js. The --watch flag is a built-in Node.js feature that automatically restarts the server whenever you save changes to your files.

Not seeing Connected to MongoDB?

  • Double check that your MONGODB_URI in .env is the full connection string from MongoDB Atlas (it starts with mongodb+srv://).
  • Make sure you replaced the <password> placeholder in the connection string with your actual database user password.
  • Verify your IP address is whitelisted in the MongoDB Atlas Network Access settings.

Help me debug my MongoDB connection error.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here is server/src/index.js:

import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import resumeRoutes from './routes/resumes.js';
import aiRoutes from './routes/ai.js';
import pdfRoutes from './routes/pdf.js';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 5000;

app.use(cors());
app.use(express.json());

mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch((err) => console.error('MongoDB connection error:', err));

app.use('/api/resumes', resumeRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/pdf', pdfRoutes);

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Here is server/src/models/Resume.js:

import mongoose from 'mongoose';

const resumeSchema = new mongoose.Schema({
  personalInfo: {
    fullName: { type: String, required: true },
    email: { type: String, required: true },
    phone: { type: String },
    location: { type: String },
    linkedin: { type: String },
    website: { type: String }
  },
  summary: { type: String, default: '' },
  experience: [{
    title: { type: String },
    company: { type: String },
    startDate: { type: String },
    endDate: { type: String },
    description: { type: String }
  }],
  education: [{
    degree: { type: String },
    institution: { type: String },
    graduationDate: { type: String },
    gpa: { type: String }
  }],
  skills: [{ type: String }],
  templateId: { type: String, default: 'classic' }
}, { timestamps: true });

export default mongoose.model('Resume', resumeSchema);

Here is server/src/routes/resumes.js:

import express from 'express';
import Resume from '../models/Resume.js';

const router = express.Router();

router.post('/', async (req, res) => {
  try {
    const resume = new Resume(req.body);
    const saved = await resume.save();
    res.status(201).json(saved);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

router.get('/', async (req, res) => {
  try {
    const resumes = await Resume.find().sort({ updatedAt: -1 });
    res.json(resumes);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

router.get('/:id', async (req, res) => {
  try {
    const resume = await Resume.findById(req.params.id);
    if (!resume) return res.status(404).json({ error: 'Resume not found' });
    res.json(resume);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

router.put('/:id', async (req, res) => {
  try {
    const resume = await Resume.findByIdAndUpdate(req.params.id, req.body, { new: true });
    if (!resume) return res.status(404).json({ error: 'Resume not found' });
    res.json(resume);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

router.delete('/:id', async (req, res) => {
  try {
    const resume = await Resume.findByIdAndDelete(req.params.id);
    if (!resume) return res.status(404).json({ error: 'Resume not found' });
    res.json({ message: 'Resume deleted' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

Your backend is alive and connected to MongoDB. Next up, you'll add AI-powered features that generate professional resume content using the OpenAI API.

Integrate OpenAI for AI-Powered Resume Features

Your backend can store and retrieve resumes, but it cannot yet help users write better content. The most time-consuming part of building a resume is crafting a compelling professional summary and tailoring keywords to each job posting.

In this step, you will connect to the OpenAI API to give your resume builder AI-powered features. You will build three endpoints: one that generates professional summaries, one that scores resumes against job descriptions, and one that enhances bullet points with stronger action verbs.

In this step, get ready to:
  • Create an AI service module that connects to the OpenAI Chat Completions API.
  • Build an ATS score checker that analyzes resumes against job descriptions.
  • Add a bullet point enhancer route for improving resume experience entries.
Create the AI service module with the summary generator

The OpenAI Chat Completions API lets you send a prompt and receive generated text. For a resume summary, you provide the user's experience and skills, and the AI returns a polished professional summary optimized for applicant tracking systems.

  • Create a new file called ai.js inside the server/src/routes folder.
  • Add the imports, OpenAI client, and the summary generation route by pasting this code:
import express from 'express';
import OpenAI from 'openai';

const router = express.Router();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

router.post('/generate-summary', async (req, res) => {
  try {
    const { experience, skills, targetRole } = req.body;

    const prompt = `You are a professional resume writer. Generate a concise, compelling professional summary (3-4 sentences) for a resume based on the following information:

Experience: ${JSON.stringify(experience)}
Skills: ${skills.join(', ')}
Target Role: ${targetRole || 'Not specified'}

The summary should:
- Start with years of experience and primary expertise
- Highlight key achievements and skills
- Be optimized for ATS systems with relevant keywords
- Be written in first person without using "I"

Respond with only the summary text, no additional formatting.`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: 'You are a professional resume writer specializing in ATS-optimized content.' },
        { role: 'user', content: prompt }
      ],
      max_tokens: 300,
      temperature: 0.7
    });

    const summary = completion.choices[0].message.content;
    res.json({ summary });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

What does this code do?

The first few lines import Express and the OpenAI SDK, then create an OpenAI client using the API key from your .env file.

The /generate-summary route extracts experience, skills, and target role from the request body. It builds a detailed prompt telling the AI exactly what kind of summary to write. The temperature: 0.7 setting gives the AI a balance between creativity and consistency.

Why GPT-4o mini?

GPT-4o mini costs $0.15 per 1M input tokens and $0.60 per 1M output tokens. For short resume summaries and ATS analysis, it provides excellent quality at minimal cost. A full test session for this project costs well under $0.05.

  • Save ai.js.

You will not test this route yet. The next two chunks finish the remaining endpoints, and then you will verify the full file together.

Build the ATS score checker

An ATS (Applicant Tracking System) scans resumes for keywords that match a job description. Your endpoint will send both the resume data and the job posting to the AI, which returns a score breakdown with specific improvement suggestions.

  • In ai.js, add the ATS score route below the closing }); of the /generate-summary route by pasting this code:
router.post('/ats-score', async (req, res) => {
  try {
    const { resume, jobDescription } = req.body;

    const prompt = `Analyze this resume against the job description and provide an ATS compatibility score.

RESUME:
Name: ${resume.personalInfo.fullName}
Summary: ${resume.summary}
Experience: ${JSON.stringify(resume.experience)}
Skills: ${resume.skills.join(', ')}
Education: ${JSON.stringify(resume.education)}

JOB DESCRIPTION:
${jobDescription}

Provide your analysis as a JSON object with this exact structure:
{
  "overallScore": <number 0-100>,
  "breakdown": {
    "keywordMatch": <number 0-100>,
    "formattingScore": <number 0-100>,
    "contentRelevance": <number 0-100>
  },
  "matchedKeywords": [<list of matched keywords>],
  "missingKeywords": [<list of important missing keywords>],
  "suggestions": [<list of 3-5 specific improvement suggestions>]
}

Respond with only the JSON object, no additional text.`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: 'You are an ATS (Applicant Tracking System) expert. Analyze resumes for ATS compatibility.' },
        { role: 'user', content: prompt }
      ],
      max_tokens: 800,
      temperature: 0.3
    });

    const content = completion.choices[0].message.content;
    const analysis = JSON.parse(content);
    res.json(analysis);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

What does this code do?

This route accepts a full resume object and a job description string. The prompt instructs the AI to return a structured JSON response with a score breakdown across three categories: keyword match, formatting, and content relevance.

The temperature: 0.3 setting keeps the response more deterministic. For scoring, you want consistent evaluations rather than creative variation. The response is parsed from JSON into a JavaScript object before being sent back to the client.

  • Save ai.js.
Add the bullet point enhancer route

Resume bullet points are most effective when they start with strong action verbs and include quantifiable results. This endpoint takes rough bullet points and returns polished, ATS-friendly versions.

  • In ai.js, add the enhance-bullets route and the export statement below the /ats-score route by pasting this code:
router.post('/enhance-bullets', async (req, res) => {
  try {
    const { bullets, targetRole } = req.body;

    const prompt = `Enhance these resume bullet points to be more impactful and ATS-friendly for a ${targetRole || 'professional'} role. Use strong action verbs and include quantifiable results where possible.

Original bullets:
${bullets.map((b, i) => `${i + 1}. ${b}`).join('\n')}

Respond with a JSON array of enhanced bullet points:
["enhanced bullet 1", "enhanced bullet 2", ...]`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: 'You are a professional resume writer.' },
        { role: 'user', content: prompt }
      ],
      max_tokens: 500,
      temperature: 0.7
    });

    const enhanced = JSON.parse(completion.choices[0].message.content);
    res.json({ bullets: enhanced });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

What does this code do?

This route takes an array of bullet point strings and a target role. It numbers each bullet in the prompt so the AI can map its enhancements back clearly. The response is a JSON array of improved bullet points that the frontend can display alongside the originals.

The final export default router makes this file importable from index.js, where it is already mounted at /api/ai.

  • Save ai.js.
  • Switch back to the terminal running your server. If the server was running with npm run dev, it will restart automatically. You should see Connected to MongoDB without any new errors.

Seeing a module not found error?

  • Make sure your file is saved at exactly server/src/routes/ai.js. The path must match the import in index.js.
  • Check that the file ends with export default router;. A missing export will cause the import to fail.
  • Confirm your .env file has OPENAI_API_KEY set. The OpenAI client reads this value on startup.

Why is my AI routes file not loading in Express?

Let's verify one of the AI endpoints is reachable. Test the health of the route by running this command from a new terminal window:

  • Test the generate-summary endpoint by running this command:
curl -X POST http://localhost:5000/api/ai/generate-summary -H "Content-Type: application/json" -d '{"experience": [{"title": "Software Engineer", "company": "Acme Inc", "description": "Built web apps"}], "skills": ["JavaScript", "React"], "targetRole": "Frontend Developer"}'

You should see a JSON response with a summary field containing an AI-generated professional summary. The exact text will vary each time.

Getting a 401 or authentication error?

  • Double check that your OPENAI_API_KEY in .env is a valid key with a payment method attached to the OpenAI account.
  • Restart the server after changing .env. Environment variables are only read at startup.

Why is my OpenAI API call returning a 401 error?

✔️ Awesome, I've got everything!

Great. Double check you have saved ai.js and your server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete server/src/routes/ai.js file for comparison:

import express from 'express';
import OpenAI from 'openai';

const router = express.Router();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

router.post('/generate-summary', async (req, res) => {
  try {
    const { experience, skills, targetRole } = req.body;

    const prompt = `You are a professional resume writer. Generate a concise, compelling professional summary (3-4 sentences) for a resume based on the following information:

Experience: ${JSON.stringify(experience)}
Skills: ${skills.join(', ')}
Target Role: ${targetRole || 'Not specified'}

The summary should:
- Start with years of experience and primary expertise
- Highlight key achievements and skills
- Be optimized for ATS systems with relevant keywords
- Be written in first person without using "I"

Respond with only the summary text, no additional formatting.`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: 'You are a professional resume writer specializing in ATS-optimized content.' },
        { role: 'user', content: prompt }
      ],
      max_tokens: 300,
      temperature: 0.7
    });

    const summary = completion.choices[0].message.content;
    res.json({ summary });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

router.post('/ats-score', async (req, res) => {
  try {
    const { resume, jobDescription } = req.body;

    const prompt = `Analyze this resume against the job description and provide an ATS compatibility score.

RESUME:
Name: ${resume.personalInfo.fullName}
Summary: ${resume.summary}
Experience: ${JSON.stringify(resume.experience)}
Skills: ${resume.skills.join(', ')}
Education: ${JSON.stringify(resume.education)}

JOB DESCRIPTION:
${jobDescription}

Provide your analysis as a JSON object with this exact structure:
{
  "overallScore": <number 0-100>,
  "breakdown": {
    "keywordMatch": <number 0-100>,
    "formattingScore": <number 0-100>,
    "contentRelevance": <number 0-100>
  },
  "matchedKeywords": [<list of matched keywords>],
  "missingKeywords": [<list of important missing keywords>],
  "suggestions": [<list of 3-5 specific improvement suggestions>]
}

Respond with only the JSON object, no additional text.`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: 'You are an ATS (Applicant Tracking System) expert. Analyze resumes for ATS compatibility.' },
        { role: 'user', content: prompt }
      ],
      max_tokens: 800,
      temperature: 0.3
    });

    const content = completion.choices[0].message.content;
    const analysis = JSON.parse(content);
    res.json(analysis);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

router.post('/enhance-bullets', async (req, res) => {
  try {
    const { bullets, targetRole } = req.body;

    const prompt = `Enhance these resume bullet points to be more impactful and ATS-friendly for a ${targetRole || 'professional'} role. Use strong action verbs and include quantifiable results where possible.

Original bullets:
${bullets.map((b, i) => `${i + 1}. ${b}`).join('\n')}

Respond with a JSON array of enhanced bullet points:
["enhanced bullet 1", "enhanced bullet 2", ...]`;

    const completion = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: 'You are a professional resume writer.' },
        { role: 'user', content: prompt }
      ],
      max_tokens: 500,
      temperature: 0.7
    });

    const enhanced = JSON.parse(completion.choices[0].message.content);
    res.json({ bullets: enhanced });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

Your backend now has AI-powered intelligence. Next up, you will build the PDF generation system so users can download their polished resumes as professional documents.

Add PDF Generation with Puppeteer

Your backend can now generate AI-powered resume content and score resumes against job descriptions. The missing piece is a way to export the finished resume as a polished, downloadable PDF.

In this step, you will build a PDF generation service using Puppeteer. Puppeteer controls a headless Chrome browser to render your resume HTML with full CSS fidelity, then captures the result as a pixel-perfect PDF file.

In this step, get ready to:
  • Set up Puppeteer for PDF rendering.
  • Create a resume HTML template.
  • Add the PDF download endpoint.
Set up Puppeteer for PDF rendering

Puppeteer is already listed in your server/package.json dependencies from Step 1. It downloads a bundled Chromium browser that your server uses to render HTML pages and convert them to PDFs.

Why Puppeteer instead of a PDF library?

Libraries like PDFKit require you to manually position every text element and line. Puppeteer renders real HTML and CSS through Chrome's layout engine, so you get pixel-perfect results with standard web styling.

The trade-off is a larger install size (~280MB for the bundled browser), but for a resume builder where visual layout matters, the fidelity is worth it.

  • Create a new file called pdf.js inside server/src/routes/.

This file will hold both the HTML template function and the PDF download route. Your index.js already imports from this path, so creating it will resolve the missing module.

Create the resume HTML template

The PDF service needs a function that converts resume data into a styled HTML document. Puppeteer will render this HTML and capture it as a PDF.

  • In server/src/routes/pdf.js, add the imports, router initialization, and the HTML template function by pasting this code:
import express from 'express';
import puppeteer from 'puppeteer';
import Resume from '../models/Resume.js';

const router = express.Router();

// Converts resume data into a styled HTML document for PDF rendering
function generateResumeHTML(resume) {
  const experienceHTML = resume.experience.map(exp => `
    <div class="experience-item">
      <div class="item-header">
        <strong>${exp.title}</strong> | ${exp.company}
        <span class="dates">${exp.startDate} - ${exp.endDate || 'Present'}</span>
      </div>
      <p>${exp.description}</p>
    </div>
  `).join('');

  const educationHTML = resume.education.map(edu => `
    <div class="education-item">
      <div class="item-header">
        <strong>${edu.degree}</strong> | ${edu.institution}
        <span class="dates">${edu.graduationDate}</span>
      </div>
      ${edu.gpa ? `<p>GPA: ${edu.gpa}</p>` : ''}
    </div>
  `).join('');

  const skillsHTML = resume.skills.map(skill => `<span class="skill-tag">${skill}</span>`).join('');

  return `<!DOCTYPE html>
<html>
<head>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: 'Georgia', serif; font-size: 11pt; line-height: 1.5; color: #333; padding: 40px 50px; }
    .header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #2c3e50; padding-bottom: 15px; }
    .header h1 { font-size: 24pt; color: #2c3e50; margin-bottom: 5px; }
    .header .contact { font-size: 10pt; color: #555; }
    .section { margin-bottom: 18px; }
    .section h2 { font-size: 13pt; color: #2c3e50; border-bottom: 1px solid #bdc3c7; padding-bottom: 3px; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }
    .experience-item, .education-item { margin-bottom: 12px; }
    .item-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
    .dates { font-style: italic; font-size: 10pt; color: #666; }
    .skill-tag { display: inline-block; background: #ecf0f1; padding: 3px 10px; margin: 3px 4px 3px 0; border-radius: 3px; font-size: 10pt; }
    p { margin-bottom: 5px; }
  </style>
</head>
<body>
  <div class="header">
    <h1>${resume.personalInfo.fullName}</h1>
    <div class="contact">
      ${resume.personalInfo.email} | ${resume.personalInfo.phone || ''} | ${resume.personalInfo.location || ''}
      ${resume.personalInfo.linkedin ? ` | ${resume.personalInfo.linkedin}` : ''}
    </div>
  </div>

  ${resume.summary ? `
  <div class="section">
    <h2>Professional Summary</h2>
    <p>${resume.summary}</p>
  </div>` : ''}

  ${resume.experience.length > 0 ? `
  <div class="section">
    <h2>Experience</h2>
    ${experienceHTML}
  </div>` : ''}

  ${resume.education.length > 0 ? `
  <div class="section">
    <h2>Education</h2>
    ${educationHTML}
  </div>` : ''}

  ${resume.skills.length > 0 ? `
  <div class="section">
    <h2>Skills</h2>
    <div>${skillsHTML}</div>
  </div>` : ''}
</body>
</html>`;
}

What does this code do?

  • The imports bring in puppeteer for browser-based rendering and the Resume model for fetching data from MongoDB.
  • The generateResumeHTML function takes a resume object and builds a complete HTML page with inline CSS. Each section (experience, education, skills) is conditionally rendered only when data exists.
  • The inline styles use professional typography (Georgia serif, navy headers) and clean layout (flexbox for date alignment, subtle skill tags) to produce a resume that looks polished when printed to PDF.
  • Save pdf.js.

You won't see a visible change yet. The next chunk adds the route that calls this function and returns the PDF.

Seeing a syntax error?

Template literals with nested ternary expressions (the ${edu.gpa ? ... : ''} patterns) are easy to mistype. Check that every ${ has a matching } and that backticks are not accidentally closed early.

help me debug a template literal syntax error in my generateResumeHTML function

Add the PDF download endpoint

Now you need a route that fetches a resume from the database, passes it through the HTML template, and uses Puppeteer to convert the rendered page into a downloadable PDF.

  • In server/src/routes/pdf.js, add the following route and export below the generateResumeHTML function:
// Fetches a resume by ID, renders it as HTML, and returns a PDF file
router.get('/:id', async (req, res) => {
  try {
    const resume = await Resume.findById(req.params.id);
    if (!resume) return res.status(404).json({ error: 'Resume not found' });

    const html = generateResumeHTML(resume);

    // Launch a headless Chrome instance to render the HTML
    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });

    // Generate a PDF buffer from the rendered page
    const pdfBuffer = await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' }
    });

    await browser.close();

    // Stream the PDF back as a downloadable file
    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="${resume.personalInfo.fullName.replace(/\s+/g, '_')}_Resume.pdf"`,
      'Content-Length': pdfBuffer.length
    });

    res.send(pdfBuffer);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

What does this route do?

  • It looks up the resume in MongoDB using the :id URL parameter. If no resume exists with that ID, it returns a 404.
  • It passes the resume data to generateResumeHTML to build the styled HTML string.
  • Puppeteer launches a headless browser, loads the HTML, and calls page.pdf() to generate an A4-sized PDF buffer.
  • The response headers tell the browser to download the file (Content-Disposition: attachment) with a filename based on the person's name.
  • Save pdf.js.
  • Switch back to your terminal running the server. Restart it by pressing Ctrl+C then running:
npm run dev

You should see Connected to MongoDB and Server running on port 5000 without any import errors. The PDF endpoint is now live at GET /api/pdf/:id.

Server crashing on startup?

If you see Cannot find module './routes/pdf.js', make sure the file is saved at exactly server/src/routes/pdf.js (not server/routes/pdf.js or another path).

If Puppeteer fails to launch on Windows, try adding args: ['--no-sandbox'] to the puppeteer.launch() options.

help me fix a Puppeteer launch error in my PDF route

✔️ Awesome, I've got everything!

Great. Double check you have saved server/src/routes/pdf.js and your server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete server/src/routes/pdf.js file:

import express from 'express';
import puppeteer from 'puppeteer';
import Resume from '../models/Resume.js';

const router = express.Router();

function generateResumeHTML(resume) {
  const experienceHTML = resume.experience.map(exp => `
    <div class="experience-item">
      <div class="item-header">
        <strong>${exp.title}</strong> | ${exp.company}
        <span class="dates">${exp.startDate} - ${exp.endDate || 'Present'}</span>
      </div>
      <p>${exp.description}</p>
    </div>
  `).join('');

  const educationHTML = resume.education.map(edu => `
    <div class="education-item">
      <div class="item-header">
        <strong>${edu.degree}</strong> | ${edu.institution}
        <span class="dates">${edu.graduationDate}</span>
      </div>
      ${edu.gpa ? `<p>GPA: ${edu.gpa}</p>` : ''}
    </div>
  `).join('');

  const skillsHTML = resume.skills.map(skill => `<span class="skill-tag">${skill}</span>`).join('');

  return `<!DOCTYPE html>
<html>
<head>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: 'Georgia', serif; font-size: 11pt; line-height: 1.5; color: #333; padding: 40px 50px; }
    .header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #2c3e50; padding-bottom: 15px; }
    .header h1 { font-size: 24pt; color: #2c3e50; margin-bottom: 5px; }
    .header .contact { font-size: 10pt; color: #555; }
    .section { margin-bottom: 18px; }
    .section h2 { font-size: 13pt; color: #2c3e50; border-bottom: 1px solid #bdc3c7; padding-bottom: 3px; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }
    .experience-item, .education-item { margin-bottom: 12px; }
    .item-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
    .dates { font-style: italic; font-size: 10pt; color: #666; }
    .skill-tag { display: inline-block; background: #ecf0f1; padding: 3px 10px; margin: 3px 4px 3px 0; border-radius: 3px; font-size: 10pt; }
    p { margin-bottom: 5px; }
  </style>
</head>
<body>
  <div class="header">
    <h1>${resume.personalInfo.fullName}</h1>
    <div class="contact">
      ${resume.personalInfo.email} | ${resume.personalInfo.phone || ''} | ${resume.personalInfo.location || ''}
      ${resume.personalInfo.linkedin ? ` | ${resume.personalInfo.linkedin}` : ''}
    </div>
  </div>

  ${resume.summary ? `
  <div class="section">
    <h2>Professional Summary</h2>
    <p>${resume.summary}</p>
  </div>` : ''}

  ${resume.experience.length > 0 ? `
  <div class="section">
    <h2>Experience</h2>
    ${experienceHTML}
  </div>` : ''}

  ${resume.education.length > 0 ? `
  <div class="section">
    <h2>Education</h2>
    ${educationHTML}
  </div>` : ''}

  ${resume.skills.length > 0 ? `
  <div class="section">
    <h2>Skills</h2>
    <div>${skillsHTML}</div>
  </div>` : ''}
</body>
</html>`;
}

router.get('/:id', async (req, res) => {
  try {
    const resume = await Resume.findById(req.params.id);
    if (!resume) return res.status(404).json({ error: 'Resume not found' });

    const html = generateResumeHTML(resume);

    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });

    const pdfBuffer = await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' }
    });

    await browser.close();

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="${resume.personalInfo.fullName.replace(/\s+/g, '_')}_Resume.pdf"`,
      'Content-Length': pdfBuffer.length
    });

    res.send(pdfBuffer);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

Your backend is now complete. It can store resumes, generate AI content, score resumes against job descriptions, and export polished PDFs. Next up, you will build the React frontend that ties all of these features together.

Build the React Frontend with Resume Form and Preview

Your backend can store resumes, generate AI content, and produce PDFs. Now you need a way for users to actually interact with all of that functionality.

In this step, you will build the React frontend for the resume builder. This includes a multi-section form for entering resume data and a live preview that updates as the user types.

In this step, get ready to:
  • Install frontend dependencies and set up the React project structure.
  • Build a multi-section Resume Form component with tabs for each resume section.
  • Build a Resume Preview component that renders resume data in real-time.
Install dependencies and set up the project structure

The client directory already has a package.json with all the dependencies listed. You need to install them and create the source file structure.

  • In your terminal, navigate to the client directory by running this command:
cd client
  • Install the project dependencies by running:
npm install

What does this install?

This installs react and react-dom for building the UI, axios for making HTTP requests to your backend, and react-router-dom for page navigation.

It also installs Vite and the React plugin as dev dependencies for the development server and build tooling.

Seeing permission errors?

On macOS, try prefixing with sudo. On Windows, run your terminal as Administrator. Make sure you are inside the client folder (not the project root or server folder).

Help me fix npm permission errors

  • Create the src/components directory structure by running:
mkdir -p src/components
  • Confirm the directory was created by running:
ls src

You should see components listed in the output.

Now create the entry point file that boots up your React app.

  • Create a new file at src/main.jsx and add the following code:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

What does this code do?

This is the entry point for your React app. It finds the #root div in index.html and renders the App component inside it.

StrictMode is a development helper that highlights potential problems in your code. It does nothing in production.

  • Save src/main.jsx.

Next, create the global stylesheet that styles the entire application.

  • Create a new file at src/index.css and add the following styles:
:root {
  --primary: #2c3e50;
  --accent: #3498db;
  --bg: #f8f9fa;
  --card: #ffffff;
  --text: #333333;
  --border: #e0e0e0;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
}

.app { max-width: 1200px; margin: 0 auto; padding: 20px; }

.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding: 15px 20px;
  background: var(--primary);
  color: white;
  border-radius: 8px;
}

.app-header h1 { font-size: 1.5rem; }

.header-actions { display: flex; gap: 10px; }

button {
  padding: 8px 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.9rem;
  background: var(--accent);
  color: white;
  transition: opacity 0.2s;
}

button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.active { background: var(--primary); }

.tabs {
  display: flex;
  gap: 5px;
  margin-bottom: 20px;
  border-bottom: 2px solid var(--border);
  padding-bottom: 10px;
}

.tabs button { background: transparent; color: var(--text); }
.tabs button.active { background: var(--accent); color: white; }

.main-content { background: var(--card); border-radius: 8px; padding: 25px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }

.resume-form .section-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.section-tabs button { font-size: 0.85rem; padding: 6px 12px; }

.form-section h3 { margin-bottom: 15px; color: var(--primary); }

input, textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 0.95rem;
  margin-bottom: 10px;
}

textarea { resize: vertical; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }

.form-group { padding: 15px; background: var(--bg); border-radius: 8px; margin-bottom: 12px; }
.date-row { display: flex; gap: 10px; }
.date-row input { flex: 1; }

.remove-btn { background: #e74c3c; font-size: 0.8rem; padding: 5px 10px; }
.add-btn { background: #27ae60; }
.ai-button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); margin-top: 10px; }

.skill-tag, .preview-skill-tag {
  display: inline-block;
  background: #ecf0f1;
  padding: 4px 10px;
  margin: 3px 4px 3px 0;
  border-radius: 15px;
  font-size: 0.85rem;
}

.resume-preview { padding: 20px; }
.preview-page {
  max-width: 700px;
  margin: 0 auto;
  padding: 40px;
  background: white;
  box-shadow: 0 0 20px rgba(0,0,0,0.1);
  border: 1px solid var(--border);
}

.preview-header { text-align: center; border-bottom: 2px solid var(--primary); padding-bottom: 15px; margin-bottom: 20px; }
.preview-header h1 { color: var(--primary); font-size: 1.8rem; }
.contact-line { font-size: 0.9rem; color: #666; margin-top: 5px; }

.preview-section { margin-bottom: 20px; }
.preview-section h2 { font-size: 1.1rem; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 5px; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }

.preview-item { margin-bottom: 12px; }
.preview-item-header { display: flex; justify-content: space-between; margin-bottom: 4px; }
.preview-dates { font-style: italic; color: #666; font-size: 0.9rem; }

.ats-panel { max-width: 700px; }
.ats-description { color: #666; margin-bottom: 15px; }

.ats-results { margin-top: 25px; }
.score-circle { text-align: center; margin-bottom: 20px; }
.score-number { font-size: 3rem; font-weight: bold; color: var(--accent); }
.score-label { font-size: 1.2rem; color: #666; }

.score-breakdown { margin-bottom: 20px; }
.score-breakdown h4 { margin-bottom: 10px; }
.score-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.score-item span:first-child { width: 140px; font-size: 0.9rem; }
.score-bar { flex: 1; height: 8px; background: #ecf0f1; border-radius: 4px; overflow: hidden; }
.score-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.5s; }

.keyword-tags { display: flex; flex-wrap: wrap; gap: 5px; }
.keyword-tag { padding: 4px 10px; border-radius: 15px; font-size: 0.8rem; }
.keyword-tag.missing { background: #fdecea; color: #c0392b; }

.suggestions { margin-top: 15px; }
.suggestions h4 { margin-bottom: 8px; }
.suggestions ul { padding-left: 20px; }
.suggestions li { margin-bottom: 6px; font-size: 0.9rem; line-height: 1.4; }

.missing-keywords { margin-bottom: 15px; }
.missing-keywords h4 { margin-bottom: 8px; }

What does this stylesheet do?

This CSS defines the visual design for the entire app using CSS variables for consistent theming. The --primary and --accent variables control the color scheme across headers, buttons, and section headings.

It also includes styles for the form layout, the resume preview page (which mimics a printed document), and the ATS score visualization you will build in the next step.

  • Save src/index.css.

✔️ Awesome, I've got everything!

Great. Make sure both src/main.jsx and src/index.css are saved.

ⓧ I'd like to double check the full code

Here is the complete src/index.css file:

:root {
  --primary: #2c3e50;
  --accent: #3498db;
  --bg: #f8f9fa;
  --card: #ffffff;
  --text: #333333;
  --border: #e0e0e0;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
}

.app { max-width: 1200px; margin: 0 auto; padding: 20px; }

.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  padding: 15px 20px;
  background: var(--primary);
  color: white;
  border-radius: 8px;
}

.app-header h1 { font-size: 1.5rem; }

.header-actions { display: flex; gap: 10px; }

button {
  padding: 8px 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.9rem;
  background: var(--accent);
  color: white;
  transition: opacity 0.2s;
}

button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.active { background: var(--primary); }

.tabs {
  display: flex;
  gap: 5px;
  margin-bottom: 20px;
  border-bottom: 2px solid var(--border);
  padding-bottom: 10px;
}

.tabs button { background: transparent; color: var(--text); }
.tabs button.active { background: var(--accent); color: white; }

.main-content { background: var(--card); border-radius: 8px; padding: 25px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }

.resume-form .section-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.section-tabs button { font-size: 0.85rem; padding: 6px 12px; }

.form-section h3 { margin-bottom: 15px; color: var(--primary); }

input, textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 0.95rem;
  margin-bottom: 10px;
}

textarea { resize: vertical; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }

.form-group { padding: 15px; background: var(--bg); border-radius: 8px; margin-bottom: 12px; }
.date-row { display: flex; gap: 10px; }
.date-row input { flex: 1; }

.remove-btn { background: #e74c3c; font-size: 0.8rem; padding: 5px 10px; }
.add-btn { background: #27ae60; }
.ai-button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); margin-top: 10px; }

.skill-tag, .preview-skill-tag {
  display: inline-block;
  background: #ecf0f1;
  padding: 4px 10px;
  margin: 3px 4px 3px 0;
  border-radius: 15px;
  font-size: 0.85rem;
}

.resume-preview { padding: 20px; }
.preview-page {
  max-width: 700px;
  margin: 0 auto;
  padding: 40px;
  background: white;
  box-shadow: 0 0 20px rgba(0,0,0,0.1);
  border: 1px solid var(--border);
}

.preview-header { text-align: center; border-bottom: 2px solid var(--primary); padding-bottom: 15px; margin-bottom: 20px; }
.preview-header h1 { color: var(--primary); font-size: 1.8rem; }
.contact-line { font-size: 0.9rem; color: #666; margin-top: 5px; }

.preview-section { margin-bottom: 20px; }
.preview-section h2 { font-size: 1.1rem; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 5px; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }

.preview-item { margin-bottom: 12px; }
.preview-item-header { display: flex; justify-content: space-between; margin-bottom: 4px; }
.preview-dates { font-style: italic; color: #666; font-size: 0.9rem; }

.ats-panel { max-width: 700px; }
.ats-description { color: #666; margin-bottom: 15px; }

.ats-results { margin-top: 25px; }
.score-circle { text-align: center; margin-bottom: 20px; }
.score-number { font-size: 3rem; font-weight: bold; color: var(--accent); }
.score-label { font-size: 1.2rem; color: #666; }

.score-breakdown { margin-bottom: 20px; }
.score-breakdown h4 { margin-bottom: 10px; }
.score-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.score-item span:first-child { width: 140px; font-size: 0.9rem; }
.score-bar { flex: 1; height: 8px; background: #ecf0f1; border-radius: 4px; overflow: hidden; }
.score-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.5s; }

.keyword-tags { display: flex; flex-wrap: wrap; gap: 5px; }
.keyword-tag { padding: 4px 10px; border-radius: 15px; font-size: 0.8rem; }
.keyword-tag.missing { background: #fdecea; color: #c0392b; }

.suggestions { margin-top: 15px; }
.suggestions h4 { margin-bottom: 8px; }
.suggestions ul { padding-left: 20px; }
.suggestions li { margin-bottom: 6px; font-size: 0.9rem; line-height: 1.4; }

.missing-keywords { margin-bottom: 15px; }
.missing-keywords h4 { margin-bottom: 8px; }
Build the Resume Form component

The Resume Form is the main input interface. It uses tabbed sections so users can focus on one part of their resume at a time without being overwhelmed by a giant form.

  • Create a new file at src/components/ResumeForm.jsx and add the following code:
import { useState } from 'react';

function ResumeForm({ resume, setResume, onGenerateSummary, loading }) {
  const [activeSection, setActiveSection] = useState('personal');

  // Update a single field in personalInfo
  const updatePersonalInfo = (field, value) => {
    setResume(prev => ({
      ...prev,
      personalInfo: { ...prev.personalInfo, [field]: value }
    }));
  };

  // Update a field within a specific experience entry
  const updateExperience = (index, field, value) => {
    setResume(prev => {
      const updated = [...prev.experience];
      updated[index] = { ...updated[index], [field]: value };
      return { ...prev, experience: updated };
    });
  };

  // Add a blank experience entry
  const addExperience = () => {
    setResume(prev => ({
      ...prev,
      experience: [...prev.experience, { title: '', company: '', startDate: '', endDate: '', description: '' }]
    }));
  };

  // Remove an experience entry by index
  const removeExperience = (index) => {
    setResume(prev => ({
      ...prev,
      experience: prev.experience.filter((_, i) => i !== index)
    }));
  };

  // Update a field within a specific education entry
  const updateEducation = (index, field, value) => {
    setResume(prev => {
      const updated = [...prev.education];
      updated[index] = { ...updated[index], [field]: value };
      return { ...prev, education: updated };
    });
  };

  // Add a blank education entry
  const addEducation = () => {
    setResume(prev => ({
      ...prev,
      education: [...prev.education, { degree: '', institution: '', graduationDate: '', gpa: '' }]
    }));
  };

  // Remove an education entry by index
  const removeEducation = (index) => {
    setResume(prev => ({
      ...prev,
      education: prev.education.filter((_, i) => i !== index)
    }));
  };

  // Parse comma-separated skills into an array
  const handleSkillsChange = (value) => {
    const skillsArray = value.split(',').map(s => s.trim()).filter(s => s);
    setResume(prev => ({ ...prev, skills: skillsArray }));
  };

  return (
    <div className="resume-form">
      <div className="section-tabs">
        <button className={activeSection === 'personal' ? 'active' : ''} onClick={() => setActiveSection('personal')}>Personal</button>
        <button className={activeSection === 'summary' ? 'active' : ''} onClick={() => setActiveSection('summary')}>Summary</button>
        <button className={activeSection === 'experience' ? 'active' : ''} onClick={() => setActiveSection('experience')}>Experience</button>
        <button className={activeSection === 'education' ? 'active' : ''} onClick={() => setActiveSection('education')}>Education</button>
        <button className={activeSection === 'skills' ? 'active' : ''} onClick={() => setActiveSection('skills')}>Skills</button>
      </div>

      {activeSection === 'personal' && (
        <div className="form-section">
          <h3>Personal Information</h3>
          <input placeholder="Full Name" value={resume.personalInfo.fullName} onChange={(e) => updatePersonalInfo('fullName', e.target.value)} />
          <input placeholder="Email" type="email" value={resume.personalInfo.email} onChange={(e) => updatePersonalInfo('email', e.target.value)} />
          <input placeholder="Phone" value={resume.personalInfo.phone} onChange={(e) => updatePersonalInfo('phone', e.target.value)} />
          <input placeholder="Location" value={resume.personalInfo.location} onChange={(e) => updatePersonalInfo('location', e.target.value)} />
          <input placeholder="LinkedIn URL" value={resume.personalInfo.linkedin} onChange={(e) => updatePersonalInfo('linkedin', e.target.value)} />
          <input placeholder="Website" value={resume.personalInfo.website} onChange={(e) => updatePersonalInfo('website', e.target.value)} />
        </div>
      )}

      {activeSection === 'summary' && (
        <div className="form-section">
          <h3>Professional Summary</h3>
          <textarea
            placeholder="Write your professional summary or use AI to generate one..."
            value={resume.summary}
            onChange={(e) => setResume(prev => ({ ...prev, summary: e.target.value }))}
            rows={5}
          />
          <button onClick={onGenerateSummary} disabled={loading} className="ai-button">
            {loading ? 'Generating...' : '✨ Generate with AI'}
          </button>
        </div>
      )}

      {activeSection === 'experience' && (
        <div className="form-section">
          <h3>Work Experience</h3>
          {resume.experience.map((exp, index) => (
            <div key={index} className="form-group">
              <input placeholder="Job Title" value={exp.title} onChange={(e) => updateExperience(index, 'title', e.target.value)} />
              <input placeholder="Company" value={exp.company} onChange={(e) => updateExperience(index, 'company', e.target.value)} />
              <div className="date-row">
                <input placeholder="Start Date" value={exp.startDate} onChange={(e) => updateExperience(index, 'startDate', e.target.value)} />
                <input placeholder="End Date" value={exp.endDate} onChange={(e) => updateExperience(index, 'endDate', e.target.value)} />
              </div>
              <textarea placeholder="Description" value={exp.description} onChange={(e) => updateExperience(index, 'description', e.target.value)} rows={3} />
              {resume.experience.length > 1 && (
                <button onClick={() => removeExperience(index)} className="remove-btn">Remove</button>
              )}
            </div>
          ))}
          <button onClick={addExperience} className="add-btn">+ Add Experience</button>
        </div>
      )}

      {activeSection === 'education' && (
        <div className="form-section">
          <h3>Education</h3>
          {resume.education.map((edu, index) => (
            <div key={index} className="form-group">
              <input placeholder="Degree" value={edu.degree} onChange={(e) => updateEducation(index, 'degree', e.target.value)} />
              <input placeholder="Institution" value={edu.institution} onChange={(e) => updateEducation(index, 'institution', e.target.value)} />
              <input placeholder="Graduation Date" value={edu.graduationDate} onChange={(e) => updateEducation(index, 'graduationDate', e.target.value)} />
              <input placeholder="GPA (optional)" value={edu.gpa} onChange={(e) => updateEducation(index, 'gpa', e.target.value)} />
              {resume.education.length > 1 && (
                <button onClick={() => removeEducation(index)} className="remove-btn">Remove</button>
              )}
            </div>
          ))}
          <button onClick={addEducation} className="add-btn">+ Add Education</button>
        </div>
      )}

      {activeSection === 'skills' && (
        <div className="form-section">
          <h3>Skills</h3>
          <textarea
            placeholder="Enter skills separated by commas (e.g., JavaScript, React, Node.js, MongoDB)"
            value={resume.skills.join(', ')}
            onChange={(e) => handleSkillsChange(e.target.value)}
            rows={4}
          />
          <div className="skills-preview">
            {resume.skills.map((skill, index) => (
              <span key={index} className="skill-tag">{skill}</span>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

export default ResumeForm;

What does this component do?

  • activeSection state controls which tab is visible (Personal, Summary, Experience, Education, or Skills).
  • Each helper function (updatePersonalInfo, updateExperience, etc.) uses the spread operator to immutably update nested state without mutating the original object.
  • The Experience and Education sections support multiple entries with addExperience and removeExperience (and matching education functions).
  • The Summary section includes an AI generate button that calls onGenerateSummary from the parent component.
  • Save src/components/ResumeForm.jsx.

Seeing a syntax error?

Make sure the file extension is .jsx (not .js). Vite needs the JSX extension to process React's HTML-like syntax correctly.

Help me debug my ResumeForm.jsx syntax error

✔️ Awesome, I've got everything!

Great. Double check you saved src/components/ResumeForm.jsx.

ⓧ I'd like to double check the full code

Here is the complete src/components/ResumeForm.jsx:

import { useState } from 'react';

function ResumeForm({ resume, setResume, onGenerateSummary, loading }) {
  const [activeSection, setActiveSection] = useState('personal');

  const updatePersonalInfo = (field, value) => {
    setResume(prev => ({
      ...prev,
      personalInfo: { ...prev.personalInfo, [field]: value }
    }));
  };

  const updateExperience = (index, field, value) => {
    setResume(prev => {
      const updated = [...prev.experience];
      updated[index] = { ...updated[index], [field]: value };
      return { ...prev, experience: updated };
    });
  };

  const addExperience = () => {
    setResume(prev => ({
      ...prev,
      experience: [...prev.experience, { title: '', company: '', startDate: '', endDate: '', description: '' }]
    }));
  };

  const removeExperience = (index) => {
    setResume(prev => ({
      ...prev,
      experience: prev.experience.filter((_, i) => i !== index)
    }));
  };

  const updateEducation = (index, field, value) => {
    setResume(prev => {
      const updated = [...prev.education];
      updated[index] = { ...updated[index], [field]: value };
      return { ...prev, education: updated };
    });
  };

  const addEducation = () => {
    setResume(prev => ({
      ...prev,
      education: [...prev.education, { degree: '', institution: '', graduationDate: '', gpa: '' }]
    }));
  };

  const removeEducation = (index) => {
    setResume(prev => ({
      ...prev,
      education: prev.education.filter((_, i) => i !== index)
    }));
  };

  const handleSkillsChange = (value) => {
    const skillsArray = value.split(',').map(s => s.trim()).filter(s => s);
    setResume(prev => ({ ...prev, skills: skillsArray }));
  };

  return (
    <div className="resume-form">
      <div className="section-tabs">
        <button className={activeSection === 'personal' ? 'active' : ''} onClick={() => setActiveSection('personal')}>Personal</button>
        <button className={activeSection === 'summary' ? 'active' : ''} onClick={() => setActiveSection('summary')}>Summary</button>
        <button className={activeSection === 'experience' ? 'active' : ''} onClick={() => setActiveSection('experience')}>Experience</button>
        <button className={activeSection === 'education' ? 'active' : ''} onClick={() => setActiveSection('education')}>Education</button>
        <button className={activeSection === 'skills' ? 'active' : ''} onClick={() => setActiveSection('skills')}>Skills</button>
      </div>

      {activeSection === 'personal' && (
        <div className="form-section">
          <h3>Personal Information</h3>
          <input placeholder="Full Name" value={resume.personalInfo.fullName} onChange={(e) => updatePersonalInfo('fullName', e.target.value)} />
          <input placeholder="Email" type="email" value={resume.personalInfo.email} onChange={(e) => updatePersonalInfo('email', e.target.value)} />
          <input placeholder="Phone" value={resume.personalInfo.phone} onChange={(e) => updatePersonalInfo('phone', e.target.value)} />
          <input placeholder="Location" value={resume.personalInfo.location} onChange={(e) => updatePersonalInfo('location', e.target.value)} />
          <input placeholder="LinkedIn URL" value={resume.personalInfo.linkedin} onChange={(e) => updatePersonalInfo('linkedin', e.target.value)} />
          <input placeholder="Website" value={resume.personalInfo.website} onChange={(e) => updatePersonalInfo('website', e.target.value)} />
        </div>
      )}

      {activeSection === 'summary' && (
        <div className="form-section">
          <h3>Professional Summary</h3>
          <textarea
            placeholder="Write your professional summary or use AI to generate one..."
            value={resume.summary}
            onChange={(e) => setResume(prev => ({ ...prev, summary: e.target.value }))}
            rows={5}
          />
          <button onClick={onGenerateSummary} disabled={loading} className="ai-button">
            {loading ? 'Generating...' : '✨ Generate with AI'}
          </button>
        </div>
      )}

      {activeSection === 'experience' && (
        <div className="form-section">
          <h3>Work Experience</h3>
          {resume.experience.map((exp, index) => (
            <div key={index} className="form-group">
              <input placeholder="Job Title" value={exp.title} onChange={(e) => updateExperience(index, 'title', e.target.value)} />
              <input placeholder="Company" value={exp.company} onChange={(e) => updateExperience(index, 'company', e.target.value)} />
              <div className="date-row">
                <input placeholder="Start Date" value={exp.startDate} onChange={(e) => updateExperience(index, 'startDate', e.target.value)} />
                <input placeholder="End Date" value={exp.endDate} onChange={(e) => updateExperience(index, 'endDate', e.target.value)} />
              </div>
              <textarea placeholder="Description" value={exp.description} onChange={(e) => updateExperience(index, 'description', e.target.value)} rows={3} />
              {resume.experience.length > 1 && (
                <button onClick={() => removeExperience(index)} className="remove-btn">Remove</button>
              )}
            </div>
          ))}
          <button onClick={addExperience} className="add-btn">+ Add Experience</button>
        </div>
      )}

      {activeSection === 'education' && (
        <div className="form-section">
          <h3>Education</h3>
          {resume.education.map((edu, index) => (
            <div key={index} className="form-group">
              <input placeholder="Degree" value={edu.degree} onChange={(e) => updateEducation(index, 'degree', e.target.value)} />
              <input placeholder="Institution" value={edu.institution} onChange={(e) => updateEducation(index, 'institution', e.target.value)} />
              <input placeholder="Graduation Date" value={edu.graduationDate} onChange={(e) => updateEducation(index, 'graduationDate', e.target.value)} />
              <input placeholder="GPA (optional)" value={edu.gpa} onChange={(e) => updateEducation(index, 'gpa', e.target.value)} />
              {resume.education.length > 1 && (
                <button onClick={() => removeEducation(index)} className="remove-btn">Remove</button>
              )}
            </div>
          ))}
          <button onClick={addEducation} className="add-btn">+ Add Education</button>
        </div>
      )}

      {activeSection === 'skills' && (
        <div className="form-section">
          <h3>Skills</h3>
          <textarea
            placeholder="Enter skills separated by commas (e.g., JavaScript, React, Node.js, MongoDB)"
            value={resume.skills.join(', ')}
            onChange={(e) => handleSkillsChange(e.target.value)}
            rows={4}
          />
          <div className="skills-preview">
            {resume.skills.map((skill, index) => (
              <span key={index} className="skill-tag">{skill}</span>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

export default ResumeForm;
Build the Resume Preview component

The preview component renders the resume data in a clean, professional layout. It updates in real-time as the user types in the form, giving instant visual feedback.

  • Create a new file at src/components/ResumePreview.jsx and add the following code:
function ResumePreview({ resume }) {
  return (
    <div className="resume-preview">
      <div className="preview-page">
        {/* Header with name and contact info */}
        <div className="preview-header">
          <h1>{resume.personalInfo.fullName || 'Your Name'}</h1>
          <p className="contact-line">
            {[resume.personalInfo.email, resume.personalInfo.phone, resume.personalInfo.location]
              .filter(Boolean)
              .join(' | ')}
          </p>
          {resume.personalInfo.linkedin && <p className="contact-line">{resume.personalInfo.linkedin}</p>}
        </div>

        {/* Professional Summary section */}
        {resume.summary && (
          <div className="preview-section">
            <h2>Professional Summary</h2>
            <p>{resume.summary}</p>
          </div>
        )}

        {/* Experience section - only shows if at least one entry has a title */}
        {resume.experience.some(exp => exp.title) && (
          <div className="preview-section">
            <h2>Experience</h2>
            {resume.experience.filter(exp => exp.title).map((exp, index) => (
              <div key={index} className="preview-item">
                <div className="preview-item-header">
                  <strong>{exp.title}</strong> | {exp.company}
                  <span className="preview-dates">{exp.startDate} - {exp.endDate || 'Present'}</span>
                </div>
                <p>{exp.description}</p>
              </div>
            ))}
          </div>
        )}

        {/* Education section - only shows if at least one entry has a degree */}
        {resume.education.some(edu => edu.degree) && (
          <div className="preview-section">
            <h2>Education</h2>
            {resume.education.filter(edu => edu.degree).map((edu, index) => (
              <div key={index} className="preview-item">
                <div className="preview-item-header">
                  <strong>{edu.degree}</strong> | {edu.institution}
                  <span className="preview-dates">{edu.graduationDate}</span>
                </div>
                {edu.gpa && <p>GPA: {edu.gpa}</p>}
              </div>
            ))}
          </div>
        )}

        {/* Skills displayed as tags */}
        {resume.skills.length > 0 && (
          <div className="preview-section">
            <h2>Skills</h2>
            <div className="preview-skills">
              {resume.skills.map((skill, index) => (
                <span key={index} className="preview-skill-tag">{skill}</span>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export default ResumePreview;

What does this component do?

  • It receives the resume object as a prop and renders each section conditionally. Empty sections are hidden automatically.
  • The .filter(Boolean) call on the contact line removes any empty values (phone, location) so you don't get dangling pipe characters.
  • Experience and Education use .some() to check if at least one entry has been filled in before rendering the section, and .filter() to skip blank entries.
  • Save src/components/ResumePreview.jsx.
  • Start the Vite development server by running:
npm run dev

You should see output showing a local URL (typically http://localhost:5173). The page will show an error because App.jsx does not exist yet. You will create it in the next step when you wire up the AI features.

Dev server not starting?

Make sure you are inside the client directory when running the command. Also confirm that npm install completed successfully earlier (check for a node_modules folder inside client).

Help me troubleshoot my Vite dev server

✔️ Awesome, I've got everything!

Great. Double check you saved src/components/ResumePreview.jsx.

ⓧ I'd like to double check the full code

Here is the complete src/components/ResumePreview.jsx:

function ResumePreview({ resume }) {
  return (
    <div className="resume-preview">
      <div className="preview-page">
        <div className="preview-header">
          <h1>{resume.personalInfo.fullName || 'Your Name'}</h1>
          <p className="contact-line">
            {[resume.personalInfo.email, resume.personalInfo.phone, resume.personalInfo.location]
              .filter(Boolean)
              .join(' | ')}
          </p>
          {resume.personalInfo.linkedin && <p className="contact-line">{resume.personalInfo.linkedin}</p>}
        </div>

        {resume.summary && (
          <div className="preview-section">
            <h2>Professional Summary</h2>
            <p>{resume.summary}</p>
          </div>
        )}

        {resume.experience.some(exp => exp.title) && (
          <div className="preview-section">
            <h2>Experience</h2>
            {resume.experience.filter(exp => exp.title).map((exp, index) => (
              <div key={index} className="preview-item">
                <div className="preview-item-header">
                  <strong>{exp.title}</strong> | {exp.company}
                  <span className="preview-dates">{exp.startDate} - {exp.endDate || 'Present'}</span>
                </div>
                <p>{exp.description}</p>
              </div>
            ))}
          </div>
        )}

        {resume.education.some(edu => edu.degree) && (
          <div className="preview-section">
            <h2>Education</h2>
            {resume.education.filter(edu => edu.degree).map((edu, index) => (
              <div key={index} className="preview-item">
                <div className="preview-item-header">
                  <strong>{edu.degree}</strong> | {edu.institution}
                  <span className="preview-dates">{edu.graduationDate}</span>
                </div>
                {edu.gpa && <p>GPA: {edu.gpa}</p>}
              </div>
            ))}
          </div>
        )}

        {resume.skills.length > 0 && (
          <div className="preview-section">
            <h2>Skills</h2>
            <div className="preview-skills">
              {resume.skills.map((skill, index) => (
                <span key={index} className="preview-skill-tag">{skill}</span>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export default ResumePreview;

Your frontend components are built. In the next step, you will create the App.jsx file that connects the form and preview to your backend AI endpoints and adds PDF download functionality.

Wire Up AI Features and PDF Download in the Frontend

Your backend can generate summaries, score resumes against job descriptions, and render PDFs. Your frontend has form and preview components ready to display data. The missing piece is the orchestration layer that connects these two halves together.

In this step, you will build the main App component that manages state, calls your API endpoints, and routes users between the editor, preview, and ATS score tabs. You will also create the ATS Score visualization panel.

In this step, get ready to:
  • Connect the React frontend to AI generation and ATS scoring endpoints.
  • Implement a PDF download flow using blob responses.
  • Build an ATS Score visualization component with progress bars and suggestions.
Connect to the AI endpoints

The App.jsx component is the central hub of your frontend. It holds all the resume state, communicates with your Express API using axios, and decides which panel the user sees.

  • Create a new file App.jsx inside client/src/ by copying the following code:
import { useState, useEffect } from 'react';
import ResumeForm from './components/ResumeForm';
import ResumePreview from './components/ResumePreview';
import AtsScorePanel from './components/AtsScorePanel';
import axios from 'axios';

// Base URL for all API calls to the Express backend
const API_URL = 'http://localhost:5000/api';

// Default empty resume structure matching the MongoDB schema
const emptyResume = {
  personalInfo: { fullName: '', email: '', phone: '', location: '', linkedin: '', website: '' },
  summary: '',
  experience: [{ title: '', company: '', startDate: '', endDate: '', description: '' }],
  education: [{ degree: '', institution: '', graduationDate: '', gpa: '' }],
  skills: [],
  templateId: 'classic'
};

function App() {
  const [resume, setResume] = useState(emptyResume);
  const [activeTab, setActiveTab] = useState('editor');
  const [savedId, setSavedId] = useState(null);
  const [loading, setLoading] = useState(false);
  const [atsScore, setAtsScore] = useState(null);

  // Save or update the resume in MongoDB
  const handleSave = async () => {
    setLoading(true);
    try {
      if (savedId) {
        await axios.put(`${API_URL}/resumes/${savedId}`, resume);
      } else {
        const response = await axios.post(`${API_URL}/resumes`, resume);
        setSavedId(response.data._id);
      }
      alert('Resume saved successfully!');
    } catch (error) {
      alert('Error saving resume: ' + error.message);
    }
    setLoading(false);
  };

  // Call the AI endpoint to generate a professional summary
  const handleGenerateSummary = async () => {
    setLoading(true);
    try {
      const response = await axios.post(`${API_URL}/ai/generate-summary`, {
        experience: resume.experience,
        skills: resume.skills,
        targetRole: resume.personalInfo.fullName ? `${resume.experience[0]?.title || 'Professional'}` : ''
      });
      setResume(prev => ({ ...prev, summary: response.data.summary }));
    } catch (error) {
      alert('Error generating summary: ' + error.message);
    }
    setLoading(false);
  };

  // Send resume and job description to the ATS scoring endpoint
  const handleAtsCheck = async (jobDescription) => {
    setLoading(true);
    try {
      const response = await axios.post(`${API_URL}/ai/ats-score`, {
        resume,
        jobDescription
      });
      setAtsScore(response.data);
    } catch (error) {
      alert('Error checking ATS score: ' + error.message);
    }
    setLoading(false);
  };

  // Download the resume as a PDF via the Puppeteer endpoint
  const handleDownloadPdf = async () => {
    if (!savedId) {
      alert('Please save your resume first!');
      return;
    }
    try {
      const response = await axios.get(`${API_URL}/pdf/${savedId}`, {
        responseType: 'blob'
      });
      const url = window.URL.createObjectURL(new Blob([response.data]));
      const link = document.createElement('a');
      link.href = url;
      link.setAttribute('download', `${resume.personalInfo.fullName || 'Resume'}.pdf`);
      document.body.appendChild(link);
      link.click();
      link.remove();
      window.URL.revokeObjectURL(url);
    } catch (error) {
      alert('Error downloading PDF: ' + error.message);
    }
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>AI Resume Builder</h1>
        <div className="header-actions">
          <button onClick={handleSave} disabled={loading}>
            {loading ? 'Saving...' : 'Save Resume'}
          </button>
          <button onClick={handleDownloadPdf} disabled={!savedId}>
            Download PDF
          </button>
        </div>
      </header>

      <nav className="tabs">
        <button className={activeTab === 'editor' ? 'active' : ''} onClick={() => setActiveTab('editor')}>Editor</button>
        <button className={activeTab === 'preview' ? 'active' : ''} onClick={() => setActiveTab('preview')}>Preview</button>
        <button className={activeTab === 'ats' ? 'active' : ''} onClick={() => setActiveTab('ats')}>ATS Score</button>
      </nav>

      <main className="main-content">
        {activeTab === 'editor' && (
          <ResumeForm
            resume={resume}
            setResume={setResume}
            onGenerateSummary={handleGenerateSummary}
            loading={loading}
          />
        )}
        {activeTab === 'preview' && (
          <ResumePreview resume={resume} />
        )}
        {activeTab === 'ats' && (
          <AtsScorePanel
            onCheck={handleAtsCheck}
            score={atsScore}
            loading={loading}
          />
        )}
      </main>
    </div>
  );
}

export default App;

What does this code do?

This component manages the entire application state and connects your frontend to the backend API.

  • handleSave sends the resume data to POST /api/resumes for new resumes or PUT /api/resumes/:id for updates.
  • handleGenerateSummary posts your experience and skills to the AI endpoint and writes the generated summary back into state.
  • handleAtsCheck sends the full resume plus a job description to the scoring endpoint and stores the result.
  • handleDownloadPdf fetches the PDF as a binary blob and triggers a browser file download.
  • Save App.jsx.

Your Vite dev server will show an error because AtsScorePanel does not exist yet. You will create it in the third substep below.

Seeing a different error?

If you see an error about axios not being found, make sure you installed it in the previous step. Run npm install again inside the client directory.

Help me debug an import error in my React app.

Implement PDF download

The handleDownloadPdf function you just wrote uses a technique called a blob download. Instead of navigating the browser to a URL, it fetches the PDF as raw binary data and programmatically triggers a file save.

Why use a blob instead of a direct link?

A direct link (like <a href="/api/pdf/123">) works for simple downloads but gives you no control over error handling or loading states. Using responseType: 'blob' with axios lets you catch errors gracefully and show feedback to the user.

The function creates a temporary object URL from the blob, attaches it to an invisible link element, clicks it to trigger the browser download dialog, then cleans up the URL to free memory.

The Download PDF button is already wired up in your header. It stays disabled until the user saves their resume (because the PDF endpoint needs a MongoDB _id to fetch the data).

Add the ATS Score display

The ATS Score panel gives users a visual breakdown of how well their resume matches a job description. It sends data to your /api/ai/ats-score endpoint and renders the response as progress bars and keyword lists.

  • Create a new file AtsScorePanel.jsx inside client/src/components/ by copying the following code:
import { useState } from 'react';

function AtsScorePanel({ onCheck, score, loading }) {
  // Local state for the job description text area
  const [jobDescription, setJobDescription] = useState('');

  const handleCheck = () => {
    if (!jobDescription.trim()) {
      alert('Please enter a job description');
      return;
    }
    onCheck(jobDescription);
  };

  return (
    <div className="ats-panel">
      <h3>ATS Score Checker</h3>
      <p className="ats-description">
        Paste a job description below to see how well your resume matches the requirements.
      </p>

      <textarea
        placeholder="Paste the job description here..."
        value={jobDescription}
        onChange={(e) => setJobDescription(e.target.value)}
        rows={8}
      />

      <button onClick={handleCheck} disabled={loading} className="ai-button">
        {loading ? 'Analyzing...' : '\uD83C\uDFAF Check ATS Score'}
      </button>

      {score && (
        <div className="ats-results">
          <div className="score-circle">
            <span className="score-number">{score.overallScore}</span>
            <span className="score-label">/ 100</span>
          </div>

          <div className="score-breakdown">
            <h4>Breakdown</h4>
            <div className="score-item">
              <span>Keyword Match</span>
              <div className="score-bar">
                <div className="score-fill" style={{ width: `${score.breakdown.keywordMatch}%` }}></div>
              </div>
              <span>{score.breakdown.keywordMatch}%</span>
            </div>
            <div className="score-item">
              <span>Formatting</span>
              <div className="score-bar">
                <div className="score-fill" style={{ width: `${score.breakdown.formattingScore}%` }}></div>
              </div>
              <span>{score.breakdown.formattingScore}%</span>
            </div>
            <div className="score-item">
              <span>Content Relevance</span>
              <div className="score-bar">
                <div className="score-fill" style={{ width: `${score.breakdown.contentRelevance}%` }}></div>
              </div>
              <span>{score.breakdown.contentRelevance}%</span>
            </div>
          </div>

          {score.missingKeywords.length > 0 && (
            <div className="missing-keywords">
              <h4>Missing Keywords</h4>
              <div className="keyword-tags">
                {score.missingKeywords.map((kw, i) => (
                  <span key={i} className="keyword-tag missing">{kw}</span>
                ))}
              </div>
            </div>
          )}

          {score.suggestions.length > 0 && (
            <div className="suggestions">
              <h4>Improvement Suggestions</h4>
              <ul>
                {score.suggestions.map((suggestion, i) => (
                  <li key={i}>{suggestion}</li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

export default AtsScorePanel;

What does this component do?

  • It provides a textarea for the user to paste a job description.
  • handleCheck validates the input is not empty, then calls the parent's onCheck prop (which triggers the API call in App.jsx).
  • When a score exists, it renders a large number display, three progress bars (keyword match, formatting, content relevance), a list of missing keywords, and actionable suggestions.
  • Save AtsScorePanel.jsx.
  • Switch back to your browser. The Vite dev server should now render your app without errors.
  • You should see a dark header with "AI Resume Builder", a **Save Resume** button, a **Download PDF** button, and three tab buttons: **Editor**, **Preview**, and **ATS Score**.

Still seeing a blank page or error?

Check your browser console for the specific error. If it mentions a missing module, verify the file paths match exactly: ./components/ResumeForm, ./components/ResumePreview, and ./components/AtsScorePanel.

Make sure all three component files are inside client/src/components/ and each file exports a default function.

Help me fix a module not found error in my Vite React project.

To test the full flow, make sure your backend server is still running on port 5000. Fill in some resume data in the Editor tab, click **Save Resume**, then try clicking **Download PDF**. A PDF file should download to your machine.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full App.jsx:

import { useState, useEffect } from 'react';
import ResumeForm from './components/ResumeForm';
import ResumePreview from './components/ResumePreview';
import AtsScorePanel from './components/AtsScorePanel';
import axios from 'axios';

const API_URL = 'http://localhost:5000/api';

const emptyResume = {
  personalInfo: { fullName: '', email: '', phone: '', location: '', linkedin: '', website: '' },
  summary: '',
  experience: [{ title: '', company: '', startDate: '', endDate: '', description: '' }],
  education: [{ degree: '', institution: '', graduationDate: '', gpa: '' }],
  skills: [],
  templateId: 'classic'
};

function App() {
  const [resume, setResume] = useState(emptyResume);
  const [activeTab, setActiveTab] = useState('editor');
  const [savedId, setSavedId] = useState(null);
  const [loading, setLoading] = useState(false);
  const [atsScore, setAtsScore] = useState(null);

  const handleSave = async () => {
    setLoading(true);
    try {
      if (savedId) {
        await axios.put(`${API_URL}/resumes/${savedId}`, resume);
      } else {
        const response = await axios.post(`${API_URL}/resumes`, resume);
        setSavedId(response.data._id);
      }
      alert('Resume saved successfully!');
    } catch (error) {
      alert('Error saving resume: ' + error.message);
    }
    setLoading(false);
  };

  const handleGenerateSummary = async () => {
    setLoading(true);
    try {
      const response = await axios.post(`${API_URL}/ai/generate-summary`, {
        experience: resume.experience,
        skills: resume.skills,
        targetRole: resume.personalInfo.fullName ? `${resume.experience[0]?.title || 'Professional'}` : ''
      });
      setResume(prev => ({ ...prev, summary: response.data.summary }));
    } catch (error) {
      alert('Error generating summary: ' + error.message);
    }
    setLoading(false);
  };

  const handleAtsCheck = async (jobDescription) => {
    setLoading(true);
    try {
      const response = await axios.post(`${API_URL}/ai/ats-score`, {
        resume,
        jobDescription
      });
      setAtsScore(response.data);
    } catch (error) {
      alert('Error checking ATS score: ' + error.message);
    }
    setLoading(false);
  };

  const handleDownloadPdf = async () => {
    if (!savedId) {
      alert('Please save your resume first!');
      return;
    }
    try {
      const response = await axios.get(`${API_URL}/pdf/${savedId}`, {
        responseType: 'blob'
      });
      const url = window.URL.createObjectURL(new Blob([response.data]));
      const link = document.createElement('a');
      link.href = url;
      link.setAttribute('download', `${resume.personalInfo.fullName || 'Resume'}.pdf`);
      document.body.appendChild(link);
      link.click();
      link.remove();
      window.URL.revokeObjectURL(url);
    } catch (error) {
      alert('Error downloading PDF: ' + error.message);
    }
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>AI Resume Builder</h1>
        <div className="header-actions">
          <button onClick={handleSave} disabled={loading}>
            {loading ? 'Saving...' : 'Save Resume'}
          </button>
          <button onClick={handleDownloadPdf} disabled={!savedId}>
            Download PDF
          </button>
        </div>
      </header>

      <nav className="tabs">
        <button className={activeTab === 'editor' ? 'active' : ''} onClick={() => setActiveTab('editor')}>Editor</button>
        <button className={activeTab === 'preview' ? 'active' : ''} onClick={() => setActiveTab('preview')}>Preview</button>
        <button className={activeTab === 'ats' ? 'active' : ''} onClick={() => setActiveTab('ats')}>ATS Score</button>
      </nav>

      <main className="main-content">
        {activeTab === 'editor' && (
          <ResumeForm
            resume={resume}
            setResume={setResume}
            onGenerateSummary={handleGenerateSummary}
            loading={loading}
          />
        )}
        {activeTab === 'preview' && (
          <ResumePreview resume={resume} />
        )}
        {activeTab === 'ats' && (
          <AtsScorePanel
            onCheck={handleAtsCheck}
            score={atsScore}
            loading={loading}
          />
        )}
      </main>
    </div>
  );
}

export default App;

And the full AtsScorePanel.jsx:

import { useState } from 'react';

function AtsScorePanel({ onCheck, score, loading }) {
  const [jobDescription, setJobDescription] = useState('');

  const handleCheck = () => {
    if (!jobDescription.trim()) {
      alert('Please enter a job description');
      return;
    }
    onCheck(jobDescription);
  };

  return (
    <div className="ats-panel">
      <h3>ATS Score Checker</h3>
      <p className="ats-description">
        Paste a job description below to see how well your resume matches the requirements.
      </p>

      <textarea
        placeholder="Paste the job description here..."
        value={jobDescription}
        onChange={(e) => setJobDescription(e.target.value)}
        rows={8}
      />

      <button onClick={handleCheck} disabled={loading} className="ai-button">
        {loading ? 'Analyzing...' : '\uD83C\uDFAF Check ATS Score'}
      </button>

      {score && (
        <div className="ats-results">
          <div className="score-circle">
            <span className="score-number">{score.overallScore}</span>
            <span className="score-label">/ 100</span>
          </div>

          <div className="score-breakdown">
            <h4>Breakdown</h4>
            <div className="score-item">
              <span>Keyword Match</span>
              <div className="score-bar">
                <div className="score-fill" style={{ width: `${score.breakdown.keywordMatch}%` }}></div>
              </div>
              <span>{score.breakdown.keywordMatch}%</span>
            </div>
            <div className="score-item">
              <span>Formatting</span>
              <div className="score-bar">
                <div className="score-fill" style={{ width: `${score.breakdown.formattingScore}%` }}></div>
              </div>
              <span>{score.breakdown.formattingScore}%</span>
            </div>
            <div className="score-item">
              <span>Content Relevance</span>
              <div className="score-bar">
                <div className="score-fill" style={{ width: `${score.breakdown.contentRelevance}%` }}></div>
              </div>
              <span>{score.breakdown.contentRelevance}%</span>
            </div>
          </div>

          {score.missingKeywords.length > 0 && (
            <div className="missing-keywords">
              <h4>Missing Keywords</h4>
              <div className="keyword-tags">
                {score.missingKeywords.map((kw, i) => (
                  <span key={i} className="keyword-tag missing">{kw}</span>
                ))}
              </div>
            </div>
          )}

          {score.suggestions.length > 0 && (
            <div className="suggestions">
              <h4>Improvement Suggestions</h4>
              <ul>
                {score.suggestions.map((suggestion, i) => (
                  <li key={i}>{suggestion}</li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

export default AtsScorePanel;

Your full-stack AI Resume Builder is now complete. The frontend can generate AI summaries, check ATS scores with visual breakdowns, and download polished PDF resumes. Next up, the Secret Mission will push this further with a personalized Cover Letter Generator.

Secret mission

Cover Letter Generator

Your resume builder can generate summaries and check ATS scores. But what about cover letters? In this secret mission, you'll add a Cover Letter Generator that crafts personalized cover letters by combining your resume data with a target job description.

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 a free MongoDB Atlas cluster and a pay-per-use OpenAI API key, so ongoing costs are minimal.

Cost warning

Your MongoDB Atlas free cluster costs nothing to keep running. However, your OpenAI API key is pay-per-use. Every API call you make (generating summaries, checking ATS scores, creating cover letters) incurs a small charge.

If you leave applications running that make API calls, you will be billed. Delete or pause to avoid unexpected charges.

Resources you used:

  • MongoDB Atlas free cluster (cloud database).
  • OpenAI API key (pay-per-use AI service).
  • Local project files (server/ and client/ directories on your Desktop).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the resume builder.

  • Your MongoDB Atlas free cluster stays active indefinitely at no cost. It auto-pauses after 30 days of inactivity and resumes when you reconnect.
  • Your OpenAI API key only incurs charges when you make API calls. No calls means no cost.
  • Your local project files remain on your Desktop ready to use.

Pause - I'll come back to this later

Stop running processes to free up system resources. Your files and cloud resources stay intact.

  • In your terminal, press Ctrl+C in each terminal window to stop both the backend server and the Vite dev server.
  • Your MongoDB Atlas free cluster auto-pauses after 30 days of inactivity. No manual action needed.
  • Your OpenAI API key stays active but incurs zero charges while you are not making calls.
  • Your local project files stay on your Desktop for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Stop both running servers by pressing Ctrl+C in each terminal window.
  • Delete your MongoDB Atlas cluster. Go to your Atlas dashboard at cloud.mongodb.com. On the Clusters page, click the ellipsis next to your cluster. Click Terminate. Enter the cluster name to confirm, then click Terminate again.
  • Delete your OpenAI API key. Go to platform.openai.com/api-keys. Click the trashcan icon next to the API key you created for this project.
  • Delete your local project files by running this command:
cd ~/Desktop
rm -rf ai-resume-builder

On Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force ai-resume-builder

Nice Work!

Nice Work!

You just built a full-stack AI Resume Builder from scratch. This application combines a modern React frontend, a Node.js/Express backend, a MongoDB database, and OpenAI-powered intelligence into a single cohesive system.

Here's what you learned:

  • Built a complete Express.js REST API with MongoDB for creating, reading, updating, and deleting resume data.
  • Integrated OpenAI's Chat Completions API to generate professional summaries, enhance bullet points, and analyze resumes against job descriptions with an ATS scoring system.
  • Created a React frontend with a multi-section resume form, live preview, and server-side PDF generation using Puppeteer for polished resume downloads.
  • Added a Cover Letter Generator that crafts personalized cover letters from resume data and job descriptions.

Ready to quiz yourself?