Modern TypeScript
Excel reader & writer
Read and write real .xlsx workbooks from a single ESM-first document model — an ExcelJS-shaped rewrite with a low-memory streaming writer for exports too big to buffer. One runtime dependency, full type declarations.
Write, then read it back
# Install
npm install @alosha/xlsx
import { Workbook } from '@alosha/xlsx'
const workbook = new Workbook()
const sheet = workbook.addWorksheet('Report')
sheet.getCell('A1').value = 'Item'
sheet.getCell('B1').value = 'Qty'
sheet.getCell('B2').value = 42
sheet.getCell('B2').numFmt = '#,##0'
sheet.mergeCells('A4:B4')
// Browser-safe — never touches node:fs.
const bytes: Uint8Array = await workbook.xlsx.writeBuffer()
// Node convenience wrapper.
await workbook.xlsx.writeFile('report.xlsx')
// Read it back.
const loaded = new Workbook()
await loaded.xlsx.load(bytes)
loaded.getWorksheet('Report')?.getCell('B2').value // → 42Everything a real workbook needs
- ExcelJS-shaped APInew Workbook(), addWorksheet, getCell, getRow and the workbook.xlsx.writeBuffer/writeFile/load/readFile accessor all behave the way ExcelJS code already expects.
- Drop-in @alosha/xlsx/compatSwap the import — `import ExcelJS from "@alosha/xlsx/compat"` — and keep the rest of an ExcelJS codebase unchanged: same cell.type numbers, same style-setter precedence, same addRow/row.values behaviour.
- Styles, number formats & mergesFonts, fills, borders, alignment, protection and number formats round-trip on read and write, with mergeCells and a useStyles toggle for lean exports.
- Freeze panes & auto-filterworksheet.views for frozen/split panes and worksheet.autoFilter for the header-row filter range both round-trip as data, not just on write.
- Conditional formattingcellIs, expression, the text-contains family, top10, aboveAverage/belowAverage, duplicateValues/uniqueValues, plus self-visualising colorScale, dataBar and iconSet rules.
- Data validationList dropdowns and whole/decimal/date/time/text-length/custom constraints, settable per cell or per range via worksheet.dataValidations.add(sqref, dv).
- Cell commentscell.note round-trips plain-string and rich-text notes with a multi-author table — real comments{n}.xml plus the legacy VML anchor Excel expects.
- Embedded imagesworkbook.addImage + worksheet.addImage support two-cell range anchors and one-cell/pixel-extent anchors for logos, charts-as-images and photo grids.
- Low-memory streaming writerThe stream.xlsx.WorkbookWriter-shaped API renders and deflates each row as you commit it — ~15.6× less peak memory than the buffered writer at ~1.9× the throughput on 500k-row exports.
Why teams move off ExcelJS or a home-grown OOXML writer
Large exports blow up memory on the buffered path
A finance or reporting export with hundreds of thousands of rows built as one in-memory workbook can push a Node process into GC thrash or an OOM kill, right when the report matters most.
What you take on building or maintaining it yourself
- Peak memory
Holding every row, style and shared string in memory before serializing means peak RSS scales linearly with row count.
- Time to first byte
Nothing streams to the client until the entire workbook has been built and zipped, so large exports feel frozen.
- Container limits
A report that worked in dev OOMs in a memory-capped serverless function or container once real production data volume hits it.
With @alosha/xlsx: The streaming WorkbookWriter renders and deflates each row into the output archive the moment you call row.commit(), keeping peak memory roughly flat regardless of row count — with the same styles, merges, panes, data validation, conditional formatting, comments and images as the buffered writer.
Porting off ExcelJS usually means a rewrite, not a swap
Most alternatives to ExcelJS have a different API shape, so migrating existing spreadsheet code means touching every call site — a project few teams have time to start.
What you take on building or maintaining it yourself
- API drift
Different constructor, cell-access and serialization surfaces mean every getCell/addRow/style call needs to be rewritten and re-tested.
- Silent behaviour changes
Subtle differences — style-setter merge vs replace semantics, cell.type numbering — can pass a smoke test and still corrupt output in production.
- Migration cost
A full rewrite of the spreadsheet layer competes with every other roadmap item, so teams stay on an unmaintained dependency instead.
With @alosha/xlsx: @alosha/xlsx/compat is built to be a same-behaviour drop-in — matching cell.type numbers, ExcelJS's whole-category style-setter precedence, and the addRow/row.values/worksheet.columns contract — so most codebases port with a single import-line change, not a rewrite.
Peak memory: buffered vs streaming writer
Representative peak memory writing 500,000 rows × 5 columns. Streaming keeps memory roughly flat regardless of row count; buffered scales with it.
peak memory, indexed to 100
~15.6× less peak memory, ~1.9× the throughput
Production recipes
Swap ExcelJS for @alosha/xlsx without touching the rest of the file
The problem: An existing report generator is built on ExcelJS, but the dependency has gone quiet and a full rewrite of every getCell/style call isn't on the roadmap.
- import ExcelJS from "exceljs"
+ import ExcelJS from "@alosha/xlsx/compat"
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet('Report')
sheet.getCell('A1').value = 'Item'
sheet.getColumn(2).width = 12
sheet.getCell('B1').font = { bold: true }
await workbook.xlsx.writeFile('report.xlsx')Why it works: @alosha/xlsx/compat backs the same new Workbook()/addWorksheet/getCell surface with @alosha/xlsx's model and writer, matching ExcelJS's cell.type numbers and whole-category style-setter precedence — so everything below the import line keeps working unchanged.
Export 500,000 rows without holding the workbook in memory
The problem: A nightly export job builds a report so large that the buffered writer's in-memory workbook risks OOM-killing the container it runs in.
import { WorkbookWriter } from '@alosha/xlsx'
const wb = new WorkbookWriter({ filename: 'big-report.xlsx' })
const ws = wb.addWorksheet('Data')
for (const record of hugeDataset) {
ws.addRow([record.id, record.name, record.amount]).commit() // flushed now
}
ws.commit()
await wb.commit()Why it works: row.commit() renders and deflates that row into the output archive immediately instead of retaining it, so peak memory stays roughly flat regardless of row count — about 15.6× less peak memory than the buffered writer at 1.9× the throughput on a 500k-row benchmark. Pass a Writable instead of a filename to stream straight to an HTTP response, or omit both to consume the writer as an AsyncIterable<Uint8Array>.
Highlight out-of-range values with conditional formatting
The problem: A QA dashboard needs failing measurements to visually stand out, without a client-side pass recomputing which cells are out of spec on every render.
const ws = workbook.addWorksheet('Measurements')
ws.addConditionalFormatting({
ref: 'B2:B500',
rules: [
{ type: 'cellIs', operator: 'greaterThan', formulae: [100], style: { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC7CE' } } } },
{ type: 'colorScale', cfvo: [{ type: 'min' }, { type: 'max' }], color: ['FFF8696B', 'FF63BE7B'] }
]
})Why it works: Conditional formatting rules are written straight into the sheet's <conditionalFormatting> blocks with differential styles deduped into a shared <dxfs> pool, so Excel and LibreOffice recompute the highlighting themselves from the live cell values — no client-side re-derivation, and the rules survive a round-trip through readWorkbookBuffer.
Constrain input to a dropdown list with a same-sheet data validation
The problem: A generated order-entry template needs a status column restricted to a fixed set of values, enforced by Excel itself, not just documented in a comment.
const ws = workbook.addWorksheet('Orders')
ws.dataValidations.add('C2:C200', {
type: 'list',
allowBlank: true,
showInputMessage: true,
showErrorMessage: true,
formulae: ['"Pending,Shipped,Cancelled"']
})Why it works: The <dataValidations> block is emitted in the correct CT_Worksheet position, so Excel renders a real in-cell dropdown and rejects free-text input on that range — enforcement lives in the file itself, so it holds even if the workbook is edited outside your app.
Freeze the header row and add an auto-filter in one pass
The problem: A generated report is unreadable once scrolled — the header row disappears and there's no way to filter columns without manually re-adding both in Excel after every export.
const ws = workbook.addWorksheet('Report')
ws.views = [{ state: 'frozen', xSplit: 0, ySplit: 1, topLeftCell: 'A2' }]
ws.autoFilter = 'A1:F1'Why it works: views and autoFilter are real worksheet state, not writer-only options — they round-trip on read the same way ExcelJS's do, so a workbook you generate and then re-open programmatically still reports the frozen pane and filter range exactly as written.
Attach a reviewer note to a cell without a full comments library
The problem: A generated audit spreadsheet needs to flag specific cells with a reviewer's explanation, in a form Excel's native comment popup understands — not a sidecar column.
const ws = workbook.addWorksheet('Audit')
ws.getCell('D12').note = {
texts: { richText: [{ text: 'Flagged: ', font: { bold: true } }, { text: 'exceeds threshold by 12%' }] },
author: 'Compliance'
}Why it works: cell.note writes a real comments{n}.xml note/author table plus the legacy VML anchor Excel's comment UI expects, so the note shows up as a native comment bubble with the author attributed — no sidecar sheet or custom popover to build and keep in sync.
Built to pass a dependency review
| Metric / concern | What ships |
|---|---|
| Dependencies | 1 runtime dep (fflate) |
| Type safety | Full .d.ts, ESM + CJS |
| Streaming writer | ~15.6× less peak memory |
| ExcelJS parity | @alosha/xlsx/compat drop-in |
| Feature coverage | Styles, CF, validation, comments, images |
| Runtime targets | Node ≥ 20, browser, edge |
| Licensing | MIT |
Need more than the open-source core?
- Migration help porting a large ExcelJS codebase onto @alosha/xlsx/compat
- Guidance wiring the streaming writer into an existing export pipeline
- Priority bug fixes and answers straight from the maintainer