Read, edit, and write Excel files in TypeScript

Open any .xlsx or start from an empty workbook. Change cells, styles, formulas, and charts through typed functions, then save a file that validates against the ECMA-376 schemas. It runs in Node 22 and later, and in the browser.

Get started Open the playground

A worksheet and the code that built it

hero-workbook.ts
export function buildHeroWorkbook(): Workbook {
  const wb = createWorkbook();
  const ws = addWorksheet(wb, 'Q3 sales');

  const white = makeColor({ rgb: 'FFFFFFFF' });
  const green = makeColor({ rgb: 'FF168A4F' });
  const header = registerCellStyle(wb, {
    font: makeFont({ bold: true, color: white }),
    fill: makePatternFill({ patternType: 'solid', fgColor: green }),
  });
  appendRow(ws, ['Region', 'Units', 'Revenue', 'Growth'], {
    styleIds: [header, header, header, header],
  });
  setFreezePanes(ws, 'A2');

  const regions = [
    ['North', 1_240, 186_000, 0.124],
    ['South', 980, 139_650, 0.081],
    ['East', 1_515, 242_400, 0.193],
    ['West', 760, 102_600, -0.036],
  ] as const;
  const units = registerCellStyle(wb, { numberFormat: '#,##0' });
  const money = registerCellStyle(wb, { numberFormat: '"$"#,##0' });
  const percent = registerCellStyle(wb, { numberFormat: '0.0%' });
  appendRows(ws, regions, { styleIds: [undefined, units, money, percent] });

  // The library never evaluates formulas, so cache the sums it can work out:
  // viewers that do not calculate then show a number instead of a blank.
  const sum = (col: 1 | 2): number => regions.reduce((n, region) => n + region[col], 0);
  const total = {
    font: makeFont({ bold: true }),
    border: makeBorder({ top: makeSide({ style: 'thin' }) }),
  };
  appendRow(
    ws,
    [
      'Total',
      makeFormula('SUM(B2:B5)', { cachedValue: sum(1) }),
      makeFormula('SUM(C2:C5)', { cachedValue: sum(2) }),
    ],
    {
      styleIds: [
        registerCellStyle(wb, total),
        registerCellStyle(wb, { ...total, numberFormat: '#,##0' }),
        registerCellStyle(wb, { ...total, numberFormat: '"$"#,##0' }),
        registerCellStyle(wb, total),
      ],
    },
  );

  setColumnWidths(ws, [14, 10, 14, 10]);
  return wb;
}
B6 =SUM(B2:B5)
ABCDEF
1RegionUnitsRevenueGrowth
2North1,240$186,00012.4%
3South980$139,6508.1%
4East1,515$242,40019.3%
5West760$102,600-3.6%
6Total4,495$670,650
7
8
9
10
11

That sheet is real output. The code shown with it built the workbook, the library saved it and read the bytes back, and getCellDisplayText put each value through its number format. Download it and open it in Excel: the totals are live formulas. Or change the code and watch the sheet follow in the REPL.

  • loadWorkbook(source)

    Edit a workbook you already have

    Load an .xlsx or .xlsm from bytes, a file, a Blob, or a fetch response. Change the cells that change and save. Pivot tables, VBA projects, threaded comments, external links, and custom XML are carried through byte for byte.

    Read the getting started guide
  • createWorkbook()

    Build one from nothing

    createWorkbook() returns an empty workbook with no template file to ship. Add sheets and rows, register a style once and reuse it by id, then add number formats, formulas, tables, data validation, conditional formatting, images, and charts.

    Browse the recipes
  • createWriteOnlyWorkbook(sink)

    Stream the big ones

    The write-only workbook deflates each row as you append it and pushes the bytes straight to the sink. The streaming reader walks a sheet with a SAX parser and yields one row at a time, so neither side holds the sheet in memory.

    Read the streaming guide

Most spreadsheets that matter already exist

The report finance sends every month, the template with the pivot table, the .xlsm someone’s macros depend on. Load it, write the cells that change, and save. The parts the library does not model go back into the file exactly as they came out.

Reading is as typed as writing. A cell value is a discriminated union, not any; getCellDisplayText gives the text Excel shows under the cell’s number format, and getCellDate reads a date serial through the workbook’s epoch.

basic-read-write.ts
import { loadWorkbook, workbookToBytes } from '@office-kit/xlsx/io';
import { fromBuffer } from '@office-kit/xlsx/node';
import { setCell } from '@office-kit/xlsx/worksheet';
import { readFile, writeFile } from 'node:fs/promises';

const wb = await loadWorkbook(fromBuffer(await readFile('input.xlsx')));
const ref = wb.sheets[0];
if (ref?.kind === 'worksheet') {
  setCell(ref.sheet, 1, 1, 'Hello from @office-kit/xlsx');
}
await writeFile('output.xlsx', await workbookToBytes(wb));

