DEV Community

Cover image for Build a Keyword-Based Resume Analyzer with FastAPI and React
Prakrati Pawar
Prakrati Pawar

Posted on AI-assisted

Build a Keyword-Based Resume Analyzer with FastAPI and React

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:

  1. The user creates an account and logs in.
  2. FastAPI returns a JWT access token.
  3. The frontend sends a PDF resume with that token.
  4. The backend validates the PDF, extracts its text, and stores the record.
  5. The user submits a job description.
  6. 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
Enter fullscreen mode Exit fullscreen mode

On Windows PowerShell, create and activate a Python 3.12 virtual environment:

py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
Enter fullscreen mode Exit fullscreen mode

On macOS or Linux, use:

python3 -m venv .venv
source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install the packages:

python -m pip install --upgrade pip
python -m pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

2. Configure PostgreSQL

Create the database using pgAdmin or SQL:

CREATE DATABASE resume_analyzer;
Enter fullscreen mode Exit fullscreen mode

Create this backend structure:

backend/
├── app/
│   ├── api/v1/
│   ├── core/
│   ├── models/
│   ├── schemas/
│   ├── services/
│   └── utils/
├── tests/
├── .env
├── .env.example
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

Add an empty __init__.py file to each Python package directory.

Generate a JWT secret:

python -c "import secrets; print(secrets.token_urlsafe(32))"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
    })
Enter fullscreen mode Exit fullscreen mode

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."
        ),
    }
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Set the API URL in frontend/.env:

VITE_API_URL=http://127.0.0.1:8000/api/v1
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

  1. Create a test account and log in.
  2. Upload a fictional, text-based PDF resume smaller than 5 MB.
  3. Paste a job description containing known skills.
  4. Confirm that matched and missing skills are correct.
  5. Try an invalid file and confirm that the API rejects it.
  6. Try a job description without recognized skills and confirm a 0% result.

Run the backend unit tests:

python -m unittest discover -s tests -v
Enter fullscreen mode Exit fullscreen mode

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)