mrkeyoor.com_
Thu 06 Aug 15:40 UTC
npmDataupdated 06 Aug 2026

exceljs

ExcelJS reads and writes real .xlsx workbooks from JavaScript. Not CSV pretending to be Excel, and not an HTML table with an .xls extension: it builds and parses the actual Office Open XML zip, so the output opens in Excel, Numbers, and LibreOffice with the formatting you asked for. The object model mirrors what you see in the application. A Workbook holds Worksheets, a worksheet has rows, columns, and cells, and a cell has a value plus style properties for font, fill, border, alignment, and number format. On top of that it covers most of the things people actually need from a spreadsheet export: merged cells, frozen panes, autofilters, data validation dropdowns, conditional formatting, defined names, cell comments, hyperlinks, images, tables, sheet protection, and formulas stored with an optional cached result. There are two modes. The document mode builds the whole workbook in memory and then writes it, which is simple and caps out at whatever your heap allows. The streaming mode writes rows to disk as you commit them and reads rows without holding the file, which is how you handle exports of hundreds of thousands of rows. It also reads and writes CSV through fast-csv, using the same worksheet objects.

Verdict

Still the most complete xlsx reader and writer in the Node ecosystem, and the only mainstream one with a real streaming mode, so for formatted exports and spreadsheet uploads it remains the default choice. Go in knowing the project has been quiet since early 2025 with a large issue backlog and an aging dependency tree, and that formulas are text you write rather than values it computes.

API stability5/5The 4.x API has not moved since 2020 and 4.4.0 has been the published version since October 2023, so nothing is going to break under you. That score reflects stasis as much as discipline: an API is very stable when no releases are happening
Docs3/5One README of roughly 200 KB with a table of contents, which covers essentially every feature with a runnable snippet and is genuinely useful once you learn to search it. What it lacks is structure and honesty about limits: no site, no versioned reference, differences between document and streaming mode scattered across sections, and a New Features list of merged pull request titles instead of a changelog
Maintenance2/5Last push 2025-01-21, last release October 2023, and 656 open issues out of 798 open issues and PRs, many of them reproducible bugs with patches attached. The code is stable and heavily exercised by 13M weekly downloads, so it is not broken, but nine runtime dependencies pinned to old majors means the security surface ages with nobody watching it
Ecosystem4/5About 13M downloads a week and it is what Node tutorials, admin panels, and report generators reach for; wide enough usage that almost any question already has a Stack Overflow answer. There is no plugin ecosystem, and the alternatives split awkwardly between write-only generators and the SheetJS packaging situation, which is part of why this one keeps its share

Use it if

  • You need to produce a formatted .xlsx that a finance or operations team will open: number formats, bold headers, frozen top row, column widths, and a filter, none of which CSV can carry
  • You are exporting more rows than fit comfortably in memory: the streaming writer commits each row and frees it, so a million-row export runs in a bounded footprint
  • You have to read uploaded spreadsheets, including styles, merged ranges, formulas, and cell comments, rather than just the raw values
  • You want Excel features that go beyond a grid of values: dropdown validation, conditional formatting rules, embedded images, autofilters, defined names, and sheet protection are all supported
  • You need the same library to handle CSV and XLSX with one worksheet API, so an endpoint can serve either format from the same building code
Skip it if

Setup reality

npm install exceljs works with no build step and the TypeScript definitions are in the package, but three things bite. First, memory. The document workbook holds every cell as an object, so a 200k-row file is gigabytes of heap and the process dies with an allocation failure rather than a useful error; anything large has to use ExcelJS.stream.xlsx.WorkbookWriter and commit rows as it goes. Second, the streaming API is a different API wearing the same clothes. Rows must be committed, a committed row is gone and cannot be edited, worksheets cannot be removed, unMergeCells is unavailable, and useStyles and useSharedStrings both default to false, so a streamed workbook comes out unstyled unless you opt in. Third, the type definitions are hand-written inside the repository rather than generated, and they lag the implementation, so you will hit valid runtime options that TypeScript rejects and typed properties that do not exist. Add to that a package.json declaring node >= 8.3.0 while the dependency set assumes something much newer, a dist/ folder whose contents are explicitly unsupported apart from the prebundled browser files, and a headless browser test in the suite that fails on Windows Subsystem for Linux unless you create a .disable-test-browser file. Budget an afternoon for the first non-trivial export, mostly spent discovering that column definitions are a building convenience and do not fully persist into the file.

