For years my CV lived in two worlds: a web page people browse on phones, and a PDF recruiters print or feed into ATS systems. I tried “one file to rule them all” with print CSS and browser exports, but it was constant whack-a-mole: hyphenation off, widows/orphans, fonts shifting between machines, A4 vs Letter page breaks, and the web/PDF drifting out of sync. I wanted the web version fast, accessible, and SEO-friendly—and the PDF to look like a grown-up typesetter touched it. Most of my updates are tiny anyway: tweak the text of my current role or add a new role when I change jobs.
So I split the job by purpose:
- Web: a normal /cv (React/MDX), accessible and easy to update.
- Print: a LaTeX PDF with real control over typography (hyphenation, ligatures, predictable breaks).
- Build reality: Vercel’s CI doesn’t include a LaTeX toolchain, so I run pdflatex locally (or Overleaf for theme changes), commit the hashed PDF under /public/files, and Vercel just reads a build-time CV_VERSION to link the current file.
Architecture
- Hashed artifacts kept: old PDFs remain in /public/files (cv-
.pdf history). - Linking: the site imports CV_VERSION and links directly to /files/cv-${CV_VERSION}.pdf (no query param).
- No LaTeX in CI: Vercel skips TeX completely; the PDF is already in the repo.
Source of truth & drafting
- Day to day, content lives in resume.json; generate-latex.js turns it into cv-source/main.tex with proper escaping and section stitching.
{
"company": "NewCo",
"position": "Senior Software Engineer",
"dates": "2025 – present",
"location": "Manchester",
"description": [
"Led X to deliver Y.",
"Improved Z by N% via A/B testing."
],
"technologies": ["C#", ".NET", "Azure"],
"iconId": "newco",
"url": "https://newco.example"
}
- For small layout tweaks, I'll export cv-source/main.tex to Overleaf, experiment, then pull the updated TeX back and keep generating from JSON.
- For big theme changes, I start from a new Overleaf template, paste in JSON content manually, then adjust the generator/JSON schema so future edits are back to "change JSON → regenerate."
Build flow (local → commit → deploy)
- Local: npm run update-cv (or equivalent) which:
- regenerates main.tex from JSON,
- runs pdflatex to produce public/files/cv-
.pdf, - optionally cleans aux files.
- Commit the PDF(s) and push.
- Vercel build runs generate-version.js which hashes cv-source/main.tex (and the class file) and writes CV_VERSION to lib/version.js. The front end reads that constant.
// /app/cv/page.tsx (excerpt)
import { CV_VERSION } from '@/lib/version';
const cvUrl = `/files/cv-${CV_VERSION}.pdf`;
// ...
<a href={cvUrl} target="_blank" rel="noopener noreferrer">Open PDF Version</a>
Why this approach won
- Print quality you can feel: LaTeX handles line breaks, spacing, ligatures, and avoids orphan/widow lines. Your class file centralises the rules.
- Deterministic builds: pdflatex runs on your machine, so the PDF you proof is the PDF you ship.
- Zero CI pain: no TeX in Vercel; builds stay fast.
- Instant cache correctness: the filename carries the content hash; the link updates as soon as CV_VERSION changes.
Notes from the trenches
- Escaping: generating TeX from JSON means escaping _ % & # everywhere. The generator handles this—extend it when adding new fields.
- Don't edit TeX post-gen: if you hand-tweak main.tex, your hash and data will drift. Fix the generator or JSON instead.
- Template evolution: it's fine to treat Overleaf as an R&D lab. For small tweaks, round-trip the single file; for big theme overhauls, adopt the new template, then bring the generator back into the loop.
Future niceties (on the backlog)
- Pre-push guard: fail the push if cv-source changed but the committed PDF didn't.
- One-shot "rebuild CV" action: a local script that opens Overleaf export, drops it into cv-source, regenerates, builds, and stages everything.
Related posts
- Vibe Coding — how I iterate quickly on small, high-leverage changes.
- Automating My GitHub Avatar Sync — same "one source of truth → pipeline handles the rest" pattern, applied to images.