Google Docs as a Template Engine: DOCX to PDF with LibreOffice
Your app generates contracts. Or invoices, or work agreements, or a privacy policy someone has to sign. Whatever they are, two things are true about them: they are mostly prose, and the prose is not yours. It belongs to whoever is accountable for what it says — a lawyer, an operations lead, a founder. Not you.
So how do they change a sentence?
The two usual answers
Put the template in the repo. An HTML file, a Handlebars template, some JSX rendered to PDF. This is the reflex, and it works right up until someone needs a clause reworded. Then a legal change becomes a code change: a ticket, a branch, a review, a deploy. The people who own the words cannot touch them; the people who can touch them do not own the words. Every comma is priced as an engineering task, and — worse — the person who cares most about the wording ends up describing it to someone who cares least.
Buy a document-generation service. These are real products and they solve a real problem, but they charge for it: per document, per template, or per seat. The bill grows with exactly the thing you want to grow. And your contracts — names, ID numbers, addresses, signatures — leave your infrastructure to be rendered on someone else's.
Both answers accept the same premise: that the document is a thing your system owns and someone else requests changes to.
What if it weren't?
Here is the reframe. The only contract between the document and your code is the variable names.
The document lives where non-engineers already write — Google Docs. They write prose, they format it, they mark the blanks. Your code never knows what the document says. It knows only which blanks to fill:
EMPLOYER_NAME, EMPLOYEE_NAME, START_DATE, MONTHLY_SALARY
Reword a clause? Edit the doc. Add a signature block? Edit the doc. Change the payment terms three days before a launch? Edit the doc. None of those is a deploy, a PR, or a conversation with an engineer. Adding a new field is the only change that touches code, and it is one key in an object.
This is how I generate every contract, invoice and work agreement on a platform currently running in production. Here is the whole pipeline, then the four things that will break it.
Google Doc ──export──▶ .docx ──docxtemplater──▶ filled .docx │ soffice --headless --convert-to pdf ▼ PDF ──qpdf encrypt──▶ served
The code below is plain TypeScript so you can lift it anywhere. In production these are NestJS services with the usual injected dependencies; none of that matters to the parts worth reading.
The one bit of syntax
The reframe only holds if the syntax is something a non-engineer types correctly on the first try. {{mustache}} is not that. Neither is ${dollar_brace}.
Square brackets are:
This agreement is made between [EMPLOYER_NAME] and [EMPLOYEE_NAME], beginning [START_DATE], for a monthly salary of [MONTHLY_SALARY] FCFA.
Nobody needs a tutorial to read that, and nobody needs one to write it either — brackets already mean "fill this in" to anyone who has seen a paper form. docxtemplater defaults to {{ }}, so this is a configuration choice you make once:
import PizZip from "pizzip"; import Docxtemplater from "docxtemplater"; const DELIMITERS = { start: "[", end: "]" } as const;
That is the entire syntax the system asks a human to learn. Keep it that way — the moment you need loops and conditionals in the document, you have started building a programming language for people who did not ask for one.
Exporting the doc
Google Docs will hand you a .docx over plain HTTP. No API key, no OAuth dance, no googleapis dependency:
const EXPORT_BASE = "https://docs.google.com/feeds/download/documents/export/Export"; /** Pull the document ID out of any Google Docs URL a human might paste. */ export function extractDocId(url: string): string | null { return /\/document\/d\/([a-zA-Z0-9_-]+)/.exec(url)?.[1] ?? null; } export async function exportAsDocx(docId: string): Promise<Buffer> { const res = await fetchWithTimeout( `${EXPORT_BASE}?id=${docId}&exportFormat=docx`, { redirect: "follow" }, 15_000, ); if (!res.ok) { throw new Error( `Could not export Google Doc (HTTP ${res.status}). Make sure the ` + `document is set to "Anyone with the link can view".`, ); } return Buffer.from(await res.arrayBuffer()); }
The only setup is on the document: Share → Anyone with the link → Viewer. An admin pastes the URL into your admin panel and you are done.
Two details. redirect: "follow" is required — the export endpoint bounces you at least once. And every outbound call gets a timeout; a stalled fetch with no AbortController is how you pile up sockets until the process falls over:
export async function fetchWithTimeout( input: RequestInfo | URL, init: RequestInit = {}, timeoutMs = 10_000, ): Promise<Response> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(input, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } }
Gotcha #1: the 200 that isn't
The code above is not finished, and this is the first thing that will bite you.
Ask for a document that isn't shared and Google does not return 403. It returns HTTP 200 — with a sign-in page. res.ok is true. You hand a chunk of HTML to a ZIP parser and get back a stack trace about a corrupt archive, which tells the admin nothing about the one setting they got wrong.
Check the content type:
const contentType = res.headers.get("content-type") ?? ""; if (contentType.includes("text/html")) { throw new BadRequestException( 'Google Doc is private or not accessible. Set sharing to ' + '"Anyone with the link can view" and try again.', ); }
The wording matters more than the check. This error is read by an operations person who pasted a link, not by you. It should name the setting and the value.
Filling the blanks
docxtemplater does the work. A .docx is a ZIP of XML, PizZip opens it, and the templater walks the document tree replacing tags:
export function fillTemplate( docxBuffer: Buffer, data: Record<string, string>, ): Buffer { let zip: PizZip; try { zip = new PizZip(docxBuffer); } catch { throw new UnprocessableEntityException("Failed to parse template file"); } const template = new Docxtemplater(zip, { delimiters: DELIMITERS, paragraphLoop: true, linebreaks: true, }); try { template.render(data); } catch (err: any) { // docxtemplater buries the useful part. Dig it out — "Template rendering // failed" is useless to whoever has to fix the document. const tags = err?.properties?.errors ?.map((e: any) => e.properties?.id) .filter(Boolean) ?? []; throw new UnprocessableEntityException( tags.length ? `Missing tags: ${tags.join(", ")}` : "Template rendering failed", ); } return template .getZip() .generate({ type: "nodebuffer", compression: "DEFLATE" }); }
linebreaks: true makes \n in your data render as an actual line break instead of a literal \n in the PDF — you will want it the first time someone passes a multi-line address.
That catch block is worth the space. Someone adds [WITNESS_NAME] to the document, nobody adds it to the payload, and generation starts failing. "Missing tags: WITNESS_NAME" turns a debugging session into a five-second fix.
Letting the document declare its own fields
Here is what makes the admin panel pleasant rather than a guessing game. docxtemplater ships an inspection module that reports every tag in a template, so the document tells you what it needs:
export function extractVariables(docxBuffer: Buffer): string[] { try { // CJS-only — this one genuinely needs require(), not import. const InspectModule = require("docxtemplater/js/inspect-module.js"); const iModule = InspectModule(); const doc = new Docxtemplater(new PizZip(docxBuffer), { delimiters: DELIMITERS, paragraphLoop: true, linebreaks: true, modules: [iModule], }); doc.compile(); const tags = iModule.getAllTags(); return tags && typeof tags === "object" ? Object.keys(tags) : []; } catch { // A template we cannot inspect is still a template worth storing. return []; } }
Run this at import time, store the result, and your admin UI can render the exact list of fields a template expects — without anyone maintaining documentation that drifts.
Note doc.compile() rather than render(): you are parsing the template, not filling it, so there is no data to supply.
The swallowed error is deliberate, not lazy. A template whose variables cannot be extracted should still be storable; you lose the field list, not the document. Failing the upload because introspection failed would be the wrong trade.
Live export, with a snapshot underneath
This is the payoff, and it is about fifteen lines.
When you import a Google Doc, store the exported .docx in your object storage — but when you actually fill the template, re-export it from Google first:
async function loadTemplate(doc: TemplateRecord): Promise<Buffer> { if (doc.sourceMode !== "GOOGLE_DOCS" || !doc.googleDocsId) { return fetchStoredSnapshot(doc); } try { return await exportAsDocx(doc.googleDocsId); } catch (err) { // A sharing change or a real HTTP error is the admin's problem to fix, // so let it surface. A network blip is not — fall back and stay up. if (err instanceof HttpException) throw err; logger.warn( `Live export failed for ${doc.googleDocsId}; using stored snapshot`, ); return fetchStoredSnapshot(doc); } }
An edit in Google Docs takes effect on the very next document generated. No re-import, no cache to bust, no "did you remember to re-upload it?" The stored copy is insurance against Drive being unreachable, not the source of truth.
That distinction between error kinds is the part to copy. If the doc was un-shared, the admin needs to know immediately — silently serving a stale snapshot would hide it for weeks. If Google merely timed out, serving the snapshot is strictly better than failing a contract signature.
DOCX to PDF
Nobody signs a .docx. LibreOffice converts, headlessly, and it is the same engine that renders the document in Writer — so what you get out matches what the admin saw when they wrote it.
First, find it. Developers install it in different places and you cannot assume a path:
const CANDIDATES = [ "soffice", "/usr/bin/soffice", "/usr/local/bin/soffice", String.raw`C:\Program Files\LibreOffice\program\soffice.exe`, String.raw`C:\Program Files (x86)\LibreOffice\program\soffice.exe`, ]; async function findSoffice(): Promise<string | null> { for (const candidate of CANDIDATES) { try { await execFileAsync(candidate, ["--version"]); return candidate; } catch { // not here, try the next one } } return null; }
Probing with --version rather than stat-ing the path also confirms the binary actually runs.
Then convert. This looks like a one-liner and is not:
soffice --headless --convert-to pdf --outdir /tmp input.docx
Gotcha #2: two conversions at once
Run that command twice at the same moment and the second one hangs, or dies, or — worst — returns a PDF built from the wrong settings.
LibreOffice was designed as a desktop application. It assumes one user, sitting at one computer, running one copy, against one user profile directory. Two concurrent headless conversions sharing that profile are two copies of Word fighting over the same settings file. Your server does not know this. It will happily fire six conversions at once the moment six people click "download contract".
The fix is a throwaway profile per invocation:
const ts = Date.now(); const rand = Math.random().toString(36).slice(2, 7); const loUserDir = path.join(os.tmpdir(), `lo_user_${ts}_${rand}`); // -env:UserInstallation takes a file:// URI, not a path. On Windows the // backslashes have to be normalised or LibreOffice ignores the whole flag // and silently falls back to the shared profile — the bug you were fixing. const posix = loUserDir.replaceAll("\\", "/"); const loUserDirUri = `file://${posix.startsWith("/") ? "" : "/"}${posix}`; await execFileAsync(soffice, [ `-env:UserInstallation=${loUserDirUri}`, "--headless", "--convert-to", "pdf", "--outdir", tmpDir, inputPath, ]);
Both the timestamp and the random suffix are load-bearing: two requests inside the same millisecond are not rare under load, and a collision puts you right back where you started.
It costs a few hundred milliseconds of profile setup per conversion. Pay it.
Gotcha #3: fonts fail silently
A missing font is not an error. LibreOffice substitutes something close, the conversion succeeds, and the PDF comes out subtly wrong — different metrics, different line breaks, a two-page contract that is now three pages with a signature block stranded alone at the top of page three.
Nobody notices until a client does.
Two halves to the fix. First, install real fonts in the image and rebuild the font cache:
| Package | Why |
|---|---|
fonts-liberation | Metric-compatible with Arial, Times New Roman, Courier New — what documents written in Google Docs actually ask for |
fonts-dejavu-core | Broad Unicode coverage, sane fallback |
fonts-noto-core | Accented and non-Latin characters |
fontconfig | Provides fc-cache, without which the installed fonts are not found |
RUN apt-get update && apt-get install -y --no-install-recommends \ libreoffice-writer \ fonts-liberation \ fonts-dejavu-core \ fonts-noto-core \ fontconfig && \ fc-cache -f && \ rm -rf /var/lib/apt/lists/*
libreoffice-writer, not libreoffice — you need Writer, not Calc, Impress and Draw. It is a large difference in image size.
Second, turn substitution off, so a font problem is visible rather than quietly approximated. LibreOffice reads its configuration from the profile directory you are already creating, so write the setting into it:
const fontSubXcu = `<?xml version="1.0" encoding="UTF-8"?> <oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <item oor:path="/org.openoffice.VCL/SubstFonts"> <prop oor:name="FontPairs" oor:type="oor:string-list"><value/></prop> </item> </oor:items>`; await fs.mkdir(loUserDir, { recursive: true }); await fs.writeFile( path.join(loUserDir, "registrymodifications.xcu"), fontSubXcu, "utf8", );
An empty FontPairs list means "no substitution pairs". You would rather see a wrong glyph in QA than ship a correctly-rendered document with quietly wrong pagination.
Clean up in a finally, all of it — input, output, and the profile directory:
} finally { await fs.unlink(inputPath).catch(() => {}); await fs.unlink(outputPath).catch(() => {}); await fs.rm(loUserDir, { recursive: true, force: true }).catch(() => {}); }
Skip this and you will find out how many profile directories fit in /tmp.
A fallback for laptops
Not every developer wants LibreOffice installed to run the test suite. When findSoffice() returns null, fall back to converting DOCX → HTML with mammoth and printing that with Puppeteer:
export async function convertToPdf(docxBuffer: Buffer): Promise<Buffer> { const soffice = await findSoffice(); return soffice ? await convertWithLibreOffice(docxBuffer, soffice) : await convertWithPuppeteer(docxBuffer); }
Be honest about what this is. It is lossy — mammoth maps a subset of DOCX to HTML, and complex tables, headers, footers and precise spacing do not survive. It exists so pnpm dev works on a fresh clone. It is not a second production renderer, and if you find yourself tuning its CSS to match LibreOffice output, install LibreOffice instead.
Locking the PDF
A generated contract should be printable and not editable. qpdf handles it — an owner password with an empty user password means anyone can open it, nobody can alter it:
await qpdf.encrypt(inputPath, { keyLength: 128, password: { owner: process.env.PDF_OWNER_PASSWORD, user: "", // empty: no password prompt on open }, restrictions: { print: "full", modify: "n", copy: "n", useSubset: "n", modifyAnnotations: "n", }, outputFile: outputPath, });
And one deliberate trade worth naming out loud:
} catch (err: any) { if (err?.code === "ENOENT") { logger.warn( "qpdf binary not found — returning unprotected PDF. " + "Install qpdf for production use.", ); return pdfBuffer; } throw err; }
A missing qpdf degrades to an unprotected PDF instead of failing the request. That is the right call for a local environment and an uncomfortable one for production, which is why it logs loudly. Make that decision consciously — and if the document must never be unprotected, delete this branch and let it throw.
Cache on the way out, invalidate by template
Generating a PDF costs a Google round-trip plus a LibreOffice process. Cache the result. The interesting part is the key:
pdf:contract:<contractId>:<templateId> pdf:invoice:<invoiceId>:<templateId>
The template ID goes last, and that is the whole design. When somebody edits a template, every PDF derived from it — across contracts, invoices, agreements, all of them — has to go. A trailing template ID makes that a single pattern:
async function invalidateByTemplate(templateId: string): Promise<void> { const pattern = `pdf:*:${templateId}`; let cursor = "0"; do { const [next, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); if (keys.length) await redis.del(...keys); cursor = next; } while (cursor !== "0"); }
SCAN, not KEYS. KEYS blocks the entire Redis server while it walks the keyspace, which on a shared instance means blocking every other service you run. SCAN is a cursor and yields between batches. The difference is invisible with a thousand keys and an outage with a million.
Running it in a container
Three environment variables, none of them obvious, all of them the difference between working and hanging:
ENV HOME=/tmp \ SAL_USE_VCLPLUGIN=svp \ DISPLAY=""
HOME=/tmp— LibreOffice wants a writable home directory. Running as a non-root user without one, it fails in a way that does not mention home directories.SAL_USE_VCLPLUGIN=svp— the headless backend. Without it LibreOffice may try to reach a display server that isn't there.DISPLAY=""— belt and braces for the same reason.
Then the memory arithmetic, which surprises people:
ENV NODE_OPTIONS="--max-old-space-size=512"
In a 700 MB container, the Node heap is capped at 512 MB — deliberately below what Node would otherwise take — because LibreOffice needs the rest. It is a full office suite; it is not shy about memory. If you size the container for your Node process and then spawn soffice inside it, the OOM killer will teach you this at an inconvenient hour.
What it actually costs
Go back to the opening. The bill for this approach is the four gotchas above: concurrency, fonts, the 200 that isn't, and memory. You pay them once, in code you own, and they stay paid.
That is not automatically the right trade:
- If the documents only ever change when engineers change them, a template in the repo is genuinely simpler and you should use one. This whole design exists to get engineers out of the loop; if they were never in it, you are buying nothing.
- If you render thousands of documents an hour, spawning an office suite per document is the wrong shape and you want a real typesetting pipeline.
- If the layout must be pixel-exact, LibreOffice's DOCX rendering is very good but it is an implementation of someone else's format.
What you get for it is the thing that is hard to buy: the person accountable for the words can change the words, today, without asking anyone. On the day a regulator changes a required disclosure, that stops being a nice property and starts being the only one that matters.