Open source · Free npm package

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.

.styles .formulas .CF .data validation .comments .images .streamingone package, the whole workbook
Latest npm versionMinified + gzipped bundle sizeTypeScript types includedMonthly npm downloadsLicense

Write, then read it back

A Workbook instance, cells addressed by A1 or (row, col), and a Uint8Array out — no Node Buffer required.
# 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 // → 42

Everything a real workbook needs

Built to be the package you reach for instead of hand-rolling OOXML or fighting ExcelJS internals.
  • ExcelJS-shaped API
    new 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/compat
    Swap 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 & merges
    Fonts, 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-filter
    worksheet.views for frozen/split panes and worksheet.autoFilter for the header-row filter range both round-trip as data, not just on write.
  • Conditional formatting
    cellIs, expression, the text-contains family, top10, aboveAverage/belowAverage, duplicateValues/uniqueValues, plus self-visualising colorScale, dataBar and iconSet rules.
  • Data validation
    List dropdowns and whole/decimal/date/time/text-length/custom constraints, settable per cell or per range via worksheet.dataValidations.add(sqref, dv).
  • Cell comments
    cell.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 images
    workbook.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 writer
    The 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

The engineering cost of hand-rolling spreadsheet export/import, or staying on a stalled dependency, compared to a maintained, typed alternative.

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.

Buffered writer — full workbook in memory$100/mo

peak memory, indexed to 100

Streaming WorkbookWriter$6/mo

~15.6× less peak memory, ~1.9× the throughput

Production recipes

Real spreadsheet problems, solved with the published API.

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

The questions a CTO asks before adding a package to production — answered up front.
Metric / concern What ships
Dependencies1 runtime dep (fflate)
Type safetyFull .d.ts, ESM + CJS
Streaming writer~15.6× less peak memory
ExcelJS parity@alosha/xlsx/compat drop-in
Feature coverageStyles, CF, validation, comments, images
Runtime targetsNode ≥ 20, browser, edge
LicensingMIT

Need more than the open-source core?

Alosha XLSX is free and MIT-licensed. When you need a hosted feature, a custom build, or a fast answer from the person who wrote it, there is a paid path — backed by the founder, not a ticket queue.
  • 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