Patterns

Build a workbook and write it to a filecreate-and-write

const ExcelJS = require("exceljs");

const workbook = new ExcelJS.Workbook();
workbook.creator = "reports-service";
workbook.created = new Date();

const sheet = workbook.addWorksheet("Orders", {
  views: [{ state: "frozen", ySplit: 1 }],
});

sheet.columns = [
  { header: "Id", key: "id", width: 10 },
  { header: "Customer", key: "customer", width: 32 },
  { header: "Total", key: "total", width: 14, style: { numFmt: '#,##0.00' } },
];

sheet.addRow({ id: 1, customer: "Acme", total: 1234.5 });
sheet.addRows(orders);            // array of objects keyed by column key

await workbook.xlsx.writeFile("orders.xlsx");

Setting sheet.columns writes the header row and gives every column a key, which is what lets addRow take an object instead of an array. The README warns that column structures are a building convenience: apart from width, they are not fully persisted, so reading the file back does not give you the keys again. Sheet names are validated and cannot contain the characters Excel forbids or exceed 31 characters, and addWorksheet throws rather than truncating.

Read an uploaded file and walk the rowsread-a-workbook

const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile("upload.xlsx");
// or: await workbook.xlsx.load(buffer)

const sheet = workbook.getWorksheet("Orders") ?? workbook.worksheets[0];

sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
  if (rowNumber === 1) return;                 // header
  const [, id, customer, total] = row.values;  // values is 1-based, [0] is empty
  console.log(id, customer, total);
});

// skip parts of the file you do not need
await workbook.xlsx.load(buffer, { ignoreNodes: ["dataValidations", "conditionalFormatting"] });

row.values is a sparse array whose index 0 is always undefined because columns are 1-based, which trips up every destructuring attempt exactly once. getWorksheet(name) returns undefined for a missing sheet rather than throwing, and worksheet ids are not the same as positions, so index by name or by worksheets[0]. ignoreNodes is the cheapest speed win when you only want values out of a heavily formatted file.

Write hundreds of thousands of rows without the heapstream-large-export

const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
  filename: "./big-export.xlsx",
  useStyles: true,        // defaults to false
  useSharedStrings: true, // smaller file, more memory while writing
});

const sheet = workbook.addWorksheet("Rows");
sheet.columns = [{ header: "Id", key: "id" }, { header: "Name", key: "name" }];

for await (const record of db.stream()) {
  sheet.addRow({ id: record.id, name: record.name }).commit();
}

sheet.commit();
await workbook.commit();   // resolves once the stream is flushed

This is the difference between an export that works and a container that gets OOM killed. Once a row is committed it is dropped and cannot be read or edited, so anything that needs a later row, such as a merge spanning rows, must stay uncommitted until both exist. useStyles and useSharedStrings default to false, so forgetting them gives you a plain unstyled sheet. Passing a stream instead of filename lets you pipe straight to an HTTP response, and awaiting workbook.commit() is what tells you the bytes are actually out.

Read a huge file row by rowstream-large-read

const reader = new ExcelJS.stream.xlsx.WorkbookReader("./huge.xlsx", {
  worksheets: "emit",
  sharedStrings: "cache",   // resolve strings into cell values
  styles: "ignore",         // skip style parsing for speed
  hyperlinks: "ignore",
});

for await (const worksheet of reader) {
  for await (const row of worksheet) {
    process(row.values);
  }
}

