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

ApproachBest forTradeoff
ChangeThisFile APIProduction SaaS, varied user uploads, serverlessPer-call cost, network dependency
libreoffice-convertBackend services with control over containers500MB+ image size, single-instance bottleneck
mammoth + puppeteerAir-gapped or pure-JS-only environmentsLower 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.body directly 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.