Most AI resume tools produce polished-sounding garbage. They generate confident, well-formatted text that reads well to a human but fails ATS scanners on keyword density, gets caught by plagiarism detectors, or uses language so generic it conveys nothing about the actual candidate.
When I built the resume generator inside Mentora, I wanted something that solved the actual problem: getting a real candidate's real experience into a format that clears ATS and then lands with a human reader. Here's how I architected it.
The Architecture Decision: Structured Output vs. Free Text
The first design question was whether to ask the LLM to generate freeform resume text or to produce structured JSON that I'd then render with my own templates.
Free text is faster to prototype. You prompt the model, it returns markdown, you render it. But it's brittle: formatting is inconsistent, sections appear in different orders, and there's no easy way to validate that required fields are present.
Structured JSON requires more upfront schema design, but it gives you complete control over rendering, makes validation trivial, and lets you apply consistent formatting across every generated resume.
I chose structured JSON.
// The schema I validate against with Zod
const ResumeSchema = z.object({
summary: z.string().max(300),
experience: z.array(z.object({
company: z.string(),
title: z.string(),
duration: z.string(),
bullets: z.array(z.string()).min(2).max(5),
})),
skills: z.object({
technical: z.array(z.string()),
tools: z.array(z.string()),
}),
education: z.array(z.object({
institution: z.string(),
degree: z.string(),
year: z.string(),
})),
});
Gemini's responseMimeType: "application/json" with a responseSchema means the model is constrained to produce valid JSON matching my schema — no markdown wrapping, no missing fields.
The Prompt Engineering
This is where the actual work is. The quality of the output is almost entirely determined by the quality of the prompt.
My prompt has four sections:
1. Role definition and tone:
You are an expert resume writer and ATS optimization specialist with 10+ years of experience
helping software engineers land roles at top-tier technology companies. You write clear,
specific, quantified bullet points. You never use filler phrases like "responsible for"
or "worked on". Every bullet begins with a strong action verb and includes a specific outcome.
2. The job description analysis instruction:
Analyse the provided job description and extract:
- Required technical skills (must appear verbatim in the resume)
- Preferred skills (include if the candidate has them)
- The 3-5 most important responsibilities from the description
- Any specific tools, frameworks, or methodologies mentioned
Use exact keyword matches from the job description — ATS systems often perform exact-match
keyword analysis. Paraphrasing relevant keywords will hurt the candidate's score.
3. The candidate data: This is where I inject the user's skills, experience, education, and any previous resume content they've uploaded. I structure it clearly so the model can reference specific items without hallucinating.
4. Output constraints:
Generate experience bullets that are:
- Action verb → task description → specific outcome/metric (when available)
- 15–20 words each
- Truthful — only include information present in the candidate data
- Unique — no two bullets should begin with the same verb
- ATS-optimised — incorporate keywords from the job description naturally
Do not invent metrics, companies, or technologies the candidate hasn't listed.
The hallucination guard is the most important constraint. AI resume tools that invent metrics are a liability. I enforce this in the prompt, add a validation step that flags suspicious patterns, and make it clear in the UI that the model generates based only on provided data.
Streaming Responses with Vercel AI SDK
Resume generation takes 5–10 seconds. A loading spinner with no feedback is a terrible experience. I stream the response so users see content appearing progressively.
// app/api/generate-resume/route.ts
import { streamObject } from "ai";
import { google } from "@ai-sdk/google";
export async function POST(req: Request) {
const { candidateData, jobDescription } = await req.json();
const result = streamObject({
model: google("gemini-1.5-pro"),
schema: ResumeSchema,
prompt: buildPrompt(candidateData, jobDescription),
});
return result.toTextStreamResponse();
}
// components/ResumeGenerator.tsx
"use client";
import { experimental_useObject as useObject } from "ai/react";
export function ResumeGenerator({ candidateData, jobDescription }) {
const { object, submit, isLoading } = useObject({
api: "/api/generate-resume",
schema: ResumeSchema,
});
return (
<div>
<button onClick={() => submit({ candidateData, jobDescription })}>
Generate Resume
</button>
{/* Content appears progressively as the stream resolves */}
{object?.summary && <SummarySection content={object.summary} />}
{object?.experience?.map((exp, i) => (
<ExperienceSection key={i} data={exp} />
))}
</div>
);
}
The experimental_useObject hook handles the stream incrementally. As each part of the JSON resolves, the corresponding section appears in the UI. Users see the professional summary first, then experience bullets appear one by one — which is both genuinely useful (they can start reviewing while the rest generates) and psychologically satisfying (it feels fast even when it isn't).
ATS Keyword Scoring
After generation, I run a lightweight ATS compatibility check client-side:
function scoreATSCompatibility(resume: Resume, jobDescription: string): number {
const jdKeywords = extractKeywords(jobDescription);
const resumeText = serializeResumeToText(resume).toLowerCase();
const matched = jdKeywords.filter(keyword =>
resumeText.includes(keyword.toLowerCase())
);
return Math.round((matched.length / jdKeywords.length) * 100);
}
function extractKeywords(text: string): string[] {
// Strip common stop words, extract multi-word tech terms first
// Then single technical nouns and skills
const techPatterns = [
/react\s+native/gi, /next\.js/gi, /node\.js/gi,
/machine\s+learning/gi, /large\s+language\s+models/gi,
// ... more patterns
];
// ...
}
The score appears instantly after generation. Users can iterate on the prompt inputs — adjusting which skills to emphasise, adding context about specific projects — and regenerate to improve their score.
What Makes It Actually Useful
The features that elevate this from a toy to a real tool:
Job description parsing — Users paste the job description and I highlight which of their skills are matched and which keywords are missing from their profile. Before they even generate the resume, they know the gaps.
Version history — Every generated resume is saved with the job description it was tailored to. Users can generate five versions for five different roles and compare them.
Editable sections — The generated content is fully editable. Clicking a bullet opens an inline editor. The AI output is a starting point, not a final product.
Cover letter coherence — The cover letter generator uses the same job description analysis and the generated resume data, so it's coherent with what the resume says. The cover letter doesn't contradict the resume's claims.
The Hard Part
The hardest part wasn't the AI integration — it was the UX of communicating what the tool can and can't do. Users naturally want the AI to invent impressive metrics. "I was responsible for the backend" becomes "Reduced API latency by 47% by refactoring..." which sounds great but is fabricated.
I made the limitation explicit in the UI, added help text explaining that the tool amplifies real experience into better language rather than inventing experience, and added a warning indicator on any bullet that looks like it might contain a fabricated metric.
Trust is the product. A resume tool that helps users lie isn't useful — it gets them rejected in technical interviews when they can't back up the claims.
Build tools that make users better, not tools that make users look better than they are.