sharedStrings defaults to 'cache', and setting it to 'emit' or 'ignore' leaves cell values as numeric indexes into the shared string table instead of text, which looks like a parser bug if you did not choose it. styles: 'ignore' is a large speed win when you only want data. The README notes the worksheet iterator yields in batches for performance reasons, so do not rely on getting exactly one row object per iteration in every version.

Fonts, fills, borders, and number formatsstyle-cells

const header = sheet.getRow(1);
header.font = { bold: true, size: 12, color: { argb: "FFFFFFFF" } };
header.fill = {
  type: "pattern",
  pattern: "solid",
  fgColor: { argb: "FF1F4E78" },   // AARRGGBB, alpha first
};
header.alignment = { vertical: "middle", horizontal: "center", wrapText: true };
header.height = 22;

sheet.getColumn("total").numFmt = '#,##0.00;[Red]-#,##0.00';
sheet.getColumn("date").numFmt = "yyyy-mm-dd";

sheet.getCell("B2").border = {
  bottom: { style: "thin", color: { argb: "FFBFBFBF" } },
};

Colors are ARGB with the alpha pair first, so FFFF0000 is opaque red and a six-digit hex silently produces the wrong color. Assigning a style to a row or column applies it to existing cells and to cells created afterwards, but a row style does not reach cells beyond the last one that has a value. Number formats are Excel format strings, not JavaScript formatting: write the raw number into the cell and let numFmt handle presentation, because writing a preformatted string makes the column text and breaks every downstream SUM.

Write formulas, and supply the resultformulas

sheet.getCell("D2").value = { formula: "B2*C2", result: 42.5 };

// shared formula across a column
sheet.getCell("D3").value = { sharedFormula: "D2", result: 17 };

// whole-column total
sheet.getCell("D10").value = { formula: "SUM(D2:D9)" };

// tell Excel to recalculate on open
workbook.calcProperties.fullCalcOnLoad = true;

// reading back
const cell = sheet.getCell("D2");
cell.formula;  // 'B2*C2'
cell.result;   // whatever was cached in the file, possibly stale

ExcelJS has no formula engine. Without a result, the cell has a formula and no cached value, which Excel fixes on open but Google Sheets, Numbers, and most preview panes render as blank. Setting workbook.calcProperties.fullCalcOnLoad = true is the standard fix. When reading, cell.result is whatever the producing application last computed, so treat it as a hint rather than truth, and note that cell.value returns the formula object rather than a number for these cells.

Generate a file client-side and trigger a downloaddownload-in-browser

import ExcelJS from "exceljs";

const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("Data");
sheet.addRows(rows);

const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
  type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});

const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: url, download: "data.xlsx" });
a.click();
URL.revokeObjectURL(url);

writeBuffer is the only browser path: the streaming writer and reader are Node-only, so a browser export is bounded by tab memory. The MIME type matters, because the wrong one makes some browsers save a file Excel then refuses to open. Remember to revoke the object URL or every export leaks its whole buffer for the life of the page, and be aware you are shipping jszip and archiver to users for what may be a fifty-row table.

Stream an export straight to the responseserve-over-http

app.get("/export.xlsx", async (req, res) => {
  res.setHeader(
    "Content-Type",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  );
  res.setHeader("Content-Disposition", 'attachment; filename="export.xlsx"');

  const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: res, useStyles: true });
  const sheet = workbook.addWorksheet("Export");
  sheet.columns = [{ header: "Id", key: "id" }, { header: "Total", key: "total" }];

  for await (const row of query()) sheet.addRow(row).commit();
  sheet.commit();
  await workbook.commit();   // ends the response
});

Passing the response as the stream means bytes start flowing before the query finishes, so the browser shows a download immediately and the server never buffers the file. The cost is that headers are already sent when something fails halfway, so a mid-export error becomes a truncated file rather than a 500; log it and consider writing to a temp file first if a corrupt download is worse than a slow one. Do not call res.end() yourself, workbook.commit() closes the stream.

