DEV Community

Cover image for Building JobFit AI, an AI Resume Matcher with the MERN Stack
SAVITA WADJE
SAVITA WADJE

Posted on

Building JobFit AI, an AI Resume Matcher with the MERN Stack

Job hunting means rewriting the same resume bullet points for every posting and guessing whether you're even a good match. So I built JobFit AI: paste your resume and a job description, and it scores the match, tells you what's missing, and rewrites bullet points to fit the role, powered by the Gemini API, with your history tracked over time.

Here's how it's built, how it works, and how it might help you if you're job hunting too.

The problem I was solving

Every job application starts the same way. Copy the resume, tweak a few lines, guess if the keywords match what the recruiter is looking for, submit, repeat. I was doing this manually dozens of times and had no real way to know if my resume actually matched a posting or just felt like it did.

That is basically the whole reason JobFit AI exists.

Tech stack

  • Frontend: React 19 + Vite, React Router, Tailwind CSS v4
  • Backend: Node.js + Express, MongoDB with Mongoose
  • Auth: JWT + bcrypt, email/password, own database
  • AI: Google Gemini API (free tier)

Architecture

Three layers, kept deliberately separate:
1. Client (React)
Handles the resume/job description input, displays the match score and suggestions, and manages auth state. Kept as dumb as possible, all the real logic lives in the backend.

2. API layer (Express)
Handles auth, request validation, and talks to both MongoDB and the Gemini API. Each route is scoped tightly, one endpoint to submit a match request, one to fetch history, one to handle auth.

3. Data layer (MongoDB + Mongoose)
Stores user accounts and a history of every resume/job match a user has run, so they can go back and compare results over time instead of losing them after one session.

// simplified match route
router.post("/match", authMiddleware, async (req, res) => {
  const { resumeText, jobDescription } = req.body;

  const prompt = buildPrompt(resumeText, jobDescription);
  const aiResponse = await geminiClient.generateContent(prompt);

  const result = parseMatchResult(aiResponse);

  await MatchHistory.create({
    userId: req.user.id,
    resumeText,
    jobDescription,
    score: result.score,
    suggestions: result.suggestions,
  });

  res.json(result);
});
Enter fullscreen mode Exit fullscreen mode

How the matching actually works

The core of the app is a single prompt sent to Gemini that asks it to compare the resume against the job description and return three things: a match score, a list of missing skills or keywords, and rewritten bullet points that better align with the role.

function buildPrompt(resumeText, jobDescription) {
  return `
    Compare this resume against the job description below.
    Return a match score out of 100, a list of missing keywords,
    and 3 rewritten resume bullet points tailored to this job.

    Resume:
    ${resumeText}

    Job Description:
    ${jobDescription}
  `;
}
Enter fullscreen mode Exit fullscreen mode

The tricky part was not the prompt itself, it was getting Gemini to return something consistently structured that I could reliably parse into the UI, instead of a wall of freeform text.

Challenges I ran into

  • Structuring AI output reliably. Free-text responses looked fine but broke my UI whenever the format shifted slightly. I ended up being very explicit in the prompt about the exact structure I wanted back.
  • Rate limits on the free tier. Had to add basic request throttling on my end so users could not spam the match endpoint and burn through the quota.
  • Keeping history without bloating the database. Storing every resume and job description per request adds up fast, so I trimmed what gets stored long term versus what is just used for the immediate response.

What's next

  • Better diffing between resume versions over time
  • Exporting rewritten bullet points directly into a formatted resume
  • Support for multiple resume profiles per user

Try it / see the code
๐Ÿ”—

Top comments (0)