All work
Built in a day

CV Maker

A real PDF. No paywall at the end.

One clean template, a design panel to tune it, and a real PDF at the end — built after one too many CV sites that let you design the whole thing and then charge you to download it.

Alex_Morgan.pdf · real, selectable text
Role
Design & development
Built for
Me
Built
July 2026, in about a day
Stack
Next.js · Postgres · Puppeteer

01Why it exists

Why pay for something I can build myself?

A lot of CV sites work the same way. You pick a look, type in your whole working life, tune it until it’s right — and only then does the download button ask you to pay.

I didn’t want a subscription. I wanted a template, some boxes to fill in, and a PDF.

If I can make exactly what they’re selling, why pay for it? So I built my own, in about a day. It’s deliberately simple: one clean template, a panel to adjust it, and a real PDF at the end.

You do sign in — your CVs are saved to your account — but the download is just a download.

The usual CV site

Free until the last step

  • Pick a template from the gallery
  • Fill in everything and tune it until it’s right
  • “Download PDF” turns out to be a payment page
CV Maker

Type, tune, export

  • One clean template, and a Design panel to make it yours
  • A live A4 preview that shows exactly where each page breaks
  • A real PDF, with selectable text and working links

02Try it

Two pages? Pull it back onto one.

This CV runs a page and a bit. Every block is measured at the printed width and dealt onto A4 sheets, the way the app does it — so change the density, the font or the spacing and watch the page count.

Alex Morgan — Product Saved

Density

2 pages

The quickest way to pull a CV back onto one page — adjusts size, leading, spacing and margins together.

Typography

Heading colour

Spacing

2 pages

A working miniature of the builder: the renderer, the block measuring and the pagination are ported from the app, with three of its six metric-matched fonts loaded from Google Fonts. Simplified: pages sit side by side instead of stacked, nothing is saved anywhere (the status only mimics the 900 ms autosave), and Export opens your browser’s print dialog with the app’s print CSS, standing in for the server’s Chromium. Alex Morgan is invented.

03What it does

Small app, careful details.

A preview you can trust

Real A4 sheets that paginate as you type, with a page count, “1 / 2” labels and zoom from 25% to 200%, or Fit.

Saves as you go

900 ms after you stop, and again when the tab is hidden or closed. The status reads Saving, Unsaved or Saved — or Retry, if a save fails.

Undo in whole thoughts

Sixty steps. Typing in one field merges into a single step while the keystrokes are under 700 ms apart. ⌘ Z, ⇧ ⌘ Z and ⌘ S work as you’d expect.

Bullets like an outliner

Three levels deep. Tab and Shift-Tab indent a bullet with everything under it; deleting one promotes its children instead of losing them. Paste an indented list and it splits into bullets.

Bold, italic, named links

One floating toolbar follows your selection. A link can have a name, so the CV prints “Portfolio” while pointing at the full address.

Your sections, any screen

Detailed, Simple or Label-list sections, named as you like, and up to 50 CVs per account. On a phone it becomes Edit, Design and Preview tabs, with 16 px inputs so iOS doesn’t zoom.

04How it’s built

Rows in Postgres, Chromium on demand.

A Next.js app on Vercel. The editor keeps the whole CV in memory and saves it as plain database rows; the PDF is printed by a real browser on the server.

AppNext.js 16 App Router, React 19, TypeScript, Tailwind v4, Zustand.
DataPrisma 6 on PostgreSQL (Supabase). A CV is rows, not a JSON blob: Resume → Section → Entry → Bullet, each with an order, bullets with an indent level from 0 to 2.
SavingA full replace in one transaction, using createMany with IDs made in the app — a fixed five statements however big the CV is, which matters when the database is in another region.
AccountsNextAuth v4 with JWTs: email and password (bcrypt, cost 12), or GitHub and Google. An unknown email still runs a dummy bcrypt check, so the response time doesn’t reveal who has an account.
PDFpuppeteer-core with @sparticuz/chromium, in a Node function allowed up to 60 seconds.
src/app/api/export/route.ts
async function launchBrowser(): Promise<Browser> {
  const puppeteer = (await import("puppeteer-core")).default;

  if (isServerless) {
    const chromium = (await import("@sparticuz/chromium")).default;
    // No WebGL in a CV. Skipping it avoids unpacking swiftshader, which is a
    // meaningful chunk of an already slow cold start.
    chromium.setGraphicsMode = false;

    return puppeteer.launch({
      args: [...chromium.args, "--font-render-hinting=none"],
      executablePath: await chromium.executablePath(),
      headless: true,
    });
  }

The start of the export route, as it is in the repo. The browser is always closed in a finally block, so a failed render can’t leave Chromium running.

05Build notes

Where a day actually went.

01

The preview and the PDF break in the same places

One pure-string renderer feeds both. It writes the CV as a flat list of blocks — the header, each section heading, each entry. The preview measures every block off-screen at the exact printed text width and deals them onto A4 sheets, collapsing margins the way CSS does and keeping a heading with its first entry. It measures again once the fonts arrive. In print, margins come from @page rather than padding, so page two gets a top margin too.

02

Fonts that measure the same everywhere

Vercel’s Chromium ships with Open Sans and nothing else, and a different font means different line breaks — so different page breaks. The app uses open fonts with the same metrics as Arial, Calibri, Georgia, Times and Cambria: Arimo, Carlito, Gelasio, Tinos and Caladea, with EB Garamond standing in for Garamond. The same files go to the browser and, inlined as base64, into the PDF’s HTML, because setContent() has no base URL to load them from.

03

Chromium in a serverless function

puppeteer-core drives a compressed Chromium, with graphics off to shorten cold starts and the binary explicitly included in the function bundle. Export waits for a save to finish, then posts the CV to /api/export; the file comes back named after the person — Alex_Morgan.pdf.

04

No keystroke left behind

Undo steps coalesce, saves queue so only one runs at a time, and anything typed during a save stays marked unsaved until the next one. A small sanitiser keeps bold, italic and underline and escapes every other tag, so List<String> survives instead of disappearing.