Last month I wanted to build a resume checker that scores resumes against job descriptions. The catch: it had to run entirely in the browser. No backend. No API calls. No user data leaving the page.
Here's exactly how I did it — and how you can build similar tools for your portfolio or side project.
Why Client-Side?
Three reasons:
- Zero hosting costs — static HTML on GitHub Pages. Done.
- Privacy by design — resume text never leaves the browser. That's a real selling point.
- No API rate limits — unlimited usage, no keys to manage, no billing surprises.
The tradeoff: you can't use LLMs for analysis. Everything needs to be rule-based. For an ATS checker, that's actually fine. Real ATS systems use keyword matching and pattern detection, not AI.
The Architecture
index.html
├── <textarea> for resume text
├── <textarea> for job description (optional)
├── scoring engine (vanilla JS)
└── results panel (DOM manipulation)
That's it. One file if you want. I split mine into separate CSS for readability.
Core Scoring Engine
The scorer runs eight checks. Each returns a score and feedback:
function analyzeResume(resumeText, jobDescription) {
const checks = [
checkLength(resumeText),
checkContactInfo(resumeText),
checkSections(resumeText),
checkActionVerbs(resumeText),
checkQuantifiedAchievements(resumeText),
checkFormatting(resumeText),
checkBuzzwords(resumeText),
checkKeywordMatch(resumeText, jobDescription)
];
const totalScore = checks.reduce((sum, c) => sum + c.score, 0);
return { score: Math.round(totalScore / checks.length), checks };
}
The Interesting Checks
Keyword Matching
This is where it gets useful. When someone pastes a job description, you extract significant terms and check which ones appear in the resume:
function checkKeywordMatch(resume, jd) {
if (!jd) return { score: 0, label: 'Keyword Match', status: 'skip' };
const stopWords = new Set(['the','a','an','is','are','was','were',
'be','been','being','have','has','had','do','does','did','will',
'would','could','should','may','might','shall','can','need',
'dare','ought','used','to','of','in','for','on','with','at',
'by','from','as','into','through','during','before','after']);
const jdWords = jd.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter(w => w.length > 2 && !stopWords.has(w));
const jdTerms = [...new Set(jdWords)];
const resumeLower = resume.toLowerCase();
const found = jdTerms.filter(term => resumeLower.includes(term));
const ratio = found.length / jdTerms.length;
return {
score: Math.min(100, Math.round(ratio * 120)),
label: 'Keyword Match',
detail: \`\${found.length}/\${jdTerms.length} keywords found\`
};
}
The * 120 with Math.min(100, ...) means you need about 83% keyword coverage for a perfect score. That matches how real ATS thresholds work.
Action Verb Detection
Recruiters and ATS systems flag resumes that use passive language. Check for strong verbs at the start of bullet points:
const ACTION_VERBS = ['led','managed','developed','designed','built',
'created','implemented','improved','increased','reduced','delivered',
'launched','automated','optimized','established','negotiated',
'mentored','analyzed','resolved','streamlined'];
function checkActionVerbs(text) {
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const bulleted = lines.filter(l => /^[-•*]/.test(l) || /^\d+\./.test(l));
if (bulleted.length === 0) {
return { score: 40, label: 'Action Verbs', detail: 'No bullet points found' };
}
const withVerbs = bulleted.filter(line => {
const firstWord = line.replace(/^[-•*\d.)\s]+/, '').split(/\s/)[0].toLowerCase();
return ACTION_VERBS.includes(firstWord);
});
const ratio = withVerbs.length / bulleted.length;
return {
score: Math.round(ratio * 100),
label: 'Action Verbs',
detail: \`\${withVerbs.length}/\${bulleted.length} bullets start with action verbs\`
};
}
Rendering Results
Simple pass/warn/fail system:
function renderResults(analysis) {
const panel = document.getElementById('results');
const overallClass = analysis.score >= 75 ? 'pass'
: analysis.score >= 50 ? 'warn' : 'fail';
panel.innerHTML = \`
<div class="overall-score \${overallClass}">
<span class="score-number">\${analysis.score}</span>/100
</div>
\${analysis.checks.map(check => \`
<div class="check-row \${check.status || (check.score >= 70 ? 'pass' : check.score >= 40 ? 'warn' : 'fail')}">
<strong>\${check.label}</strong>: \${check.detail}
</div>
\`).join('')}
\`;
}
Deployment
Push to GitHub Pages. That's the entire deploy:
git add -A && git commit -m "resume checker v1" && git push
Live in 30 seconds. Free forever. Scales to millions of users because it's static files served from a CDN.
What I Learned
Rule-based tools are underrated. Everyone reaches for AI APIs first. But for structured analysis like ATS scoring, readability checks, and format validation, regex and string matching work fine and cost nothing to run.
Client-side = trust. When your tool says "your data never leaves your browser," users believe you because it's verifiable. Open the network tab. Zero outbound requests.
Free tools drive traffic. My resume ATS checker and LinkedIn headline generator get more clicks than any product page. Free tools build trust, and trust converts.
If you're building client-side tools and want a jumpstart, I put together a collection of developer-focused AI prompts on GitHub. Free, MIT licensed, useful for scaffolding projects like this one.
Top comments (0)