For years my CV lived in two worlds: a web page people browse on phones, and a PDF that recruiters print or feed into ATS systems. I tried “one file to rule them all” with print CSS and browser exports, and it was constant whack-a-mole. Hyphenation off, widows and orphans, fonts shifting between machines, A4 against Letter page breaks, and the web and PDF versions quietly drifting apart. I wanted the web version fast and accessible, and the PDF to look like a grown-up typesetter had touched it. Most of my updates are tiny anyway: tweak the text of my current role, or add a new one when I change jobs.
So I split the job by purpose:
- Web: a normal /cv in React and MDX, accessible and easy to update.
- Print: a LaTeX PDF with real control over hyphenation, ligatures, and where the page breaks.
- Build reality: Vercel’s CI has no LaTeX toolchain, so I run pdflatex locally (or Overleaf for theme changes), commit the hashed PDF under /public/files, and let Vercel read a build-time CV_VERSION to link the current file.
Architecture
Diagram 1 as text
13 steps, 1 of them a decision. Starts at “What changed?”.
Decision: What changed? (a starting point)
- Go to Edit resume.json, when Minor content or layout
- Go to Draft in Overleaf – new or updated template, when Theme overhaul
Edit resume.json
- Go to generate-latex.js builds main.tex
- Reached from What changed?, when Minor content or layout
generate-latex.js builds main.tex
- Go to pdflatex local outputs public/files/cv-CV_VERSION.pdf
- Reached from Edit resume.json
- Reached from Export main.tex back to repo
Draft in Overleaf – new or updated template
- Go to Export main.tex back to repo
- Reached from What changed?, when Theme overhaul
Export main.tex back to repo
- Go to generate-latex.js builds main.tex
- Reached from Draft in Overleaf – new or updated template
pdflatex local outputs public/files/cv-CV_VERSION.pdf
- Go to Keep old PDFs in public/files
- Go to git add, commit and push
- Reached from generate-latex.js builds main.tex
Keep old PDFs in public/files
- Nothing leads out of this step.
- Reached from pdflatex local outputs public/files/cv-CV_VERSION.pdf
git add, commit and push
- Go to Install dependencies
- Reached from pdflatex local outputs public/files/cv-CV_VERSION.pdf
Install dependencies (inside Vercel build - no LaTeX)
- Go to Skip LaTeX build
- Reached from git add, commit and push
Skip LaTeX build (inside Vercel build - no LaTeX)
generate-version.js computes CV_VERSION from main.tex and class file (inside Vercel build - no LaTeX)
- Go to Next build links to /files/cv-CV_VERSION.pdf
- Reached from Skip LaTeX build
Next build links to /files/cv-CV_VERSION.pdf (inside Vercel build - no LaTeX)
Users download latest PDF - cache busted by filename
- Nothing leads out of this step.
- Reached from Next build links to /files/cv-CV_VERSION.pdf
- Hashed artifacts are kept: old PDFs stay in /public/files as a cv-
.pdf history. - Linking: the site imports CV_VERSION and links straight to /files/cv-${CV_VERSION}.pdf, with no query param.
- No LaTeX in CI: Vercel skips TeX entirely, because the PDF is already in the repo.
Source of truth and drafting
Day to day the content lives in resume.json, and generate-latex.js turns it into cv-source/main.tex with the escaping and section stitching handled.
{
"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 export cv-source/main.tex to Overleaf, experiment there, then pull the updated TeX back and carry on generating from JSON.
- For big theme changes I start from a new Overleaf template, paste the JSON content in by hand, then adjust the generator and JSON schema until future edits are back to "change JSON, regenerate".
Build flow: local, commit, deploy
- Locally, npm run update-cv regenerates main.tex from JSON, runs pdflatex to produce public/files/cv-
.pdf, and optionally cleans up the aux files. - Commit the PDFs and push.
- The 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
- The print quality is something you can feel. LaTeX handles line breaks, spacing, and ligatures, and it keeps orphan and widow lines away. The class file centralises the rules.
- The builds are deterministic. pdflatex runs on my machine, so the PDF I proof is the PDF I ship.
- There's no CI pain. Vercel never sees TeX, so the builds stay fast.
- The caching is correct by construction. The filename carries the content hash, so the link updates the moment CV_VERSION changes.
Notes from the trenches
- Escaping. Generating TeX from JSON means escaping _ % & # everywhere. The generator handles it, so extend it when you add new fields.
- Don't edit the TeX after generating. Hand-tweak main.tex and your hash and your data drift apart. Fix the generator or the JSON instead.
- Template evolution. Treating Overleaf as an R&D lab works well. Round-trip the single file for small tweaks, and for a big theme overhaul adopt the new template first, then bring the generator back into the loop.
Future niceties, still on the backlog
- A pre-push guard that fails the push if cv-source changed but the committed PDF didn't.
- A one-shot "rebuild CV" script that takes the Overleaf export, drops it into cv-source, regenerates, builds, and stages everything.
Related posts
- Vibe coding, on iterating quickly through small, high-leverage changes.
- Automating my GitHub avatar sync, the same "one source of truth, pipeline handles the rest" pattern applied to images.