Current Section

Overview

0%

← Back to Real Projects
Real Projects · Project 1

Build an AI Resume Analyzer

Build a production-grade HR tech application that compares candidate resumes against target job descriptions, computes match percentages, identifies missing skill gaps, and generates tailored resume improvement recommendations using Zod-enforced JSON outputs.

Next.js App RouterSkill Match ScoringStructured JSON OutputProduction Recipe
resume-analyzer-preview.tsx
Live UI Preview

Analysis Parameters

Seeking Senior AI Engineer with Python, Next.js, pgvector, and AWS experience.
Software Engineer with 5 years experience in Python, Flask, AWS Lambda, and PostgreSQL...
Analyze Match Score ⚡

Match Results

82% Match
Matched Skills
PythonAWSPostgreSQL
Missing Skills
Next.jspgvector
AIMates Hands-On Lab

Want to Test Resume Matching Live?

Launch our pre-configured sandbox with ready-to-run Zod schema matching routes and score breakdown UI cards.

Launch Sandbox Lab →

The 30-Second Recipe

An AI resume analyzer bridges unstructured candidate histories and job requirements through deterministic skill matching.

Instead of manually scanning resumes, your backend route accepts candidate text and job descriptions, passes them through a Zod-constrained LLM schema parser, and calculates match percentages, matched skill arrays, missing skill gaps, and tailored resume improvement advice.

Resume & JD → POST /api/analyze-resume → Zod Schema Extraction → Score & Gap UI Cards

Topology

End-to-End System Architecture

Here is how data flows from candidate text submission to structured recruiter insights:

01

Resume & JD Ingestion

User inputs candidate resume text/PDF alongside the target job description requirements.

02

Payload Sanitization

Backend cleans text buffers, ensuring token counts stay safely within context boundaries.

03

Structured JSON Inference

Model analyzes alignment under strict Zod schema constraints, outputting match scores, strengths, and missing skills.

04

Schema Parsing & Validation

Backend validates the generated JSON payload, guaranteeing separate fields for score and recommendations.

05

Modular UI Workspace

Frontend renders formatted score dials, matched skill chips, missing skills, and resume fix suggestions.

Step 1 · Backend Infrastructure

The Structured Resume Match API

Create the backend route handler at app/api/analyze-resume/route.ts. It uses OpenAI Structured Outputs and Zod to compute match percentages and skill gaps:

app/api/analyze-resume/route.ts
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";

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

const ResumeAnalysisSchema = z.object({
  matchScore: z.number().describe("Percentage score from 0 to 100 representing overall role alignment"),
  matchedSkills: z.array(z.string()).describe("Skills present in both the resume and job description"),
  missingSkills: z.array(z.string()).describe("Required job description skills absent from the resume"),
  candidateStrengths: z.array(z.string()).describe("Key candidate experience highlights relevant to the role"),
  improvementSuggestions: z.array(z.string()).describe("Actionable advice to improve resume positioning for this role"),
  summaryVerdict: z.string().describe("A 2-sentence final recommendation for recruiters"),
});

