JavaScript DOCX-to-PDF is a less mature ecosystem than Python's. The good options call out to LibreOffice. The pure-JS options (mammoth + html-pdf or pdfkit) sacrifice formatting fidelity. For production user uploads where formatting matters, you almost always want a server-side renderer — either yours or a hosted API.
Method 1: ChangeThisFile API (works in Node and browsers)
The API uses LibreOffice headless on its servers, no install required. Get a free API key — 1,000 conversions/month on the free tier.
import fs from "node:fs";
const API_KEY = "sk_test_your_key_here";
async function docxToPdf(docxPath, outputPath) {
const buffer = fs.readFileSync(docxPath);
const form = new FormData();
form.append("file", new Blob([buffer]), "input.docx");
form.append("source", "docx");
form.append("target", "pdf");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(`Conversion failed (${response.status}): ${err.error || "unknown"}`);
}
const pdfBuffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(outputPath, pdfBuffer);
}
await docxToPdf("contract.docx", "contract.pdf");
For browser usage, take the file from an <input type="file">:
async function docxToPdfBrowser(file) {
const form = new FormData();
form.append("file", file);
form.append("source", "docx");
form.append("target", "pdf");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
return URL.createObjectURL(blob); // returns a downloadable URL
}
Reminder: do not embed your API key in client bundles. Proxy through your backend or use scoped session keys.
Method 2: libreoffice-convert (Node, local LibreOffice)
If you can install LibreOffice on your server, libreoffice-convert wraps the headless command-line conversion in a Node API.
apt-get install libreoffice
npm install libreoffice-convert
import fs from "node:fs/promises";
import libre from "libreoffice-convert";
import { promisify } from "node:util";
const convert = promisify(libre.convert);
async function docxToPdf(docxPath, pdfPath) {
const docxBuf = await fs.readFile(docxPath);
const pdfBuf = await convert(docxBuf, ".pdf", undefined);
await fs.writeFile(pdfPath, pdfBuf);
}
await docxToPdf("contract.docx", "contract.pdf");
Same caveats as headless LibreOffice in any language: single-instance bottleneck (concurrent conversions queue), slow first-call startup (5-10s), and the binary itself adds 500MB+ to your container image. Fine for a backend service. Painful for serverless functions.
Method 3: mammoth + html-pdf (pure JS, lower fidelity)
If you cannot install LibreOffice and cannot call an external API, you can chain mammoth (DOCX -> HTML) and a HTML-to-PDF tool. This is pure JS but loses a lot of formatting because mammoth's HTML output is intentionally simplified.
npm install mammoth puppeteer
import fs from "node:fs/promises";
import mammoth from "mammoth";
import puppeteer from "puppeteer";
async function docxToPdf(docxPath, pdfPath) {
// Step 1: DOCX -> HTML
const buffer = await fs.readFile(docxPath);
const { value: html } = await mammoth.convertToHtml({ buffer });
// Step 2: HTML -> PDF via headless Chrome
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle0" });
await page.pdf({ path: pdfPath, format: "A4", printBackground: true });
await browser.close();
}
await docxToPdf("contract.docx", "contract.pdf");
Use this when:
- The DOCX is simple (text-heavy, minimal tables, no complex layout)
- You can accept that headers/footers, page numbers, and exact pagination will not match Word output
- You cannot make outbound HTTP calls (air-gapped environment)
For anything more complex, use the API or libreoffice-convert.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| ChangeThisFile API | Production SaaS, varied user uploads, serverless | Per-call cost, network dependency |
| libreoffice-convert | Backend services with control over containers | 500MB+ image size, single-instance bottleneck |
| mammoth + puppeteer | Air-gapped or pure-JS-only environments | Lower formatting fidelity, no exact Word pagination |
For SaaS document workflows, the API is the simplest path: zero server-side dependencies, scales with user load, and uses the same LibreOffice renderer you would install locally — except you skip the install and the bottleneck.
Production tips
- Set timeouts above 120s. Big DOCX files with embedded images can take a while. The default 30s fetch timeout will cut off long conversions.
- Stream the response if you can. For huge PDFs, use
response.bodydirectly instead of buffering the whole thing into memory. - Avoid concurrent libreoffice-convert calls. They queue at the LibreOffice level and can deadlock on the user profile directory. The API handles concurrency cleanly because it manages a pool of LibreOffice instances behind the scenes.
- Test with real user uploads, not your own DOCX. User-generated DOCX is much messier than what you produce in Word. Tracked changes, weird styles, embedded objects, corrupted OOXML — they all happen.
If you control the deployment and can install LibreOffice, libreoffice-convert is solid. For SaaS apps with user uploads, the API removes operational pain — no LibreOffice in your container, no concurrency bottleneck, scales with traffic. Free tier gives 1,000 conversions/month.