Most job applications disappear into silence. Not because the candidate was unqualified — but because their resume didn't match the keywords an Applicant Tracking System (ATS) was scanning for. I built the ATS scorer inside Mapleins to solve exactly this. Here's the full technical breakdown.
What Is ATS Scoring and Why Does It Matter
An Applicant Tracking System is software companies use to filter job applications before a human reads them. It scans resumes for keywords, formatting, and structure. Studies suggest over 75% of resumes are rejected by ATS before a recruiter sees them.
The Mapleins scorer simulates this — it reads your resume, compares it against a job description, and gives you a score with specific, actionable feedback. Not a generic "add more keywords" suggestion. Actual line-level recommendations.
The Stack
- Next.js 14 (App Router) — API routes + server components
- OpenAI GPT-4o — the analysis engine
- Supabase — store scores, resumes, and user data
- pdf-parse — extract text from uploaded PDFs
- Vercel — deployment with edge functions
Step 1 — File Upload and Text Extraction
Everything starts with the resume upload. Users can drop a PDF or DOCX. I use Next.js API routes to handle the upload, then extract the raw text.
For PDFs:
// app/api/parse-resume/route.ts
import pdf from 'pdf-parse'
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('resume') as File
const buffer = Buffer.from(await file.arrayBuffer())
const parsed = await pdf(buffer)
return Response.json({ text: parsed.text })
}
pdf-parse extracts clean text from most PDFs. The edge cases — multi-column layouts, image-based PDFs, tables — can trip it up. For image-based PDFs (scanned resumes) you'd need OCR. For Mapleins I added a warning when the extracted text is under 200 characters, which usually means the PDF is image-based.
For DOCX files I use mammoth:
import mammoth from 'mammoth'
const result = await mammoth.extractRawText({ buffer })
const text = result.value
Step 2 — Prompt Engineering (The Hard Part)
This is where most AI features go wrong. A vague prompt gets vague output. The GPT-4o call needs to return structured, consistent JSON — not a paragraph of feedback that's different every time.
My prompt went through 20+ iterations. Here's the version that works:
const systemPrompt = `
You are an expert ATS (Applicant Tracking System) analyzer and career coach.
Analyze the resume against the job description and return ONLY valid JSON.
No markdown, no explanation outside the JSON object.
Score each category 0-100. Be honest and critical — a score of 70 means there
is real room for improvement. Do not give high scores unless the match is strong.
`
const userPrompt = `
RESUME:
${resumeText}
JOB DESCRIPTION:
${jobDescription}
Return this exact JSON structure:
{
"overall": ,
"categories": {
"keyword_match": ,
"skills_alignment": ,
"experience_relevance": ,
"formatting_quality": ,
"impact_language":
},
"strengths": [, , ],
"improvements": [
{ "issue": , "fix": , "priority": "high|medium|low" }
],
"missing_keywords": [],
"summary":
}
`
A few things that matter here:
- "Return ONLY valid JSON" — without this, GPT wraps the JSON in markdown code fences. Your
JSON.parse()will throw. - Explicit scoring calibration — "70 means real room for improvement" stops the model from being over-generous. Without this instruction, scores cluster around 80-90 regardless of quality.
- The exact JSON structure — defining it upfront gets consistent output. I validate with Zod before touching the data.
Step 3 — The API Route
// app/api/score-resume/route.ts
import OpenAI from 'openai'
import { z } from 'zod'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
// Zod schema validates GPT output before we trust it
const ScoreSchema = z.object({
overall: z.number().min(0).max(100),
categories: z.object({
keyword_match: z.number(),
skills_alignment: z.number(),
experience_relevance: z.number(),
formatting_quality: z.number(),
impact_language: z.number(),
}),
strengths: z.array(z.string()),
improvements: z.array(z.object({
issue: z.string(),
fix: z.string(),
priority: z.enum(['high', 'medium', 'low'])
})),
missing_keywords: z.array(z.string()),
summary: z.string(),
})
export async function POST(req: Request) {
const { resumeText, jobDescription, userId } = await req.json()
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
temperature: 0.2, // low temp = consistent, structured output
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt(resumeText, jobDescription) }
]
})
const raw = completion.choices[0].message.content ?? ''
const json = JSON.parse(raw)
const score = ScoreSchema.parse(json) // throws if GPT hallucinated the shape
// Store in Supabase
await supabase.from('ats_scores').insert({
user_id: userId,
score: score.overall,
breakdown: score.categories,
feedback: score,
created_at: new Date().toISOString()
})
return Response.json(score)
}
Two things worth calling out: temperature: 0.2 keeps the output deterministic and structured — higher values produce creative text, which is the opposite of what you want for JSON. And the Zod validation is non-negotiable. GPT will occasionally return a slightly different key name or an out-of-range number. Zod catches it before it corrupts your database.
Step 4 — Storing and Caching Scores in Supabase
Running GPT-4o on every page load would be both expensive and slow. I cache the score against the resume ID. The cache invalidates only when the user uploads a new resume.
// Check for cached score first
const { data: cached } = await supabase
.from('ats_scores')
.select('*')
.eq('resume_id', resumeId)
.order('created_at', { ascending: false })
.limit(1)
.single()
if (cached) return Response.json(cached.feedback)
// No cache — run the scorer
const score = await runATSScorer(resumeText, jobDescription)
await storeScore(score, resumeId, userId)
return Response.json(score)
The Supabase Row Level Security policy ensures users can only ever read their own scores:
-- RLS policy
CREATE POLICY "Users see own scores"
ON ats_scores FOR SELECT
USING (auth.uid() = user_id);
Step 5 — Displaying Results
The score UI needs to communicate three things at once: the overall number, the category breakdown, and the specific improvements. I use a radial progress ring for the overall score (SVG, animated with CSS), a horizontal bar chart for categories, and an accordion for improvements sorted by priority.
The most important UX decision: show the improvements first, not the score. Users who see a 62/100 and then scroll to find out why convert far better than users who see the score first and close the tab. Leading with "Here's what to fix" is more motivating than leading with a grade.
Performance — Handling the Latency
GPT-4o takes 3–8 seconds for a detailed resume analysis. That's too long to show a blank screen. Three things I do:
- Trigger on upload, not on view — start the API call the moment the PDF is confirmed, before the user navigates to the results page. By the time they click through, it's often ready.
- Streaming — for the summary text, I stream the response using
openai.chat.completions.stream()so text appears word-by-word instead of all at once. - Skeleton UI — the score cards render as skeletons immediately. They fill in as data arrives. No blank white screen.
What I Learned
- Prompt engineering is the product. The model itself is a commodity — your prompts are your competitive advantage. Invest time here.
- Always validate AI output. Zod schema validation on every GPT response is not optional. It will fail in production without it.
- Low temperature for structured tasks. Save high temperature for creative tasks. For data extraction and scoring, keep it at 0.1–0.3.
- Cache everything. AI API calls are expensive. A caching layer pays for itself immediately at any real usage volume.
The full scorer is live at mapleins.com — upload your resume and get your ATS score in under 30 seconds. Free to use.