Job seekers often compare their resumes with job descriptions to understand which technical skills overlap and which ones are missing. Doing this manually becomes repetitive when applying for several roles. In this tutorial, you will build a full-stack application that performs this comparison using transparent, rule-based keyword matching.
The application lets a user sign up, log in, upload a PDF resume, and paste a job description. It extracts recognized technical skills from both texts and returns the matched skills, missing skills, and a keyword-match percentage.
This project deliberately does not call the result an ATS score or an AI prediction. The percentage measures keyword overlap only, so the calculation remains deterministic and explainable.
The complete source code is available in the Resume Analyzer GitHub repository.
Application Preview
Upload a Resume
Enter a Job Description
Review the Keyword Match
What You Will Build
The application contains three layers:
- A React frontend for authentication, PDF upload, job-description input, and results
- A FastAPI backend for JWT authentication, file validation, PDF parsing, and keyword matching
- A PostgreSQL database for users and resume information
The request flow is straightforward:
- The user creates an account and logs in.
- FastAPI returns a JWT access token.
- The frontend sends a PDF resume with that token.
- The backend validates the PDF, extracts its text, and stores the record.
- The user submits a job description.
- The backend compares recognized skills and returns the result.
Prerequisites
You will need:
- Python 3.10–3.12; Python 3.12 is recommended
- Node.js 20 or newer
- PostgreSQL 14 or newer
- npm
- Basic familiarity with Python, React, REST APIs, and SQL
The pinned dependencies used by this project may not install correctly on Python 3.14.
1. Set Up the Backend
Create the project directories:
mkdir resume-analyzer
cd resume-analyzer
mkdir backend frontend
cd backend
On Windows PowerShell, create and activate a Python 3.12 virtual environment:
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
On macOS or Linux, use:
python3 -m venv .venv
source .venv/bin/activate
Create requirements.txt:
fastapi==0.110.0
uvicorn==0.27.1
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-jose==3.3.0
python-multipart==0.0.9
pydantic==2.6.1
python-dotenv==1.0.1
pdfplumber==0.10.3
email-validator==2.1.0.post1
Install the packages:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
2. Configure PostgreSQL
Create the database using pgAdmin or SQL:
CREATE DATABASE resume_analyzer;
Create this backend structure:
backend/
├── app/
│ ├── api/v1/
│ ├── core/
│ ├── models/
│ ├── schemas/
│ ├── services/
│ └── utils/
├── tests/
├── .env
├── .env.example
└── requirements.txt
Add an empty __init__.py file to each Python package directory.
Generate a JWT secret:
python -c "import secrets; print(secrets.token_urlsafe(32))"
Add the configuration to backend/.env:
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@localhost:5432/resume_analyzer
SECRET_KEY=PASTE_YOUR_GENERATED_SECRET
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
FRONTEND_URL=http://localhost:5173
Never commit this file. Put safe placeholders in .env.example, and add .env, .venv, uploads, and __pycache__ to backend/.gitignore.
Load the environment variables in app/core/config.py:
import os
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(
os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 30)
)
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
Create the SQLAlchemy connection in app/core/database.py:
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from app.core.config import DATABASE_URL
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
Base = declarative_base()
def get_db():
database = SessionLocal()
try:
yield database
finally:
database.close()
The full repository contains the User, Resume, and Job SQLAlchemy models. The important ownership field is Resume.user_email, which associates every uploaded resume with its authenticated user.
3. Add JWT Authentication
Use Passlib to hash passwords and python-jose to create access tokens. The essential functions in app/core/security.py are:
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import (
ACCESS_TOKEN_EXPIRE_MINUTES,
ALGORITHM,
SECRET_KEY,
)
security = HTTPBearer()
password_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return password_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_context.verify(plain_password, hashed_password)
def create_access_token(data: dict) -> str:
token_data = data.copy()
token_data["exp"] = datetime.utcnow() + timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
)
return jwt.encode(token_data, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
try:
payload = jwt.decode(
credentials.credentials,
SECRET_KEY,
algorithms=[ALGORITHM],
)
email = payload.get("sub")
if not email:
raise HTTPException(status_code=401, detail="Invalid token")
return email
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
The signup route hashes the password before saving it. The login route verifies that hash and returns a token containing the email in its sub claim. Protected routes receive the email through Depends(verify_token).
4. Extract Text from PDF Resumes
Create app/utils/resume_parser.py:
import pdfplumber
def extract_text_from_pdf(file_path: str) -> str:
extracted_text = ""
with pdfplumber.open(file_path) as pdf:
for page in pdf.pages:
extracted_text += (page.extract_text() or "") + "\n"
return extracted_text.strip()
Using page.extract_text() or "" prevents an error when a page has no readable text. This approach works with text-based PDFs. Scanned resumes require optical character recognition, which is outside this tutorial's scope.
5. Implement Explainable Keyword Matching
The matcher uses an explicit dictionary instead of treating every word as a skill. It can also normalize aliases:
import re
SKILL_ALIASES = {
"amazon web services": "aws",
"aws": "aws",
"ci/cd": "ci/cd",
"continuous integration": "ci/cd",
"django": "django",
"docker": "docker",
"fastapi": "fastapi",
"git": "git",
"javascript": "javascript",
"kubernetes": "kubernetes",
"postgres": "postgresql",
"postgresql": "postgresql",
"python": "python",
"react": "react",
"rest api": "rest api",
"rest apis": "rest api",
"sql": "sql",
"sqlalchemy": "sqlalchemy",
"typescript": "typescript",
}
def contains_keyword(text: str, keyword: str) -> bool:
pattern = rf"(?<![a-z0-9]){re.escape(keyword)}(?![a-z0-9])"
return re.search(pattern, text, flags=re.IGNORECASE) is not None
def extract_skills(text: str) -> list[str]:
if not text:
return []
return sorted({
normalized
for keyword, normalized in SKILL_ALIASES.items()
if contains_keyword(text, keyword)
})
The boundary checks stop a shorter keyword such as java from matching inside javascript.
Now compare the two skill sets:
def match_resume_with_job(resume_text: str, job_text: str) -> dict:
resume_skills = set(extract_skills(resume_text))
job_skills = set(extract_skills(job_text))
matched = sorted(resume_skills & job_skills)
missing = sorted(job_skills - resume_skills)
percentage = (
round(len(matched) / len(job_skills) * 100, 2)
if job_skills else 0
)
return {
"resume_skills": sorted(resume_skills),
"job_skills": sorted(job_skills),
"matched_skills": matched,
"missing_skills": missing,
"match_percentage": percentage,
"disclaimer": (
"This percentage measures keyword overlap only; "
"it is not an ATS or hiring prediction."
),
}
If a job description contains 11 recognized skills and the resume contains 9 of them, the result is 9 / 11 × 100 = 81.82%.
6. Validate PDF Uploads and Enforce Ownership
An HTML file picker is not a security control because clients can call the API directly. The backend checks the content type, extension, size, and PDF signature before processing a file:
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import verify_token
router = APIRouter()
UPLOAD_DIRECTORY = Path(__file__).resolve().parents[3] / "uploads"
UPLOAD_DIRECTORY.mkdir(parents=True, exist_ok=True)
MAX_FILE_SIZE = 5 * 1024 * 1024
@router.post("/upload-resume")
def upload_resume(
file: UploadFile = File(...),
email: str = Depends(verify_token),
database: Session = Depends(get_db),
):
original_name = Path(file.filename or "").name
if (
file.content_type != "application/pdf"
or Path(original_name).suffix.lower() != ".pdf"
):
raise HTTPException(400, "Only PDF files are accepted")
content = file.file.read(MAX_FILE_SIZE + 1)
if len(content) > MAX_FILE_SIZE:
raise HTTPException(413, "PDF must be 5 MB or smaller")
if not content.startswith(b"%PDF"):
raise HTTPException(400, "The uploaded file is not a valid PDF")
file_path = UPLOAD_DIRECTORY / f"{uuid4().hex}.pdf"
file_path.write_bytes(content)
# Extract the text, create a Resume model, and save it with user_email=email.
The generated UUID prevents duplicate filenames from overwriting each other. The job-matching route must query by both resume ID and authenticated email:
resume = (
database.query(Resume)
.filter(
Resume.id == request.resume_id,
Resume.user_email == email,
)
.first()
)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
Without the email condition, one user could attempt to access another user's resume by changing the numeric ID.
7. Connect the FastAPI Application
In app/main.py, create the tables, enable CORS, and register the routers:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import auth, jobs, resume
from app.core.config import FRONTEND_URL
from app.core.database import Base, engine
from app.models import job as job_model
from app.models import resume as resume_model
from app.models import user as user_model
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Keyword-Based Resume Analyzer API")
app.add_middleware(
CORSMiddleware,
allow_origins=[FRONTEND_URL],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1", tags=["authentication"])
app.include_router(resume.router, prefix="/api/v1", tags=["resume"])
app.include_router(jobs.router, prefix="/api/v1", tags=["job matching"])
The model aliases avoid overwriting the API module named resume.
Start the backend:
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
If Windows blocks port 8000, use port 8001. Open http://127.0.0.1:8000/docs to inspect the API.
8. Connect the React Frontend
Create the Vite application inside the frontend directory:
cd ../frontend
npm create vite@latest . -- --template react-ts
npm install
npm install axios react-router-dom
Set the API URL in frontend/.env:
VITE_API_URL=http://127.0.0.1:8000/api/v1
Create an Axios client that adds the stored JWT:
import axios from "axios";
const API = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
API.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default API;
The upload page sends a FormData object and stores the returned resume ID:
const formData = new FormData();
formData.append("file", file);
const response = await API.post("/upload-resume", formData);
localStorage.setItem("resume_id", response.data.resume_id);
navigate("/jd-match");
The matching page submits that ID with the job description:
const response = await API.post("/match-jd", {
resume_id: localStorage.getItem("resume_id"),
job_description: jobDescription,
});
setResult(response.data);
Render matched_skills, missing_skills, and match_percentage from the response. Recommendations should never tell users to claim skills they do not possess. A safer message is: “If you genuinely have experience with a missing skill, mention it clearly. Otherwise, consider learning it if it is relevant to your target role.”
The repository contains the complete styled React components for signup, login, upload, matching, and results.
9. Test the Application
Start the backend and frontend in separate terminals:
# backend
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
# frontend
npm run dev
Then:
- Create a test account and log in.
- Upload a fictional, text-based PDF resume smaller than 5 MB.
- Paste a job description containing known skills.
- Confirm that matched and missing skills are correct.
- Try an invalid file and confirm that the API rejects it.
- Try a job description without recognized skills and confirm a 0% result.
Run the backend unit tests:
python -m unittest discover -s tests -v
The project tests alias normalization, partial-word protection, percentage calculation, empty job descriptions, and explainable feedback.
Limitations
This implementation has deliberate limitations:
- It recognizes only skills included in the catalog.
- It does not understand context, proficiency, or years of experience.
- It cannot reliably extract text from image-only PDFs.
- Keyword overlap does not determine whether a candidate is qualified.
- For production, authentication tokens should be stored in secure HTTP-only cookies rather than local storage.
- Production deployments should use database migrations and private object storage instead of local uploads.
These limitations are why the interface uses “Keyword Match” rather than “ATS Score.”
Conclusion
You built a full-stack resume analyzer using FastAPI, React, and PostgreSQL. The application authenticates users, validates PDF uploads, extracts resume text, recognizes technical skills through an explicit catalog, and calculates a transparent keyword-overlap percentage.
The rule-based design is useful because every match can be traced to a known keyword. You can extend the project by moving the catalog into the database, adding an admin interface, supporting OCR, introducing NLP-based section detection, and deploying the application with Docker.
Most importantly, keep the result honest: it can help users compare wording and identify learning opportunities, but it should never be presented as a hiring decision or a prediction of ATS success.



Top comments (0)