Add validation so users cannot type nonsensedropdowns-and-validation

sheet.getCell("C2").dataValidation = {
  type: "list",
  allowBlank: true,
  formulae: ['"Pending,Shipped,Cancelled"'],   // note the inner quotes
  showErrorMessage: true,
  errorTitle: "Invalid status",
  error: "Pick a value from the list.",
};

// list from a range on another sheet
sheet.getCell("D2").dataValidation = {
  type: "list",
  formulae: ["=Lookups!$A$1:$A$20"],
};

sheet.getCell("E2").dataValidation = {
  type: "whole",
  operator: "between",
  formulae: [1, 100],
};

The inline list is a single formula string containing a double-quoted comma-separated list, so the escaping looks wrong and is correct: '"A,B,C"'. It is also limited to 255 characters by Excel itself, which is why longer lists have to live in a range. Validation is per cell, so applying it to a column means looping over the rows you wrote; there is no column-level shortcut.

Autofilter, merged cells, and sheet protectionfilters-merges-protection

sheet.autoFilter = "A1:D1";
sheet.views = [{ state: "frozen", xSplit: 1, ySplit: 1 }];

sheet.mergeCells("A1:C1");
sheet.getCell("A1").value = "Quarterly report";

// unlock the cells users may edit, then lock the sheet
sheet.getColumn("notes").eachCell((cell) => {
  cell.protection = { locked: false };
});
await sheet.protect("secret", { selectLockedCells: true, formatCells: false });

Only the top-left cell of a merged range holds a value; writing to the others is ignored, and reading them gives you the master's value. The README flags that a splice across a merged range moves the merge group incorrectly, so do row insertion before merging, not after. Sheet protection is an Excel convention, not security: the password is trivially removed by unzipping the file, so never use it to hide data.

Embed a logo or a pictureimages

const imageId = workbook.addImage({
  buffer: fs.readFileSync("logo.png"),
  extension: "png",           // 'jpeg' | 'png' | 'gif', required
});

sheet.addImage(imageId, "B2:D6");                 // stretch over a range
sheet.addImage(imageId, {                          // or anchor with offsets
  tl: { col: 1, row: 1 },
  ext: { width: 200, height: 80 },
});
sheet.addBackgroundImage(imageId);

Two steps: the image goes on the workbook and returns an id, then the id goes on a worksheet. Extension is required even when you pass a buffer, and getting it wrong produces a file Excel reports as corrupt rather than a clear error. Images are not supported in streaming mode at all, and the README notes that transforming or adjusting them is not supported, so crop and resize before you add them.

Same worksheet API, CSV in and outcsv-mode

// read
const workbook = new ExcelJS.Workbook();
const sheet = await workbook.csv.readFile("input.csv", {
  dateFormats: ["DD/MM/YYYY"],
  parserOptions: { delimiter: ";", headers: false },
});

// write
await workbook.csv.writeFile("output.csv", {
  sheetName: "Orders",
  formatterOptions: { delimiter: ",", quote: '"' },
});
const buf = await workbook.csv.writeBuffer();

csv.readFile returns the worksheet, not the workbook, which is the opposite of xlsx.readFile and easy to miss. parserOptions and formatterOptions pass straight through to fast-csv, so its documentation is the real reference here. Everything that makes xlsx worth using is dropped on the way out: styles, formulas, multiple sheets, and merges all disappear, and dateFormats uses dayjs tokens because dayjs is what parses them.

Alternatives

PackageRegistryPick it when
write-excel-filenpmYou only generate xlsx, never read it, and want a small actively released package with a schema-driven API
xlsxnpmYou must read .xls, .xlsb, or .ods as well as xlsx, accepting that the npm copy is frozen at 0.18.5 from 2022 and current SheetJS is distributed elsewhere
fast-csvnpmThe recipient only needs the data and you were reaching for xlsx out of habit; ExcelJS already uses this underneath for its CSV support