DEV Community

Giovanni Sizino Ennes
Giovanni Sizino Ennes

Posted on

I shipped a free ATS preview inside my paid AI tool. Here's the engineering write-up.

Most resume tools either charge for ATS scanning ($49/mo Jobscan) or treat it as a one-shot diagnostic disconnected from the actual application flow. I run an indie AI job-prep SaaS at aimvantage.uk and the recurring user pain was the same: people only find out their CV broke parsing AFTER the rejection email lands. Today I shipped a fix — a free in-product ATS preview that lives inside the paid prep-pack flow.

Here is the engineering write-up.

The constraint

The dashboard already accepts CV uploads (PDF, DOCX, TXT) and forwards them to Gemini for the full prep pack. The full pack costs 3 tokens. I wanted the ATS preview to:

  1. Run BEFORE the user spends tokens (so they can fix parse issues first)
  2. Add zero new dependencies (no pdfjs-dist, no Python service, no extra CDN script)
  3. Be deletable in 5 lines if I need to roll back
  4. Stay client-side (privacy-first; CV bytes never leave the browser for the preview)

The 5 vendor heuristics

The lint engine is a port of my open-source CV Mirror tool — pure functions on plain text, no I/O. It computes a handful of structural signals from the CV text:

export function computeSignals(text: string, fileSize: number): Signals {
  const lines = text.split('\n');
  const nonEmpty = lines.filter((l) => l.trim().length > 0);
  const wordCount = (text.match(/\b\w+\b/g) || []).length;

  // Multi-column heuristic: lines with a 5+ space gap
  const multiColumnLines = nonEmpty.filter((l) => /\S {5,}\S/.test(l)).length;
  const multiColumnRatio = nonEmpty.length > 0 ? multiColumnLines / nonEmpty.length : 0;

  const wordsPerKB = fileSize > 0 ? wordCount / (fileSize / 1024) : 0;
  const hasHeaderFooterLikeText = /^\s*page \d+( of \d+)?\s*$/im.test(text);
  const hasEmoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text);
  const hasSmartQuotes = /[‘’“”]/.test(text);
  // ... etc
}
Enter fullscreen mode Exit fullscreen mode

Then 5 vendor rule sets read those signals. Workday flags multi-column ratio > 15% as ERROR. Greenhouse flags emoji as WARN (it strips the codepoints, losing surrounding context). Lever flags missing standard section headers as ERROR (its parser uses headers to delimit sections). Taleo flags ISO-style dates as WARN (prefers Month-Year). iCIMS flags multi-column ratio > 20%.

Every rule cites a public source — docs/vendor-sources.md in the OSS repo.

Skipping PDF on purpose

The most-used CV format is PDF, but pdfjs-dist is ~300 KB minified. That's a third of my main bundle for one feature. Trade-off I made: DOCX + TXT supported inline (DOCX uses mammoth which was already in the bundle for the paid flow; TXT uses File.text()). For PDFs, the preview shows a friendly defer message: "Upload a DOCX version for the instant preview — your full Vantage analysis still works with PDFs."

The full ATS scan with multi-vendor parse view is at cv-mirror-web.vercel.app — that one DOES bundle pdfjs because it's the whole product.

Two surgical edits to Dashboard.tsx

The component is 100% additive. Two changes to the existing dashboard:

  1. One import line at the top
  2. One render block with clearly fenced comments:
{/* === ATS scanner (additive, free, client-side). Removing this and the
     import line restores the previous behaviour entirely. === */}
{cvFile && <AtsScannerSection cvFile={cvFile} />}
{/* === END ATS scanner === */}
Enter fullscreen mode Exit fullscreen mode

Roll-back is git revert <hash> plus deleting two new files. Existing tests, types, services, contexts, and routes are untouched.

Bundle impact

  • Main chunk: 1,154.70 kB → 1,165.85 kB (+11 kB minified)
  • Gzipped: 308.20 kB → 312.56 kB (+4.4 kB gzipped)
  • New dependencies: zero
  • mammoth is lazy-imported only when a DOCX is uploaded, so the cost is paid by users who actually use the feature

The audit caught two UI bugs before I shipped

After build, I did one more read-through. Two consistency issues:

  1. passCount counted vendors with zero errors regardless of warnings, but the green "Clean" pill required zero errors AND zero warnings. Headline could read "5/5" while only 3 pills were green. Fixed by aligning passCount to errors === 0 && warns === 0.
  2. The "Clean" label inherited tone.text from the info severity (sky blue) but the icon was emerald. Fixed with a single isClean flag and a labelClass derived from it.

Tiny things, but the kind of stuff that makes a free-tier feature feel sloppy. Caught and fixed before push.

Why this works as positioning

The category default is "free trial → upsell to monthly subscription." Vantage's pricing is pay-per-use (£5 for 20 tokens, never expire). Putting the ATS preview INSIDE the paid dashboard means the £5 starter pack now includes a free quality-control step. Users fix the parse issue, then their fit-score baseline is accurate, then the cover letter that gets generated cites real CV evidence rather than what survived the parse. Each piece of the chain depends on the parse being clean.

What I'm trying to figure out next

Whether to make the ATS preview the upsell hook ("see your ATS score, then pay £5 for the full prep pack") or keep it as pure value-add for users who already paid. Curious what other indie founders here have seen with free-tier-as-on-ramp patterns.

Live tool: aimvantage.uk (£5 starter, never expires, no subscription)
Open-source companion: cv-mirror-mcp on GitHub
Build-in-public posts: dev.to/goofypluto999

Operator transparency (because Gemini briefly thought I was a phishing site — separate post coming on that): aimvantage.uk/about

— Gio

Top comments (0)