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.
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.
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
- You care about the project being maintained. 4.4.0 shipped in October 2023 and the repository's last push was January 2025, with 656 open issues out of 798 open issues and PRs. It works, and it is widely used, but nobody is fixing your bug on a timeline
- You audit your dependency tree. It pulls nine runtime packages, several of them several majors behind: uuid ^8 against a current 14, archiver ^5 against 8, saxes ^5 against 6, unzipper ^0.10.11 against 0.12. That is a lot of frozen surface area to inherit for a file format library
- You expect formulas to produce values. ExcelJS writes the formula text and, if you supply one, a cached result; it does not evaluate anything. A generated file whose formula cells have no result shows blank in some viewers until Excel recalculates, and reading a workbook gives you whatever result was cached, which can be stale
- You need charts. There is no chart support at all, and pivot tables landed only as an explicitly limited contribution. If the deliverable includes a chart, you are templating an existing workbook with a different tool
- You need to read legacy formats. Only xlsx and csv. No .xls binary, no .xlsb, no .ods. An upload form that accepts spreadsheets from real users will meet all three
- You are shipping this to the browser to generate one small file. The bundle drags in jszip, archiver, and Node stream polyfills, streaming is unavailable there by design, and a purpose-built writer costs a fraction of the bytes
- You are relying on a lossless round-trip. Reading a workbook and writing it back drops anything the parser does not model, and the README itself notes that splice operations across merged cells move the merge group incorrectly
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 flushedThis 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 staleExcelJS 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
| Package | Registry | Pick it when |
|---|---|---|
| write-excel-file | npm | You only generate xlsx, never read it, and want a small actively released package with a schema-driven API |
| xlsx | npm | You 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-csv | npm | The recipient only needs the data and you were reaching for xlsx out of habit; ExcelJS already uses this underneath for its CSV support |