A printed Word document emerging from a printer on a desk, with a large red Maine Coon cat resting nearby.

Word Documents Without Word: Generating .docx Files with docx

The Orange Cat
The Orange Cat

Sooner or later, almost every app needs to hand a user a real Word document. Invoices, contracts, reports, certificates, exports that someone in accounting will absolutely open in Microsoft Word and not in a fancy web preview. The trouble is that a .docx file is not a friendly format. It is a ZIP archive full of XML parts following the Office Open XML spec, and writing that XML by hand is a fast track to corrupt files and despair.

docx is the library that makes this painless. It gives you a declarative, strongly typed object model, things like Document, Paragraph, TextRun, and Table, and quietly handles serializing all of it into valid OOXML and zipping it up for you. There is no Microsoft Office requirement, no headless Word instance, no fragile server-side automation. It is pure JavaScript that runs identically in Node and in the browser, which means you can offer a "Download as Word" button that builds the whole file client-side with zero backend round trips. With around 10 million weekly downloads and used in production by teams like Proton, it is one of those quietly essential packages.

Why Reach for docx

The historical ways to generate Word files were all unpleasant in their own way. Server-side Office automation needs Word installed, only runs on Windows, and falls over under load. Hand-rolling OOXML is verbose and a single malformed tag means a broken document. Template engines are great when you have a fixed layout but awkward the moment your structure becomes dynamic.

docx occupies the sweet spot for documents that are built structurally from code. Here is what makes it pleasant to work with:

  • A declarative, TypeScript-first API that mirrors Word's own concepts. Sections, paragraphs, runs, tables, and headings all map to objects you can discover through autocomplete.
  • Isomorphic by design. The authoring code is identical in Node and the browser. Only the final export call differs.
  • No native dependencies. It deploys anywhere a bundle of JavaScript can run, including serverless and containers.
  • Genuinely full-featured. Tables with merged and shaded cells, inline and floating images, headers and footers, bullet and numbered lists, a table of contents, custom styles, page setup, and even tracked changes.

One honest caveat up front: docx generates documents, it does not read or render them. If you need to display an existing .docx in the browser, pair it with something like mammoth or docx-preview. Its job is creation, and it does that job extremely well.

Getting It Installed

The package ships ESM, CommonJS, and TypeScript types in one tidy bundle.

npm install docx

Or with yarn:

yarn add docx

For browser downloads you will usually want a small helper to trigger the file save:

npm install file-saver

That is the entire setup. No build plugins, no peer dependencies to chase.

Your First Document

The mental model is a tree. A Document holds an array of sections, each section holds block-level children like paragraphs, and each Paragraph holds inline children like text runs. Once the tree is described, a Packer turns it into a file.

import { Document, Packer, Paragraph, TextRun } from "docx";

const doc = new Document({
  sections: [
    {
      children: [
        new Paragraph({
          children: [
            new TextRun("Hello World. "),
            new TextRun({
              text: "This part is bold.",
              bold: true,
            }),
          ],
        }),
      ],
    },
  ],
});

A TextRun is the smallest unit of formatted text. You can pass it a plain string for unstyled content, or an options object to control bold, italics, size, color, font, underline, and more. Multiple runs in one paragraph let you mix formatting on a single line, exactly like you would by selecting words in Word and changing their style.

Headings and Styled Text

Headings are just paragraphs with a heading level attached. The library wires them to Word's built-in heading styles, so they show up in the navigation pane and in a generated table of contents.

import { Document, HeadingLevel, Paragraph, TextRun } from "docx";

const titleParagraph = new Paragraph({
  text: "Quarterly Report",
  heading: HeadingLevel.HEADING_1,
});

const styledParagraph = new Paragraph({
  children: [
    new TextRun({
      text: "Important figures below",
      bold: true,
      italics: true,
      underline: {},
      color: "FF0000",
      size: 28,
      font: "Calibri",
    }),
  ],
});

Two gotchas worth tattooing on your wrist. First, size is measured in half-points, so size: 28 renders as 14pt and size: 24 as 12pt. It is very easy to end up with text that is twice as big or twice as small as you intended. Second, colors are six-digit hex without the leading #. Heading levels run from HEADING_1 through HEADING_6, plus a TITLE level for the document title.

Exporting the File

The Packer is where the runtime finally matters. Every method returns a Promise, so forgetting to await is a classic source of confusion.

In Node, export to a Buffer and write it to disk or stream it down an HTTP response:

import * as fs from "fs";
import { Packer } from "docx";

const buffer = await Packer.toBuffer(doc);
fs.writeFileSync("report.docx", buffer);

In the browser, export to a Blob and hand it to file-saver:

import { Packer } from "docx";
import { saveAs } from "file-saver";

const blob = await Packer.toBlob(doc);
saveAs(blob, "report.docx");

