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 .conditional formatting .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 most of an ExcelJS codebase keeps working unchanged: same cell.type numbers, same style-setter precedence, same addRow/row.values behaviour. A few behaviours differ (a Uint8Array instead of a Node Buffer, a 0-based worksheets array, a couple of deferred features) — the migration guide lists them.
  • 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 — ~14.2× 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 memory2,718 MiB

≈2.7 GiB peak — scales with row count

Streaming WorkbookWriter191 MiB

≈14.2× 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 14.2× 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>.

Write a formula, rich text, a hyperlink and an error code — all as plain cell values

The problem: A dashboard row needs a cached formula result, mixed-format text in one cell, a clickable link and a native #DIV/0! — without reaching for a different API per cell type.

const ws = workbook.addWorksheet('Dashboard')
const row = ws.addRow([])

// Formula with a cached result
row.getCell('A').value = { formula: 'SUM(B2:B10)', result: 420 }

// Inline rich text within a single cell
row.getCell('B').value = {
  richText: [
    { text: 'Status: ', font: { bold: true } },
    { text: 'Critical Failure', font: { color: { argb: 'FFFF0000' } } }
  ]
}

// Hyperlink with a tooltip
row.getCell('C').value = {
  text: 'Open Issue',
  hyperlink: 'https://github.com/avlisodraude/alosha-xlsx/issues/1',
  tooltip: 'Click to view issue tracker'
}

// Native Excel error code
row.getCell('D').value = { error: '#DIV/0!' }

Why it works: cell.value is a TypeScript discriminated union, so every advanced shape — formula, richText, hyperlink, error — is just an object you assign, with autocomplete narrowing the fields for each. There is no per-type setter to memorise, and each shape round-trips through readWorkbookBuffer as the same object you wrote.

Append hundreds of rows that inherit the header's fonts and borders

The problem: A ledger export re-declares the same font and border on every row of the loop, because porting ExcelJS's cryptic i / o / i+ inheritance flags never felt worth it.

const ws = workbook.addWorksheet('Ledger')

// Style a baseline row once
const header = ws.addRow(['Date', 'Account', 'Amount'])
header.font = { bold: true, size: 12 }
header.border = { bottom: { style: 'medium' } }

// New rows inherit the row above's structure — no re-styling per iteration
ws.addRow(['2026-07-08', 'Operating Costs', 1250.0], { inheritFrom: 'above' })
ws.addRow(['2026-07-09', 'SaaS Licensing', 450.0], { inheritFrom: 'above' })

Why it works: inheritFrom takes a readable { inheritFrom: "above" | "below", includeEmpty? } object instead of ExcelJS's i / o / i+ / o+ DSL, and copies the source row's style (and, with includeEmpty, its per-cell styles) — so a long write loop stays declarative without re-setting fonts and borders each pass.

Generate a workbook synchronously, with a tree-shakeable import

The problem: An edge function or a bundle-size-sensitive frontend needs to emit a spreadsheet without the overhead of an async accessor or pulling the whole library into the bundle.

import { Workbook, writeWorkbookBuffer, readWorkbookBuffer } from '@alosha/xlsx'

const wb = new Workbook()
wb.addWorksheet('SyncReport').getCell('A1').value = 'Instant Export'

// Pure, synchronous serialization — returns a Uint8Array
const bytes: Uint8Array = writeWorkbookBuffer(wb)

// Read it back, also synchronously
const roundTripped = readWorkbookBuffer(bytes)

Why it works: writeWorkbookBuffer/readWorkbookBuffer are the environment-agnostic core the workbook.xlsx.* accessor wraps — fully synchronous (no promise/microtask overhead) and tree-shakeable, so a bundler drops the reader when you only write. Neither touches node:fs, so the same call runs in a browser, worker or edge runtime.

Drop a logo or chart image into a report

The problem: An invoice or branded report needs an embedded image placed precisely — either spanning a block of cells or pinned at an exact pixel size.

// Intern the raw bytes once on the workbook
const imageId = workbook.addImage({ buffer: companyLogoBytes, extension: 'png' })

const ws = workbook.addWorksheet('Invoice')

// Option A: span a two-cell range
ws.addImage(imageId, 'A1:C3')

// Option B: pin a top-left cell with an explicit pixel size
ws.addImage(imageId, { tl: { col: 5, row: 1 }, ext: { width: 180, height: 60 } })

Why it works: workbook.addImage interns the bytes once and returns a numeric id, so the same image placed on several sheets is stored only once. worksheet.addImage accepts either a two-cell range string or a one-cell pixel-extent anchor — the two placement forms ExcelJS reports covering in day-to-day use.

Convert between A1 strings and row/column indexes without hand-rolling the math

The problem: A dynamic sheet builder keeps re-implementing "turn (row, col) into C10" and "how many rows does B2:G15 span" — fiddly code that's easy to get subtly wrong.

import { colLetterToNumber, colNumberToLetter, encodeAddress, decodeAddress, Range } from '@alosha/xlsx'

colLetterToNumber('AA')  // 27
colNumberToLetter(5)     // 'E'
encodeAddress(10, 3)     // 'C10'  (args are row, col)
decodeAddress('C10')     // { row: 10, col: 3, address: 'C10' }

const range = new Range('B2:G15')
range.range                   // 'B2:G15'
range.tl                      // 'B2'  top-left address
range.br                      // 'G15' bottom-right address
range.top                     // 2   first row
range.left                    // 2   first column (B)
range.count                   // 84  total cells
range.bottom - range.top + 1  // 14  row count

Why it works: The same cached A1 codecs the library uses internally are exported, so an index-to-address conversion is one call instead of a bespoke helper. Range normalises any A1 range into 1-based top/left/bottom/right bounds with tl/br/range/count accessors — construct it with new Range('B2:G15') or new Range(top, left, bottom, right).

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~14.2× less peak memory
ExcelJS parity@alosha/xlsx/compat drop-in
Feature coverageStyles, CF, validation, comments, images
Runtime targetsNode ≥ 22, 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