A million rows without a million rows in memory

The streaming writer pushes each row through deflate as it arrives and forwards the chunk to the sink, so row buffering stays near 64 KiB however long the sheet gets. Shared strings are capped at 100,000 entries and 8 MiB; past that, new strings are written inline.

The reader inflates a worksheet chunk by chunk into a SAX parser, so the inflated sheet is never fully resident. One honest limit: ZIP needs its central directory, so the compressed archive itself is loaded up front.

streaming-write.ts
import { toFile } from '@office-kit/xlsx/node';
import { createWriteOnlyWorkbook } from '@office-kit/xlsx/streaming';

const sink = toFile('big.xlsx');
const wb = await createWriteOnlyWorkbook(sink);
const ws = await wb.addWorksheet('Data');
ws.setColumnWidth(1, 24); // must precede the first appendRow

for (let r = 0; r < 1_000_000; r++) {
  await ws.appendRow([r, `row-${r}`, r * Math.PI]);
}
await ws.close();
await wb.finalize();

Import the part you use

There is no root import. The package has 15 subpaths, every export has exactly one home, and nothing has side effects, so a bundler keeps only what you call. Sizes are minified and brotli-compressed with dependencies included, measured at 0.18.0; the cap is what CI enforces.

SubpathWhat it holdsSizeCI cap
@office-kit/xlsx/ioLoad and save a whole workbook107 KB120 KB
@office-kit/xlsx/streamingRow-by-row reader and append-only writer61 KB80 KB
@office-kit/xlsx/stylesFonts, fills, borders, number formats14 KB60 KB
@office-kit/xlsx/worksheetCells, ranges, merges, tables, validation11 KB100 KB
@office-kit/xlsx/workbookSheets, defined names, properties9 KB100 KB

The other ten cover cells, charts, chartsheets, drawings, Node file helpers, and the packaging, schema, XML, ZIP, and utility layers. The API reference lists them all.

Valid files, checked by machines

“Excel happens to open it today” is not the bar. The bytes have to be valid OOXML, and a machine has to say so on every commit.

Every kind of write is checked against the ECMA-376 schemas.
A three-tier validator runs in CI: the OPC package structure, the ECMA-376 Transitional XSDs through xmllint, and the rules a schema cannot express, such as overlapping merges or a style index past the end of the table. A fast-check property test feeds it generated workbooks.
What the library does not model, it does not touch.
Pivot tables, VBA, OLE objects, external links, and custom XML pass through load and save byte for byte. Round-trip tests pin that against real workbooks, including the openpyxl fixture corpus.
A bundle that grows too much fails the build.
size-limit runs in CI on every push and pull request. The full load and save path has to stay under 120 KB minified and brotli-compressed with its dependencies, and the streaming entry under 80 KB.
Two runtime dependencies.
fflate for ZIP and saxes for streaming XML. Only the @office-kit/xlsx/node subpath touches fs; the rest is Web Streams, Blob, and Uint8Array.
Tested where you run it, and as you install it.
The suite runs on Linux, macOS, and Windows against Node 22, 24, and 26. A packaging job installs the packed tarball and compiles a consumer under node16, nodenext, and bundler resolution.

What you can build today

The library is pre-1.0 and says so. This is what works now, and what does not. The API reference lists every function.

Cells
Numbers, strings, booleans, errors, dates, and inline rich text. Normal, array, shared, and data-table formulas with cached values.
Styles
Fonts, fills, borders, alignment, protection, number formats, named styles, and differential styles, deduplicated into one pool.
Sheets
Merged cells, freeze panes, column widths and row heights, grouping, hidden rows and columns, page setup, sheet protection.
Data tools
Excel tables, autofilter, data validation, conditional formatting, and workbook- or sheet-scoped defined names.
Reading
A typed cell value union, the text Excel shows under a number format, dates read through the workbook epoch, and value extents.
Charts
16 classic chart kinds and 8 modern ones such as sunburst, treemap, waterfall, and funnel, with trendlines, error bars, and chartsheets.
Drawings
PNG, JPEG, GIF, BMP, WebP, TIFF, SVG, EMF, and WMF images, with format and size detected from the bytes.
Package
Hyperlinks and comments in bulk, ZIP64 past 65,535 entries, deterministic output bytes, and macro-enabled .xlsm.

Not supported

  • Formula evaluation. Cache the values you know, or ask Excel to recalculate on open
  • Other formats: .xls, .xlsb, .ods, and .csv are out of scope
  • ISO 29500 Strict workbooks are detected and named, not read
  • Encrypted workbooks are detected, not decrypted
  • Pivot table authoring (existing pivots are preserved)
  • Charts, images, and tables in the streaming writer

Written to be driven by AI agents too

Spreadsheets are increasingly written by agents, so the docs are built for them as well as for you. Because every function has one import path, a model that knows the name knows the import.