export async function POST(req: Request) {
  try {
    const { resume, jobDescription } = await req.json();

    if (!resume || !jobDescription) {
      return new Response(JSON.stringify({ error: "Resume and job description are required" }), { status: 400 });
    }

    const completion = await openai.beta.chat.completions.parse({
      model: "gpt-4o",
      messages: [
        {
          role: "system",
          content: "You are an elite technical recruiter and HR tech AI. Compare candidate resumes against job descriptions with objective accuracy.",
        },
        {
          role: "user",
          content: `Job Description:\n${jobDescription}\n\nCandidate Resume:\n${resume}`,
        },
      ],
      response_format: zodResponseFormat(ResumeAnalysisSchema, "resume_analysis"),
    });

    const result = completion.choices[0].message.parsed;
    return new Response(JSON.stringify(result), {
      headers: { "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Resume analysis error:", error);
    return new Response(JSON.stringify({ error: "Failed to analyze resume" }), { status: 500 });
  }
}

Step 2 · Frontend Implementation

The Interactive Workspace UI

Create the client workspace component at components/tools/ResumeAnalyzer.tsx to render match scores, skill pills, and recruiter verdicts:

components/tools/ResumeAnalyzer.tsx
"use client";

import { useState } from "react";

interface AnalysisResult {
  matchScore: number;
  matchedSkills: string[];
  missingSkills: string[];
  candidateStrengths: string[];
  improvementSuggestions: string[];
  summaryVerdict: string;
}

export default function ResumeAnalyzer() {
  const [jobDescription, setJobDescription] = useState("");
  const [resume, setResume] = useState("");
  const [loading, setLoading] = useState(false);
  const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);

  const handleAnalyze = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!jobDescription.trim() || !resume.trim() || loading) return;

    setLoading(true);
    try {
      const res = await fetch("/api/analyze-resume", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ resume, jobDescription }),
      });
      const data = await res.json();
      setAnalysis(data);
    } catch (err) {
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="space-y-8">
      <form onSubmit={handleAnalyze} className="space-y-4 p-6 border border-[var(--app-border)] rounded-2xl bg-[var(--app-card)] shadow-sm">
        <h3 className="text-sm font-black text-[var(--app-text)]">Configure Match Parameters</h3>
        <div>
          <label className="text-xs text-[var(--app-muted)] block mb-1">Target Job Description</label>
          <textarea
            value={jobDescription}
            onChange={(e) => setJobDescription(e.target.value)}
            placeholder="Paste job requirements and tech stack..."
            className="w-full bg-[var(--app-bg)] border border-[var(--app-border)] rounded-xl p-3 text-xs text-[var(--app-text)] h-32 focus:ring-1 focus:ring-amber-500 font-mono"
          />
        </div>
        <div>
          <label className="text-xs text-[var(--app-muted)] block mb-1">Candidate Resume Text</label>
          <textarea
            value={resume}
            onChange={(e) => setResume(e.target.value)}
            placeholder="Paste candidate resume text here..."
            className="w-full bg-[var(--app-bg)] border border-[var(--app-border)] rounded-xl p-3 text-xs text-[var(--app-text)] h-32 focus:ring-1 focus:ring-amber-500 font-mono"
          />
        </div>
        <button
          type="submit"
          disabled={loading || !jobDescription.trim() || !resume.trim()}
          className="w-full bg-amber-500 hover:bg-amber-400 disabled:opacity-50 text-slate-950 font-bold py-3 rounded-xl text-xs transition shadow-sm"
        >
          {loading ? "Comparing Resume & Job Specs..." : "Analyze Candidate Match ⚡"}
        </button>
      </form>

      {analysis && (
        <div className="space-y-6">
          {/* Score & Verdict Card */}
          <div className="p-6 border border-[var(--app-border)] rounded-2xl bg-[var(--app-card)] flex flex-col sm:flex-row items-center justify-between gap-6 shadow-sm">
            <div className="space-y-1 text-center sm:text-left">
              <h4 className="text-xs font-bold text-amber-600 dark:text-amber-400 uppercase tracking-wider">Recruiter Verdict</h4>
              <p className="text-xs leading-relaxed text-[var(--app-text)]">{analysis.summaryVerdict}</p>
            </div>
            <div className="shrink-0 flex flex-col items-center justify-center p-4 rounded-2xl bg-amber-500/10 border border-amber-500/30 w-28">
              <span className="text-2xl font-black text-amber-600 dark:text-amber-400">{analysis.matchScore}%</span>
              <span className="text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">Match Score</span>
            </div>
          </div>

          {/* Skills Grid */}
          <div className="grid gap-6 md:grid-cols-2">
            <div className="p-6 border border-[var(--app-border)] rounded-2xl bg-[var(--app-card)] space-y-3 shadow-sm">
              <h4 className="text-xs font-bold text-emerald-600 dark:text-emerald-400 uppercase tracking-wider">Matched Skills</h4>
              <div className="flex flex-wrap gap-1.5">
                {analysis.matchedSkills.map((skill, idx) => (
                  <span key={idx} className="text-[11px] bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 px-2.5 py-1 rounded-lg font-mono">
                    {skill}
                  </span>
                ))}
              </div>
            </div>

            <div className="p-6 border border-[var(--app-border)] rounded-2xl bg-[var(--app-card)] space-y-3 shadow-sm">
              <h4 className="text-xs font-bold text-rose-600 dark:text-rose-400 uppercase tracking-wider">Missing Skills (Gaps)</h4>
              <div className="flex flex-wrap gap-1.5">
                {analysis.missingSkills.map((skill, idx) => (
                  <span key={idx} className="text-[11px] bg-rose-500/10 text-rose-700 dark:text-rose-300 px-2.5 py-1 rounded-lg font-mono">
                    {skill}
                  </span>
                ))}
              </div>
            </div>
          </div>

          {/* Improvement Suggestions */}
          <div className="p-6 border border-[var(--app-border)] rounded-2xl bg-[var(--app-card)] space-y-3 shadow-sm">
            <h4 className="text-xs font-bold text-sky-600 dark:text-sky-400 uppercase tracking-wider">Resume Improvement Advice</h4>
            <ul className="space-y-2 text-xs text-[var(--app-text-secondary)]">
              {analysis.improvementSuggestions.map((tip, idx) => (
                <li key={idx} className="flex gap-2">
                  <span className="text-sky-500 font-bold">&rarr;</span>
                  <span>{tip}</span>
                </li>
              ))}
            </ul>
          </div>
        </div>
      )}
    </div>
  );
}

