DEV Community

Shenouda Bertel
Shenouda Bertel

Posted on

I built a Markdown resume builder for the AI-paste workflow — here's everything that broke

There's a workflow that basically didn't exist three years ago and now half the job-seekers I know use it: ask ChatGPT, Claude, Gemini, or any AI to write your resume bullets, get back beautifully structured text… and then spend forty minutes mangling it into Word or a drag-and-drop resume builder, fixing bullet indentation and font sizes by hand.

Here's the thing that bugged me: LLMs already speak Markdown. Ask any chatbot for a resume and you get ## Experience, **Senior Engineer**, - Shipped X — clean, structured Markdown. Then every resume tool on earth makes you throw that structure away and re-enter it into form fields.

So I built ResumeMD: a split-pane editor where you paste Markdown on the left, see a typeset resume on the right, pick a template, and download a PDF. No signup to start, everything in localStorage by default. This post is about the parts that fought back.

Decision 1: Markdown is the source of truth

Most resume builders store your resume as a proprietary JSON blob mapped to form fields. I wanted the document itself to be portable text. That means the entire product is "just" a Markdown renderer with opinions:

  • h2 = section headers (Experience, Education) — these get the decorative treatment per template: uppercase, border, background, prefix glyphs.
  • h3 = job titles — plain, bold, primary color.
  • One weird trick I'm genuinely fond of: the sidebar template splits a single Markdown document into main column and sidebar using an HTML comment (<!-- sidebar -->) as the split marker. Content above the marker is the main column; below is the sidebar. It keeps the document valid Markdown everywhere else.

The preview is react-markdown + remark-gfm with a 300ms debounce, styled by a template system that turned out to need three parallel implementations of every template: CSS classes for the live preview, inline-style functions shared between preview and template cards, and pure-JS styles for the PDF renderer. Thirty-two templates, three layers each. When I add a template I touch three files in lockstep, and yes, it's as tedious as it sounds — but each rendering context has constraints that made a single abstraction leakier than the duplication.

Decision 2: localStorage first, accounts optional

I didn't want a signup wall in front of a tool whose whole pitch is "paste and go." So the architecture is offline-first:

  • No account: everything lives in localStorage. The editor, all 32 templates, PDF export, LinkedIn import — fully functional with zero backend calls. Your resume never leaves the browser.
  • With an account: the same localStorage state gets a cloud-sync layer on top — 5-second debounce, an offline queue, and conflict resolution for multi-device edits.

The sync layer produced my favorite bug of the whole project. The Supabase real-time subscription callback captured stale closure values of the current markdown and settings. Change your template while a sync was in flight, and the subscription would "detect" a conflict between your own update and… your own update, then overwrite your state. The fix is the classic React pattern nobody enjoys writing: refs (currentMarkdownRef, cloudVersionRef) updated synchronously during render, an isSyncingRef guard so the subscription ignores our own writes, and a dependency array cut down to identity values only. If you have a useEffect subscription that needs current state but must not re-subscribe on every keystroke — refs, not closures.

A second race hid behind the first: on first sign-in, two async paths could each decide "no cloud resume exists yet, better create one," producing duplicate rows five seconds apart. The fix was a creation-specific guard ref plus a re-check inside the create branch: query once more before inserting, and if another path won the race, adopt its row instead of creating a sibling.

The PDF pipeline, or: three fights I didn't expect

Fight 1: Tailwind v4's Lightning CSS. This one cost me weeks. Lightning CSS (via @tailwindcss/postcss) aggressively optimizes selectors, and for my template CSS it would split combined h2, h3 rules and generate h3 declarations that were not in my source. It fabricated ::before pseudo-element rules I never wrote. It stripped h2 strong { color: inherit } entirely. My mitigations, in escalating order of resignation:

  1. Move all template-specific h2/h3 styling to inline style props.
  2. Render decorative prefixes (the > on the Tech template, the dash bar on Swiss) as actual React elements instead of CSS pseudo-elements.
  3. A <style> tag in the server layout's <head> that nukes every .resume-preview h2/h3 ::before/::after with content: none !important — because compiled pseudo-element rules kept resurrecting from Turbopack's cache.
  4. React.cloneElement on heading children to force color: inherit where the CSS rule got stripped.

I'm not saying Lightning CSS is wrong for your project. I'm saying: if your product is precise typography, audit what your CSS processor actually emits.

Fight 2: fonts, at scale of "the whole world writes resumes." PDF generation uses @react-pdf/renderer, which requires you to embed fonts for any glyph you render — there's no OS font fallback inside a generated PDF. Latin was easy. Then a Chinese sample resume rendered as tofu boxes, and I learned that Noto Sans SC — the font that covers Simplified Chinese — is a 17MB TTF. That's the file you have to load to render one CJK résumé. Arabic added another 825KB (plus RTL handling in the preview), Devanagari another file for Hindi. The system that emerged is a script-detection module (Unicode-range regexes classifying CJK, Arabic, Devanagari, Thai, Bengali, Tamil) feeding a font registry that maps script family → registered font, applied consistently across PDF, DOCX, and HTML export so a Japanese resume doesn't silently degrade in one format. Six languages supported end-to-end so far. Adding a script is now: drop a TTF, add a registry entry, add a sample.