There are also Packer.toBase64String(doc) and Packer.toStream(doc) for when you need to embed the document or pipe it incrementally. The authoring code above this line never changes, which is the whole point of the isomorphic design.

Going Further

Once the basics click, the more elaborate features follow the same declarative pattern. You just nest more objects.

Tables That Mean Business

Tables are the workhorse of generated reports, and docx models them with Table, TableRow, and TableCell. A table drops into a section's children right alongside paragraphs.

import {
  Document,
  Paragraph,
  Table,
  TableCell,
  TableRow,
  WidthType,
} from "docx";

const table = new Table({
  columnWidths: [3505, 5505],
  rows: [
    new TableRow({
      children: [
        new TableCell({
          width: { size: 3505, type: WidthType.DXA },
          children: [new Paragraph("Item")],
        }),
        new TableCell({
          width: { size: 5505, type: WidthType.DXA },
          children: [new Paragraph("Description")],
        }),
      ],
    }),
    new TableRow({
      children: [
        new TableCell({
          width: { size: 3505, type: WidthType.DXA },
          children: [new Paragraph("Coffee")],
        }),
        new TableCell({
          width: { size: 5505, type: WidthType.DXA },
          children: [new Paragraph("Essential developer fuel")],
        }),
      ],
    }),
  ],
});

Cell widths use WidthType.DXA, which is twentieths of a point (the unit Word calls "twips"), or WidthType.PERCENTAGE and WidthType.AUTO when you want the layout to flex. Cells support columnSpan and rowSpan for merging, plus shading and borders, so you can recreate just about any tabular layout an accountant might dream up.

Embedding Images

Pictures go in through ImageRun, which lives inside a paragraph just like a text run. It accepts the raw image bytes as a Buffer, a base64 string, a Uint8Array, or an ArrayBuffer.

import { Document, ImageRun, Packer, Paragraph } from "docx";
import * as fs from "fs";

const logo = new Paragraph({
  children: [
    new ImageRun({
      type: "png",
      data: fs.readFileSync("./logo.png"),
      transformation: { width: 120, height: 120 },
    }),
  ],
});

The transformation dimensions are in pixels. In recent v9 builds you will often need to specify the image type explicitly, one of "png", "jpg", "gif", "bmp", or "svg", so check your installed version's typings. In the browser there is no fs, so you fetch the bytes instead with fetch(url).then((r) => r.arrayBuffer()) and pass the resulting ArrayBuffer straight to data. Images can sit inline, float behind or around text, live inside table cells, or appear in headers and footers.

A Server-Side Download Endpoint

Tying it together, here is a framework-agnostic Node handler that builds a document from request data and streams it back as a proper Word attachment.

import { Document, Packer, Paragraph, TextRun, HeadingLevel } from "docx";

async function buildInvoice(items: { name: string; price: number }[]) {
  const lines = items.map(
    (item) =>
      new Paragraph({
        children: [
          new TextRun({ text: `${item.name}: `, bold: true }),
          new TextRun(`$${item.price.toFixed(2)}`),
        ],
      }),
  );

  const doc = new Document({
    sections: [
      {
        children: [
          new Paragraph({ text: "Invoice", heading: HeadingLevel.TITLE }),
          ...lines,
        ],
      },
    ],
  });

  return Packer.toBuffer(doc);
}

export async function handler(req, res) {
  const buffer = await buildInvoice(req.body.items);
  res.setHeader(
    "Content-Type",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  );
  res.setHeader("Content-Disposition", 'attachment; filename="invoice.docx"');
  res.send(buffer);
}

The two headers are what make a browser treat the response as a downloadable Word file rather than rendering bytes on screen. Notice how the document is assembled from a plain array of data using ordinary JavaScript like map and spread. That is the real strength of docx. Because the structure is just code, dynamic documents with a variable number of rows, sections, or pages are completely natural.

A Couple More Things to Know

A few sharp edges are worth keeping in mind. The current sections-based Document API replaced older patterns where you called methods like doc.addParagraph(...), so a lot of older tutorials and Stack Overflow answers will simply not compile against modern versions. Always match the docs to the major version you installed. The full library is roughly 113 KB gzipped, which is not nothing for a client bundle, so if your "Export to Word" feature is optional, lazy-load it. And remember that this library writes documents rather than reading them. To go the other direction, from .docx to HTML, reach for mammoth, which complements docx nicely rather than competing with it.

Wrapping Up

docx turns one of the more thankless tasks in software, producing a valid Microsoft Word file, into something that reads like ordinary, well-typed code. You describe content as a tree of familiar objects, pick a Packer method to match your runtime, and you are done. The same authoring logic powers a client-side download button and a server-side report endpoint without a single line of difference until the very last step.

If your documents are dynamic and defined in code, this is the tool to reach for. Leave the zipped XML to the library, keep your sanity, and let the Maine Coon on the windowsill judge your beautifully formatted invoices in peace.