Evaluation Metrics

Designing Objective AI Match Scoring

Subjective scoring leads to unpredictable HR tool behavior. Production resume matchers evaluate candidates across three weighted criteria:

1. Core Tech Stack (40%)

Presence of required programming languages, frameworks, databases, and cloud tools explicitly listed in the job description.

2. Seniority & Scope (35%)

Years of experience, leadership scope, and architectural responsibility matching the role's seniority level.

3. Impact Metrics (25%)

Quantitative evidence of past results (e.g., "reduced latency by 40%", "scaled to 10M users") in bullet points.

Level Up

Hands-On Build Challenges

Ready to take this resume analyzer to production? Implement these three enhancements:

Challenge 1: PDF Resume Upload

Integrate pdf-parse so users can upload actual PDF resume files instead of pasting raw text.

Challenge 2: Applicant Leaderboard

Build a recruiter dashboard that ranks 10 candidate resumes against a single job description automatically.

Challenge 3: One-Click Rewrite

Add a button that automatically rewrites the candidate's resume bullet points to highlight missing skills.

Release Gate

Production Resume Analyzer Checklist

✓Resumes and job descriptions are validated at the edge to prevent empty submissions.
✓AI output is structured via Zod JSON schemas, guaranteeing reliable frontend card rendering without parse errors.
✓UI provides clear match score metrics, matched skill badges, and missing skill warnings.
✓Token costs are managed by pruning redundant text before invoking the LLM model endpoint.
✓Error boundaries gracefully catch provider rate limits or malformed input payloads.
✓API keys are sequestered safely in server environment variables, never exposed to browser bundles.
✓Generated suggestions provide actionable resume improvements to guide candidate optimization.

Key Takeaways

HR tech automation turns subjective hiring into objective skill matching.

Building an AI resume analyzer teaches you how to compare unstructured candidate histories against strict job descriptions. By leveraging Zod schema validation and clean scorecard UI components, you deliver an exceptionally valuable product for job seekers and recruiters alike.

Resume & JD Input → Zod Schema Extraction → Match Score & Skill Gaps → Actionable Advice.