BuildCV grew up with round 3, and it still uploads nothing
In July I shipped BuildCV, a CV builder that is actually free: no account, no paywall on the download button, and no CV data on my server. I wrote about that first version here. It was a guided wizard with a live A4 preview and five templates, and it printed to PDF.
This week the second release went live. It is one pull request with three commits, 84 files and 8,283 added lines. It adds what a real job hunt needs: more than one CV, a way to target a specific job, Word for the agencies that still ask for it, and a faster start than typing everything again. The harder part was adding all of that without breaking the original promise.
The constraint that shaped every feature
BuildCV has one rule, and it is written down in the repo as a guardrail: the builder makes no API calls and stores nothing on a server. Each new feature had to answer "where does the data live?" before anything else.

That rule decided the design more than once. The pasted job ad goes to sessionStorage and is never written into the document, so it can never leak into an export. The share link carries the CV in the URL fragment. LinkedIn import unpacks the ZIP in the tab.
Schema v3: sections are data, not template code
The document model now has projects, certifications and custom sections that the user names. There is also a per-document section order, a set of hidden sections, a density setting and a version name. Real CVs already sit in people's localStorage, and there is no server copy to fall back on, so a schema change means a version bump and a migration. normalize() rebuilds every field, so a migration step only has to move what it can and stamp the version. A test feeds it v1 and v2 documents and expects today's defaults back.
The bigger change is in the templates. None of the five templates hard-codes a section sequence any more. Each one loops over sheetSections(doc), which has already removed the hidden and empty sections and sorted the rest into the user's order. A shared test checks order, visibility and the optional sections against every template.
Density, and a "Fit to one page" button
Density is only two CSS variables. Template CSS writes type sizes as calc(<pt> * var(--cv-type)) and vertical rhythm as calc(<mm> * var(--cv-space)). One setting tightens the whole sheet without a second stylesheet.

"Fit to one page" renders the template into a hidden element at its real size, through a throwaway React root and flushSync. Then it reads scrollHeight. The sheet uses the app's real stylesheets and loaded fonts, so the measurement matches the print. It does not touch app state at all. When even the tight setting spills over, the button says how many pages are left and leaves your density unchanged. What to cut is your decision, not the algorithm's.
Several CVs, still without a server
"My CVs" keeps up to 12 versions in one browser. The limit is about photos and the roughly 5 MB localStorage budget. The active document stays under buildcv.cv, which is the key every earlier build wrote, so existing users need no migration. The other versions are parked under buildcv.doc.<id>, and a small index lists them.
Two open tabs were the tricky case. Each tab knows the library id of its document. A save goes to the active key only if the index still names that id as active. Otherwise it goes to that document's own slot, so two tabs cannot overwrite each other's CV. A follow-up commit made every write checked and rolled back on failure, and it rebuilds a lost index from the slots on disk.
Undo and redo wrap the reducer. Keystrokes into the same field within one second merge into one step, so Ctrl+Z takes back a word, not a letter. Structural edits, imports and every look choice are steps of their own. The history keeps 100 steps.
Review and tailoring, without a model
I deliberately kept LLMs out of this. The review step is a pure lint pass over the document. It flags leftover placeholders, weak opening verbs, bullets without numbers, dates that end before they start, gaps between roles, duplicate or generic skills, and an ATS-unfriendly photo. Each finding has a "Fix" link that opens the right step, and the exact role when there is one. An ATS block shows the plain text that a parser would extract.
Tailoring takes a pasted job ad and extracts its recurring terms. These are the words an applicant tracking system matches on and a recruiter skims for. Then it shows which of them the CV already contains. A missing term becomes a skill in one click. Because of that click, the stopword lists carry ad boilerplate on purpose: "team player", "competitive package", "fluent", gender markers like "m/f". It is all plain text processing in the browser, so it is easy to test.
Getting a CV in and out
A .docx file is a ZIP of a few XML parts. A CV needs only paragraphs, two heading levels, a bullet list and a right tab for dates. So I didn't pull in a library: the Word export builds the OOXML by hand and writes it into a ZIP with uncompressed entries. Word opens it without complaint. The one piece that needs real code is the checksum:
export function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff
for (const b of bytes) crc = CRC_TABLE[(crc ^ b) & 0xff] ^ (crc >>> 8)
return (crc ^ 0xffffffff) >>> 0
}
Import goes the other way. The data export that LinkedIn hands out is a ZIP with one CSV per section. A minimal ZIP reader walks its central directory and inflates the few CSVs it knows with the browser's DecompressionStream. JSON Resume and BuildCV backups work in both directions; Markdown and plain text are export only. Every import goes through normalize().

The share link does the same in reverse. It compresses the document, encodes it as base64url and puts it after the #. It exists for moving a CV to your phone or to someone you trust. The photo is stripped to keep the link short, and the app asks before it adopts a shared CV as a new version.
The audit pass
After the feature commit I ran an audit and an accessibility pass with Claude Code over the whole change. Two follow-up commits came out of it. Each finding was confirmed before it was fixed, and several were the kind a unit test would not have caught.

The focus bug was the best one. The parent passed onClose as an inline arrow function, so the modal's effect ran again on every render. Pressing a density chip inside the preview threw focus to "Close preview". Reading the callback through a ref fixed it. All overlays now share one useModal contract: focus moves in on open and back to the opener on close, Escape closes, Tab cycles inside, and the page behind becomes inert. Drag-and-drop announcements now name the item in the UI language. The live previews render a deferred copy of the document, so typing stays fast while five templates re-render.
What I'm taking away
Modern browsers already had most of the parts: compression streams, the URL fragment, storage and a real layout engine to measure with. What the product needed was discipline about where data lives. Most of the logic sits in pure modules (review, tailoring, history, library, exporters, the ZIP code), so it is cheap to test. The BuildCV suite is 177 tests in 21 files, and it runs in under a second.
It is live at buildcv.zakaria.lu. It is still free and still uploads nothing.