How I Built a Client-Side Legal Document Generator
A Lagos agribusiness needed land contracts automated. Here's how I did it entirely in the browser — no backend, no server costs.
Abdulyekeen Maadan
June 29, 2026 · 7 min read
A Lagos agribusiness needed to stop generating land contracts in Microsoft Word. Here's how I automated it — entirely in the browser, no backend, no server costs, and no sensitive data leaving the client's device.
The Problem
When a client purchases farmland from a real estate investment company, two legal documents need to be generated immediately:
- A Deed of Assignment — transferring ownership of the land plot
- A Farm Estate Management/Ownership Agreement — governing the farming relationship and ROI terms
The old process: open a Word template, manually find and replace the client's name, calculate the plot price, figure out the 36% annual ROI, type the amount in words, adjust the documentation fees, repeat across both documents, pray nothing was missed.
Every transaction touched by human hands is a transaction that could have the wrong figure, the wrong name, or the wrong clause. In a legal document, that's not a typo — it's a liability.
The ask was simple: build a tool where staff enter client details and plot count, and both documents generate instantly as downloadable PDFs.
Architecture: Why I Went Fully Client-Side
The instinct for most developers is to reach for a backend PDF solution — Puppeteer, a headless Chrome instance, an HTML-to-PDF API. I deliberately chose not to.
The problems with backend PDF generation:
- Spinning up a headless browser is slow. We're talking 3–8 seconds per render depending on infrastructure.
- Running PDF renderers on serverless functions is expensive at scale. CPU-heavy operations burn through execution time fast.
- Most importantly: this tool processes real legal documents containing client names, addresses, and transaction amounts. Sending that data to any server — even your own — is a data handling responsibility you have to architect around.
The solution: @react-pdf/renderer
This library renders PDF documents inside the browser using React components. The entire pipeline runs on the user's CPU:
Zero backend. Zero server costs. Zero data transmission. The client's details never leave their device.
The tradeoff is that you lose server-side processing power and caching, but for a document generator of this scope, that's an entirely acceptable trade.
The Business Logic
Both documents share a single calculation engine derived from the company's fixed pricing structure:
const PLOT_PRICE = 2_999_999 // ₦2,999,999 per plot — not ₦3M, intentional
const ANNUAL_ROI_RATE = 0.36 // 36% guaranteed annual return
const DEED_FEE = 100_000 // per plot
const SURVEY_FEE = 250_000 // per plot
function calculateTotals(plots: number, deposit?: number) {
const plotPrice = PLOT_PRICE * plots
const annualROI = plotPrice * ANNUAL_ROI_RATE
const deedFee = DEED_FEE * plots
const surveyFee = SURVEY_FEE * plots
const totalDocFees = deedFee + surveyFee
const totalPayable = plotPrice + totalDocFees
const balance = deposit ? plotPrice - deposit : 0
// Installment duration: 3 months for 1 plot, 6 months for 2+
const installmentDuration = plots >= 2 ? 6 : 3
return { plotPrice, annualROI, deedFee, surveyFee, totalDocFees, totalPayable, balance, installmentDuration }
}One important detail: the plot price is ₦2,999,999 — not ₦3,000,000. That's deliberate pricing psychology on the company's part. The generator preserves it exactly.
The tool also handles two payment scenarios with conditional document content:
- Full payment: straightforward lump sum clause
- Installment: deposit amount, balance, duration, and adjusted clause wording — all auto-populated
The Bug Log
This is where it gets interesting. Here's every technical problem I hit and how I solved each one.
Bug 1: The Naira Symbol Rendered as a Broken Pipe (¦)
Every ₦ in the document came out as ¦.
Why: @react-pdf/renderer defaults to standard PDF core fonts — Helvetica, Times-Roman. These are legacy fonts that predate Unicode's Naira symbol (U+20A6). The glyph simply doesn't exist in their character maps.
Fix: Register a modern Unicode-compliant font via Font.register(). Roboto contains the correct Naira glyph and renders it cleanly.
Bug 2: Google Fonts CDN Returned 404s
After switching to Roboto, font loading failed with a 404.
Why: I pointed Font.register() at Google Fonts' CDN .woff2 URLs. Google dynamically re-hashes these filenames during font updates. The URL I hardcoded became stale.
Fix: Use the stable, versioned .ttf URLs from Google's static servers (locked to a specific version tag) instead of the dynamic CDN paths. Better yet — download the font file locally and serve it from /public/fonts/. No external dependency, no surprise breakage.
Bug 3: The fi/fl Ligature Bug — Characters Dropping Silently
Words containing fi, fl, or ff were rendering with missing characters. "first" became "frst". "five" became "fve". "affixed" became "afxed".
This one was subtle. The PDFs looked mostly fine at a glance but read wrong on close inspection — exactly the kind of bug that would embarrass you in a legal document.
Why: @react-pdf's underlying font shaping engine (fontkit) reads a font's GSUB table — the Glyph Substitution table. When it encounters f followed by i, it substitutes both characters with a single ligature glyph (fi). The problem is the renderer then fails to compile that ligature glyph into the final PDF output, leaving a blank where the ligature was.
Fix: Strip the GSUB table from the font file entirely. A custom Node.js script parses the downloaded Roboto TTF binary and zeroes out the GSUB table data, replacing it with a minimal empty stub. Without ligature mappings, the engine renders every character individually — f and i separately — which is exactly what we need.
Bug 4: Automatic Word Hyphenation Breaking Legal Copy
Standard words were being split mid-word across line breaks with hyphens. Legal text does not hyphenate. It looked wrong immediately.
Why: @react-pdf applies an automatic hyphenation algorithm to text wrapping by default.
Fix: One line:
Font.registerHyphenationCallback(word => [word])This tells the layout engine never to split any word, ever. Line wrapping happens at word boundaries only.
Bug 5: Number-in-Words Failing Beyond 10
For small plot counts everything looked right — "THREE (3) PLOTS". For larger purchases it fell apart — "32 (32) PLOTS".
Why: The initial implementation used a static array lookup that only covered 0–10. Anything beyond that fell through to the raw number.
Fix: Replace the static array with the number-to-words npm package. It handles any integer correctly and dynamically — "THIRTY-TWO (32) PLOTS", "ONE HUNDRED AND TWENTY (120) PLOTS", whatever the input.
Bug 6: A Ghost Hyphen Appearing Before the Amount in Words
Page 2 of the Deed displayed: ₦32,999,989.00 (-Thirty-Two Million...)
That hyphen before "Thirty" wasn't in the template. It appeared out of nowhere.
Why: The markup was structured as {priceInFigures} ({priceInWords}). When the text justified and wrapped, the line break landed exactly between the opening parenthesis ( and the first word. The layout engine inserted an automatic hyphen at the line break point, visually producing (-.
Fix: Combine the values into a single string using a non-breaking space (\u00A0):
const priceDisplay = `${priceInFigures}\u00A0(${priceInWords})`The non-breaking space forces the engine to treat the figure and the opening parenthesis as one unbreakable unit. The line break moves elsewhere and the hyphen disappears.
Bug 7: A Phantom Blank Page in the Farm Agreement
Page 2 of the Farm Agreement was completely empty. Content jumped from page 1 to page 3.
Why: The document had separate <Page> containers for the introductory clauses and the WHEREAS section. The introductory text overflowed its A4 height limit, causing the engine to generate a continuation page. The next explicit <Page> component then started fresh, leaving the overflow continuation blank.
Fix: Merge the introductory section and WHEREAS clauses into a single <Page> container and let the content flow naturally. The engine handles pagination automatically from that point. The document went from 5 pages to 4 pages — the correct count.
What the Tool Actually Does
Input: client name(s), address, number of plots, payment type, date.
Output: two legally structured, correctly calculated, print-ready PDF documents — generated in under two seconds, entirely in the browser.
The summary card updates live as you type. All figures recalculate in real time. Both PDFs download with one click after generation.
The tool is hosted as a demo at Maadan Tools under the client's branding before handover.
Takeaways
- Host your fonts locally. CDN font URLs break silently and at the worst possible time. Serve from
/public/fonts/and eliminate the dependency. - Know your renderer's limitations before you start.
@react-pdf/rendereris powerful but it has sharp edges — ligatures, hyphenation, and page overflow all behave differently than you'd expect from a browser layout engine. Read the internals before you hit them as bugs. - Client-side PDF generation is underused. Most developers default to backend solutions out of habit. For document generators handling sensitive data with moderate complexity, running everything in the browser is faster, cheaper, and more private.
- The boring problems are the expensive ones. None of these bugs were architecturally interesting. They were font encoding, string formatting, and page container scoping. But each one would have made the tool look broken in front of a paying client. The craft is in catching all of them.
Built with Next.js, @react-pdf/renderer, and number-to-words. Deployed on Vercel.
"The room you’re in is already deciding how far you go."
Start a Conversation