Skip to content

Typeset-perfect CV: JSON to LaTeX to PDF, without fighting CI

August 10, 2025 (1y ago)

416 views

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?”.

  • 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.

Written by Sam Ainsworth.

my face
© Sam Ainsworth 2024 - 2026. All Rights Reserved.privacy