← Aditya Yadav Engineering notes vettacv.com ↗
build log · 2024 → 2026

Building VettaCV: a resume tailor that grew into a job-search platform

Engineering notes on taking a Streamlit prototype to production: one LLM schema, a tight token budget, a lean API, Firestore, Razorpay and AWS. Five-minute read.

Next.jsFlaskOpenAI Structured OutputsPydantic FirebaseAWSRazorpay
3repos: landing, app, API
250+commits since Apr 2024
400+users this year, 7+ countries
+134%active users, last 90 days
01 · timeline

Built in the order things broke

2023Streamlit prototype → CVSwitch (Flutter)
APR 24Flask API: PDF ↔ JSON
MAR 25Next.js app + Firebase auth
APR 25Pydantic structured output
JUN 25Tailoring, AI writer, payments
2026Editor, interviews, jobs, SEO
02 · architecture

Three deployables, one origin for the browser

Browser one domain www · landing static export on a CDN app · product Next.js · forwards API calls api Python · Flask Firebase OpenAI Razorpay Resend email
The app forwards API calls server-side, so the browser only ever talks to its own origin. The dashed line is Firebase sign-in.
03 · llm layer

One Pydantic schema is the backbone

Early versions asked the model for JSON and validated it afterwards. That works until it doesn't. Moving to Structured Outputs (pass a Pydantic model, get a guaranteed match) made one Resume schema the contract between parsing, the editor, the PDF, tailoring and job matching.

# the resume schema (abridged)
class WorkExperience(BaseModel):
    name: Optional[str]            # company
    position: Optional[str]
    startDate: Optional[str]
    endDate: Optional[str]
    description: Optional[str]

class Resume(BaseModel):
    basics: Optional[BasicInfo]
    workExperiences: Optional[List[WorkExperience]]
    education: Optional[List[Education]]
    skills: Optional[Section]

completion = client.responses.parse(model=MODEL, input=messages, text_format=Resume)
resume = completion.output_parsed   # typed, no json.loads, no retries for shape
PDF uploaduser Text extractPyMuPDF, no LLM LLM → Resumegpt-4o-mini, parse() Firestoresame shape Editor + print-ready PDF Tailor to a job (diff view) Job matching + apply kit Interview practice context

Guardrail: tailoring may rephrase, reorder and emphasise. It never adds experience, tools or numbers. The UI shows a word-level diff so the user approves every change.

04 · token budget

The cheapest token is the one you don't send

TechniqueWhat it saves
Schema instead of examplesNo JSON templates or format rules in the prompt: the schema is the instruction.
Compact resume contextPrompts get trimmed plain text (sections + bullets), not the raw document.
Small models onlygpt-4o-mini / o4-mini / gpt-4.1-mini. Parsing moved off a reasoning model: no hidden reasoning tokens.
Code before LLMJob import (ATS APIs, JSON-LD), salary parsing, country detection and filters are regex and plain code.
Shared cachesCompany interview guides are researched once per company × role family and reused for 30 days: ~$0.03–0.06 per guide, not per user.
Charge on successCredits are deducted only after a generation succeeds; retries never double-charge.
05 · backend

A small Flask monolith, kept lean on purpose

One deployable, one log, one process to restart. Running on a small instance shaped the design more than any framework did:

One small API server Web workers: request path only parse, tailor, match, practise cached jobs index in memory Scheduled jobs, separate processes nightly job-board refresh, email batching each with its own memory limit
Heavy work never runs inside the web workers, so a slow batch job can't take the API down with it.
06 · data

Firestore, organised around the user

Firebase gave auth, a document store and file storage with zero servers. A resume is one nested, evolving JSON object, which fits a document model. The rules we kept:

07 · payments

Razorpay: the server owns the price

Pick a planbrowser Server sets priceINR in India, USD elsewhere PayRazorpay checkout Server confirmsthen adds credits once
Prices and credit amounts are decided and confirmed on the server, never taken from the browser.
08 · front-end

The editor, and guardrails for vibe coding

The editor was the hardest UI: WYSIWYG, print-perfect on A4 and Letter, autosaved locally, and able to show AI changes as accept/reject diffs. Open-source JSON resume builders didn't fit that flow, so it's custom.

AI coding tools let a tiny team ship a lot of UI, and break a lot of UI. What kept it shippable:

09 · infra & growth

Domain, DNS, SEO

PieceChoice
Domain / DNSBought at a registrar, DNS managed on AWS so records live next to the services they point at.
DeploysThe app builds and deploys on every push to main; the landing site is a static export on a CDN.
EmailTransactional email from the domain, authenticated with SPF, DKIM and DMARC so it lands in the inbox.
SEOFixed robots, canonical URLs, redirects, breadcrumb schema; programmatic /resume-tips/[jobTitle] pages.
AEOQ&A-shaped content AI assistants can cite. AI assistants are now the #2 traffic source after direct.
10 · numbers

How it's doing (GA4, Oct 2026)

332↑ 120%new users · 90 days
323↑ 134%active users · 90 days
3.9K↑ 202%events · 90 days
404↓ 12%page views · 90 days
6040200 JulAugSepOct
New users per week (approx., read off GA4).
United States184
India157
France20
Egypt15
UAE11
UK11
Saudi Arabia9
Active users by country, Jan–Oct 2026. The US leads, so job matching now covers the US, UK and Canada.
11 · takeaways

What I'd tell myself in 2024

Prototype the hard partStreamlit proved the LLM output was worth building around. Auth and UI could wait.
Protect one data shapeThe Resume schema saved more time than any framework choice.
Constraints are design toolsA small server forced clean background jobs and caching we needed anyway.
Ship small, revert fastWith AI writing code, review and small PRs are the real engineering.