pure-xlsx
v0.2.1
Published
Zero-dependency, lightweight .xlsx (Excel) generator for Node.js, written in TypeScript. JSON/CSV to styled Excel files, buffered or streaming.
Maintainers
Readme
pure-xlsx
Zero-dependency .xlsx writer for Node.js, written in TypeScript. It turns JSON, CSV or any (async) iterable into styled Excel files, either buffered or streamed with flat memory.
- No runtime dependencies. It uses only
node:zlib,node:streamandnode:fs. - Styling: bold, italic, underline, strike, size, colour and font name; fills; borders; alignment and wrap; number formats; per column, per row or per cell. Identical styles are deduplicated.
- Sheet features: header row, column widths (explicit or auto), freeze panes, auto filter, merged cells, row heights, tab colour, RTL, hidden sheets and columns, multiple sheets.
- Values: numbers, text, booleans,
Date,BigInt, formulas (with cached result), hyperlinks (external and internal) and per-cell styled values.NaN/Infinitybecome#NUM!and invalid dates become#VALUE!. - Safe by default:
- Text is always written as text, so CSV-style formula injection (
=cmd|...) is never executed. - XML-illegal characters are stripped.
- Text longer than 32,767 characters is truncated with a warning, or rejected when
strictis set. - Excel limits are enforced: 1,048,576 rows, 16,384 columns and sheet-name rules.
- Text is always written as text, so CSV-style formula injection (
- CSV:
- Parsing follows RFC 4180 and is incremental.
- The delimiter is detected automatically and a BOM is removed.
- Types are inferred conservatively: leading-zero codes and numbers longer than 15 digits stay text.
- Streaming:
- Rows are pulled only as fast as the destination consumes bytes.
- A ZIP64 archive is written automatically when it is needed.
- The source iterator is closed if the consumer stops early.
- Packaging: ESM and CommonJS builds with
.d.tstypes. Requires Node ≥ 20.
Install
npm install pure-xlsxCommonJS
const { Workbook, jsonToXlsx } = require('pure-xlsx');Both ESM and CommonJS builds ship with types; no @types package is needed.
Quick start
import { Workbook } from 'pure-xlsx';
const wb = new Workbook({ creator: 'My App' });
const sheet = wb.addSheet('Sales', {
columns: [
{ header: 'Order', key: 'id', width: 8 },
{ header: 'Customer', key: 'customer' }, // auto width
{ header: 'Amount', key: 'amount', numFmt: '#,##0.00' },
{ header: 'Date', key: 'date' }, // Date -> real Excel date
],
headerStyle: { font: { bold: true, color: '#FFFFFF' }, fill: '#1F4E78', border: 'thin' },
freeze: true, // freeze the header row
autoFilter: true, // filter buttons on the header
});
sheet.addRow({ id: 1, customer: 'Ravi', amount: 1234.5, date: new Date() });
sheet.addRow({ id: 2, customer: 'सीता', amount: 99, date: new Date() }, { fill: '#F2F2F2' }); // row style
sheet.addRow(['', 'Total', { formula: 'SUM(C2:C3)', result: 1333.5 }]);
await wb.writeFile('sales.xlsx'); // or: await wb.toBuffer() / wb.toStream()JSON → xlsx
import { jsonToXlsx } from 'pure-xlsx';
const buffer = await jsonToXlsx(users, {
sheetName: 'Users',
headerStyle: { font: { bold: true }, fill: '#DDEBF7' },
freeze: true,
});Object keys become columns in order of first appearance. Rows with missing keys leave the cell empty.
CSV → xlsx
import { csvToXlsx, csvToXlsxStream } from 'pure-xlsx';
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
const buffer = await csvToXlsx(csvText, { freeze: true });
// Large files: constant memory
await pipeline(csvToXlsxStream(createReadStream('big.csv')), createWriteStream('big.xlsx'));Options:
delimiter: detected automatically when omitted.header: defaulttrue.inferTypes: defaulttrue. Numbers and ISO dates are converted, while007and 16-digit IDs stay text.encoding,quoteandmaxRecordLength.
Streaming large data (DB cursor, generator, API pages)
import { xlsxStream, writeXlsx } from 'pure-xlsx';
// Express / HTTP
app.get('/export', (req, res) => {
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename="export.xlsx"');
xlsxStream({ name: 'Orders', rows: db.orders.cursor(), columns: ['id', 'customer', 'total'], freeze: true })
.on('error', (err) => res.destroy(err))
.pipe(res);
});
// File
await writeXlsx('orders.xlsx', [
{ name: 'Orders', rows: ordersAsyncIterable },
{ name: 'Refunds', rows: refundsArray, rowStyle: (row, i) => (i % 2 ? undefined : ZEBRA) },
]);How streaming behaves:
- Text is written inline by default, so memory does not grow with unique strings. Pass
{ sharedStrings: true }for smaller files when text repeats a lot. - The first
sampleRowsrows (default 100) are read ahead to learn object keys and column widths. Passcolumnswhen keys appear late in the data. - If generation fails,
writeXlsx(path)deletes the partial file.
Recipes
Download from an Express route (buffered):
app.get('/report.xlsx', async (_req, res) => {
const buf = await jsonToXlsx(await db.users.findAll(), { sheetName: 'Users', freeze: true });
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
.set('Content-Disposition', 'attachment; filename="report.xlsx"')
.send(buf);
});Handle errors by type:
import { XlsxError, XlsxInputError, XlsxLimitError, XlsxTooLargeError } from 'pure-xlsx';
try { await csvToXlsx(upload); }
catch (e) {
if (e instanceof XlsxTooLargeError) return res.status(413).end(); // input too big
if (e instanceof XlsxLimitError || e instanceof XlsxInputError) return res.status(400).end(); // Excel limit / bad argument
throw e;
}Collect warnings instead of printing them (for example truncated cells):
const warnings: string[] = [];
const wb = new Workbook({ onWarning: (w) => warnings.push(String(w)) });Several sheets from one dataset, each with its own columns:
await writeXlsx('report.xlsx', [
{ name: 'Orders', rows: orders, columns: ['id', 'customer', 'total'] },
{ name: 'Refunds', rows: refunds },
]);Troubleshooting
| Symptom | Cause / fix |
|---|---|
| ID like 007 or a 16-digit number looks wrong | Pass it as a string (JSON) or keep inferTypes on for CSV: such values stay text on purpose |
| A column is missing in a streamed export | Its key first appeared after sampleRows rows; pass columns explicitly |
| Dates are shifted by hours | Dates are written in UTC; use dateMode: 'local' for server-local time |
| XlsxLimitError on 1M+ rows | One sheet holds 1,048,576 rows; split the data across sheets |
| Text starting with = shows as text | Intended (formula-injection protection). Use { formula: '...' } for real formulas |
Cell values
| JS value | Excel |
|---|---|
| number | number (NaN/Infinity → #NUM!) |
| string | text (never a formula) |
| boolean | TRUE / FALSE |
| Date | date serial + date format (yyyy-mm-dd, or yyyy-mm-dd hh:mm:ss when it has a time) |
| bigint | number if it is a safe integer, otherwise text |
| null / undefined | empty |
| { formula: 'SUM(A1:A9)', result?: 45 } | formula (Excel recalculates on open) |
| { link: 'https://…', text?: 'Open', tooltip? } | hyperlink; link: "#'Sheet 2'!A1" is internal |
| { value, style } | value with its own style |
| other objects / arrays | JSON text |
Dates are read in UTC by default, which is deterministic on servers. Use new Workbook({ dateMode: 'local' }) for server-local time. Dates before 1900-03-01, which Excel cannot represent, are written as ISO text.
Styles
const style = {
font: { bold: true, italic: true, underline: true, strike: false, size: 12, color: '#C00000', name: 'Arial' },
fill: '#FFF2CC',
border: 'thin', // or { style: 'medium', color: '#999' } or { bottom: 'double' }
alignment: { horizontal: 'center', vertical: 'center', wrapText: true, indent: 1, textRotation: 45 },
numFmt: '#,##0.00', // '0%', 'yyyy-mm-dd', '"₹"#,##0' ...
};Styles layer in this order: column → row → cell. Each layer only overrides the parts it sets. Text containing line breaks gets wrapText automatically.
API summary
new Workbook(options?).addSheet(name?, sheetOptions?),.getSheet(name),.sheets.toBuffer(),.toStream(),.writeFile(path)
Worksheet.addRow(row, style?),.addRows(rows, style?).setCell('B3' | row, col, value, style?).mergeCells('A1:C1'),.setRowHeight(row, pt),.setColumnWidth(col, width),.rowCount
xlsxStream(sheets, options?)→Readable;writeXlsx(pathOrWritable, sheets, options?)jsonToXlsx(rows, options?),csvToXlsx(text, options?),csvToXlsxStream(input, options?)parseCsv(text, options?),parseCsvStream(input, options?),CsvParser,inferCsvValue,countCsvRecords(input, options?)(exact row count without parsing)LIMITS: the library's capacity, as constants- Errors:
XlsxError,XlsxLimitError(Excel limits),XlsxTooLargeError(input overmaxInputBytes) andXlsxInputError(bad arguments)
Workbook options
- Document properties:
creator,title,subject,company,created. - Dates:
dateMode,dateFormat,dateTimeFormat. - Output:
strict,compression(0–9),sharedStrings. - Font and warnings:
fontName,fontSize,onWarning.
Sheet options
- Columns and header:
columns,header,headerStyle. - View:
freeze,autoFilter,merges,tabColor,rightToLeft,showGridLines,hidden. - Sizes:
autoWidth,minColumnWidth,maxColumnWidth,defaultRowHeight. - Streaming only:
rowStyle,sampleRows,headerRow.
Limits and safety
| Limit | Behaviour |
|---|---|
| CSV string/Buffer input: 100 MB (LIMITS.csvMaxBytes) | XlsxTooLargeError immediately; nothing is parsed |
| Streamed CSV input: 500 MB (LIMITS.csvStreamMaxBytes) | XlsxTooLargeError as soon as the stream crosses the limit; the rest is never read |
| jsonToXlsx array longer than 1,048,576 rows | XlsxLimitError before the workbook is built |
| csvToXlsx CSV with more than 1,048,576 rows | XlsxLimitError before parsing (rows are counted in one fast pass, ~0.3 s for 40 MB) |
| 1,048,576 rows / 16,384 columns per sheet | XlsxLimitError (split big exports over several sheets) |
| 32,767 characters per cell | truncated with one warning per sheet (strict: true throws) |
| 65,530 hyperlinks per sheet | extra links are written as plain text, one summary warning |
| 64,000 distinct cell styles | XlsxLimitError; reuse style objects instead of creating one per row |
| Sheet names | ≤ 31 chars, unique, no \\ / ? * : [ ], control characters or "History" |
| Hyperlink schemes | javascript:, vbscript:, data: links are written as plain text |
| Text values | always text, never formulas, so formula injection is not possible |
Both CSV limits can be changed per call with maxInputBytes (Infinity turns the check off). XlsxTooLargeError has code: 'ERR_XLSX_TOO_LARGE', maxBytes and receivedBytes; map it to HTTP 413. All capacities are exported as LIMITS, so your app can reject oversized uploads before the library sees them:
import multer from 'multer';
import { csvToXlsx, LIMITS, XlsxTooLargeError } from 'pure-xlsx';
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: LIMITS.csvMaxBytes } }); // nothing written to disk
app.post('/convert', upload.single('file'), async (req, res) => {
try {
const xlsx = await csvToXlsx(req.file.buffer, { freeze: true });
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(xlsx);
} catch (err) {
res.status(err instanceof XlsxTooLargeError ? 413 : 400).json({ error: err.message });
}
});Warnings go to onWarning when it is given, otherwise to process.emitWarning. If generation fails, writeFile / writeXlsx(path) delete the partial file.
Tip: for 300k rows, compression: 1 was about 35% faster than the default (level 6) and the file was ~15% bigger. compression: 0 was about 2.5× faster, but the file was ~8× bigger.
Live HTTP example
node examples/server.mjs starts a server with three endpoints:
/report: a buffered report./export?rows=1000000: a streamed export. If the client disconnects, the source is closed.POST /csv: an uploaded CSV body comes back as .xlsx.
Performance
Measured on an Apple Silicon Mac with Node 20.19 (node --expose-gc bench/bench.mjs 1000000). Each row has 6 columns: number, text, Hindi text, amount, date and boolean.
| Mode | Rows | Time | File | Peak memory (heap + buffers) |
|---|---|---|---|---|
| xlsxStream | 1,000,000 | ~4.3 s | 34 MB | ~25 MB (flat) |
| csvToXlsx (49 MB CSV Buffer) | 1,000,000 | ~4.5 s | 28 MB | ~100 MB (includes the 28 MB output) |
| Workbook.toBuffer | 200,000 | ~1.0 s | 6.5 MB | ~110 MB |
Use Workbook for reports that fit in memory and xlsxStream / writeXlsx for exports of any size.
Development
npm test # build (ESM + CJS) and run node:test suites
node examples/demo.mjs
node --expose-gc bench/bench.mjs 1000000License
MIT