Fight 3: color spaces — a fight I eventually won by deleting the combatant. Tailwind v4 emits lab() and oklch() colors. html2canvas — which I used to rasterize the little resume thumbnails on the dashboard — understands neither, so for months there was an onclone callback walking a cloned DOM converting modern color functions to RGB before rasterization, guarded by the loudest comment in the codebase. The real fix shipped this month: the whole thumbnail pipeline is gone. Dashboard previews now render live from the Markdown itself — same react-markdown pipeline, scaled down — so they're never a stale snapshot, and html2canvas left the dependency tree entirely. Sometimes the best patch is pnpm remove.

What's free, honestly

The free tier isn't a demo. Without even creating an account: full editor, live preview, all 32 templates, un-watermarked PDF export, LinkedIn import, and it works in all six languages. A free account adds cloud sync for one resume and a public profile page. The paid tier ($9/month, with $24/quarter and $89/year options) is the workflow layer — unlimited synced resumes, DOCX/HTML/TXT export, AI job tailoring, version history. I gate convenience and AI compute, not the core tool. That's partly principle and partly architecture: the localStorage-first design means the free tier literally costs me almost nothing to serve — and free, un-watermarked PDF export is a published commitment on the site now, not a growth experiment I might quietly walk back.

Also, a confession that doubles as a warning about naming things: after launch I discovered "ResumeMD" collides with an unrelated open-source project, a .org, and at least one other hosted app — and the GitHub repo outranks me for my own name. Check the SERP before you fall in love with a name. I now write "ResumeMD (resumemd.pro)" everywhere, including here.

The test suite is at 2,978 tests across 105 files, most of which exist because a template, script, or sync bug got past me once. If you've fought @react-pdf font embedding or Lightning CSS selector rewriting, I'd genuinely like to compare notes in the comments.

And if you have AI-generated resume text sitting in a chat window right now — paste it into resumemd.pro and see what it looks like typeset. No signup, it runs in your browser.

Top comments (4)

Collapse
 
amitfeldman profile image
Amit Feldman

Cleanest launch-day config I've scanned this week, honestly — HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy all set, title/meta/h1/alt all tidy. One gap: no Content-Security-Policy.

Since you're on Next.js/Vercel, the low-risk way in is a report-only CSP first — add it in next.config.js under async headers() with the Content-Security-Policy-Report-Only header, watch what would break for a few days, then promote it to enforcing. Going straight to an enforcing CSP on a Next.js app usually breaks inline scripts/styles on day one.

Minor note: with a tool that pastes AI-generated resume text and renders it to PDF, a CSP is the one header that pays for itself — it's the main mitigation if anything injected ever ends up in that paste-to-render path.

Collapse
 
amitfeldman profile image
Amit Feldman

Font payload size is the classic wall for in-browser PDF work. Two tricks that help:

  1. Subset the font to just the glyphs the document actually uses — fonttools' pyftsubset does this in a few lines and routinely takes a multi-MB font down to a few hundred KB. If you're embedding in the PDF, embed the subset, not the full file.
  2. For full-coverage fonts served to the browser, use woff2 split by unicode-range so the browser only downloads the ranges the page actually touches.

Also worth checking whether you need to ship the font to the client at all — if the PDF is generated server-side or the glyphs are only needed at render time, keeping the font out of the bundle entirely is the cheapest fix.

Collapse
 
to21as profile image
Tobias

The font section is what I'd most like to compare notes on, since I run a hosted HTML-to-PDF service and fonts are most of the operational pain.

One specific trap in the script-detection design: CJK isn't one bucket. Japanese, Simplified Chinese and Traditional Chinese share Han codepoints, so a kanji-heavy Japanese resume is indistinguishable from Chinese by Unicode range alone, but they want different glyph forms. Render Japanese with Noto Sans SC and you don't get tofu, you get Chinese glyph variants for characters like 骨, 直, 今: legible, visibly wrong to a native reader, and invisible to your sample tests because nothing shows as a box. The usual disambiguators are the exclusive scripts (kana implies Japanese, Hangul implies Korean) plus an explicit language choice when the text is Han-only, because there's no reliable codepoint answer for that case. My own render container installs the Japanese and Chinese faces separately (IPAGothic and WenQuanYi Zen Hei) rather than one CJK font, for exactly this reason.

On the 17MB, worth separating load cost from artifact cost. Check what actually lands in the output: if the embedding is subsetted to used glyphs, a CJK resume's PDF stays small and the 17MB is purely a fetch you can defer until script detection fires. If the full face goes in, that's 17MB per generated resume, which is a different problem with a different fix (pre-subset the TTF to the ranges you support).

On the three-implementations-per-template tax: the reason a browser-based PDF path doesn't have it is that the preview and the PDF are the same renderer, one CSS implementation instead of three. I won't pretend that's a free swap for you, though. It needs a server, and it would put a backend call in the middle of the localStorage-first free tier that's the entire point of your architecture. For your constraints @react-pdf is the defensible call, and the duplication is what you're paying for the offline guarantee.

Collapse
 
online366 profile image
online365

Generating PDF font files in a browser is a troublesome problem; the font files are too large.