DEV Community

Cover image for Stop bundling SheetJS for simple Excel exports: a 5 KB alternative
azizam techno
azizam techno

Posted on • Originally published at mubsiraanalytics.com

Stop bundling SheetJS for simple Excel exports: a 5 KB alternative

TL;DR

I open-sourced mini-xlsx, a one-function, zero-dependency XLSX builder for the browser. Around 5 KB minified. No SheetJS, no ExcelJS, no JSZip. It writes real .xlsx files that open cleanly in Excel, LibreOffice Calc, Google Sheets and Apple Numbers.

If your only requirement is to output Excel files (no reading, no formulas, no charts), you almost certainly do not need a 600 KB library to do it. This article walks through how I got there.

The problem

I was finishing Mubsira Business, a free offline-first invoice and expense tracker for freelancers. Tax season was the next milestone, and I needed a "send to accountant" button. One click, one file, everything inside: invoices, expenses, payments, clients, summary. Multi-sheet Excel was the obvious format because every accountant I have ever worked with lives in Excel.

The obvious library was SheetJS. It is excellent, very battle-tested, and I have used it on other projects without complaint. But three things stopped me from reaching for it this time:

  1. The whole app is around 50 KB gzipped. Adding SheetJS would have roughly quadrupled the bundle.
  2. The app is offline-first. A bigger bundle means a slower service worker install, which means a worse first-run experience for the exact users I care about (people opening the app on a laptop in a cafe with patchy wifi).
  3. Most of SheetJS's surface area is for reading Excel files, with all the legacy .xls, encrypted-workbook and edge-case parsing that implies. I needed none of that.

So I went looking at what an .xlsx file actually is.

What an .xlsx file actually is

An .xlsx file is a ZIP archive with a small set of XML files inside it. The full spec (OOXML, ECMA-376) is long, but the minimum you need to produce a workbook that Excel will open without complaint is genuinely small. The directory layout looks like this:

my-workbook.xlsx
├── [Content_Types].xml
├── _rels/
│   └── .rels
└── xl/
    ├── workbook.xml
    ├── sharedStrings.xml
    ├── styles.xml
    ├── _rels/
    │   └── workbook.xml.rels
    └── worksheets/
        ├── sheet1.xml
        └── sheet2.xml
Enter fullscreen mode Exit fullscreen mode

Seven files. None of them are large. None of them are conceptually difficult. The roles are roughly:

  • [Content_Types].xml declares the MIME type of every part inside the zip.
  • _rels/.rels points the package at its main document (xl/workbook.xml).
  • xl/workbook.xml lists the sheets in the workbook and their display names.
  • xl/_rels/workbook.xml.rels maps the sheet ids to the actual XML files on disk and to sharedStrings.xml and styles.xml.
  • xl/sharedStrings.xml is a deduplicated table of every string used in any cell.
  • xl/styles.xml holds fonts, fills, number formats and the styling indices that cells reference by id.
  • xl/worksheets/sheetN.xml is one file per sheet, containing the actual rows and cells.

Once you accept that this is the whole problem, the rest is just text generation and a ZIP wrapper.

The minimum viable XLSX

Here is the function signature I landed on:

const bytes = miniXlsx([
  {
    name: "Invoices",
    headers: ["ID", "Client", "Date", "Amount"],
    rows: [
      ["INV-001", "ACME Corp", "2026-04-24", 1250.00],
      ["INV-002", "Globex",    "2026-04-25",  890.50],
    ],
  },
  {
    name: "Expenses",
    headers: ["Date", "Vendor", "Amount"],
    rows: [
      ["2026-04-20", "Office Depot", 42.99],
    ],
  },
]);

const blob = new Blob([bytes], {
  type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
Enter fullscreen mode Exit fullscreen mode

You give it an array of sheet definitions. It gives you back a Uint8Array you can wrap in a Blob and download. That is the whole API.

Internally, four things have to happen.

1. The shared strings table

Excel does not put strings inline in cells (well, it can, but the canonical way is not to). It builds a deduplicated string table once, and every string cell points at it by index. So before generating any sheet XML, I walk every header and every cell, and feed strings to a small addStr helper:

const sst = [], sstMap = {};
function addStr(s) {
  s = String(s == null ? "" : s);
  if (sstMap.hasOwnProperty(s)) return sstMap[s];
  const idx = sst.length;
  sst.push(s);
  sstMap[s] = idx;
  return idx;
}
Enter fullscreen mode Exit fullscreen mode

That gives you xl/sharedStrings.xml, and a lookup table you can use later when emitting cells.

2. Sheet XML with auto type detection

Excel cells are typed. A number cell and a string cell have completely different syntax. To keep the API one-line-friendly, I auto-detect:

  • If the value parses as a number, write it as a numeric cell.
  • If the value matches YYYY-MM-DD, convert it to an Excel date serial and tag it with the date number format.
  • Otherwise, treat it as a string and write the shared-string index.

The date serial is the only mildly weird part. Excel measures dates as days since 1899-12-30, and it includes a fictional 1900-02-29 leap day for backwards compatibility with Lotus 1-2-3. So:

function dateToSerial(ds) {
  const [y, m, d] = ds.trim().split("-").map(Number);
  const dt = new Date(y, m - 1, d);
  const epoch = new Date(1899, 11, 30);
  let diff = Math.round((dt - epoch) / 86400000);
  if (diff > 59) diff++; // the imaginary 1900 leap day
  return diff;
}
Enter fullscreen mode Exit fullscreen mode

That single +1 after day 59 is the kind of thing I would happily pay SheetJS to handle for me on a bigger project. On this one, six lines is fine.

3. A tiny styles.xml

I wanted three things visually: a coloured header row, a frozen top row, and reasonable date and number formatting. That fits in roughly 15 lines of styles.xml: two fonts (regular and bold-white), three fills (none, gray125, and a teal FF0D9488 for the header), two number formats (date and #,##0.00), and four cellXfs entries that combine them.

Cells then reference styles by their index in cellXfs. Header cells get s="1", date cells s="2", decimal cells s="3", everything else s="0".

Frozen header is one line in the sheet XML:

<sheetViews>
  <sheetView workbookViewId="0">
    <pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>
  </sheetView>
</sheetViews>
Enter fullscreen mode Exit fullscreen mode

4. A no-compression ZIP

This is the part I was most nervous about. ZIP is not difficult, but the spec is long and full of historical baggage. The shortcut: the ZIP format defines a compression method 0, called STORED, which means "the file content is in the archive verbatim". No DEFLATE, no Huffman coding, no zlib. You just write the bytes.

It turns out Excel, LibreOffice, Google Sheets and Numbers all accept STORED .xlsx files without complaint. The only cost is that the resulting file is larger than a properly-compressed one, but for the kind of payload an in-browser export typically produces (a few hundred KB at most), the difference is rounding error.

The full ZIP writer is one function, around 60 lines. It computes a CRC32 per file, writes a local header, writes the file bytes, accumulates a central directory, and finishes with an end-of-central-directory record. That is it. No external dependency, no DEFLATE implementation, nothing to bundle.

The result

About 300 lines of source. Around 5 KB minified. Zero dependencies. Produces files that open without warnings or "recovered content" prompts in Excel 2016+, LibreOffice Calc 7+, Google Sheets, and Apple Numbers.

In production it powers the "send to accountant" export at the bottom of Mubsira Business. One click bundles invoices, expenses, payments, clients and a summary into a five-sheet workbook the user can email to their accountant.

You can play with it here:

Limitations (by design)

I want to be clear about what this thing is not, so nobody adopts it for the wrong job:

  • No formulas. Cells hold values, not expressions.
  • No images, charts, comments, merged cells or conditional formatting.
  • No reading. It only writes.
  • Not appropriate for very large workbooks (think tens of MB of data). The XML is built as a string in memory.

If you need any of that, use SheetJS or ExcelJS. They are both excellent and they are doing genuinely hard work that I am happy to skip.

But if your needs are "render some rows of data into a downloadable .xlsx and move on", consider whether you actually need the heavyweight option. A lot of the time, you do not.

Takeaways

A few things I took away from the build that I think generalise:

  1. Read the format spec before reaching for the library. OOXML looks intimidating from the outside; the subset you actually need for output is tiny. Same is often true of PDF, ICS, vCard, and a surprising amount of "scary" binary formats.
  2. STORED ZIP is a real escape hatch. If your bundle budget cannot fit a DEFLATE implementation, you may not need one. Most consumers of ZIP-based formats accept method 0.
  3. The shared strings table is the unintuitive bit. First time I tried this I wrote strings inline and Excel opened the file but stripped half of them. Once I pointed everything at sharedStrings.xml the warnings went away.
  4. Frozen header and autofilter are one-line wins. Both add real usability for accountants and analysts and cost nothing.

If you give it a try and it works (or breaks) in an interesting way, I would love to hear about it on the issue tracker. And if you are running into the same problem on your own product, the code is MIT, copy what you need.

Thanks for reading.


I build Mubsira Analytics — small, offline-first tools for freelancers and accountants. The repo is github.com/MubsiraAnalytics/mini-xlsx — stars and PRs welcome.

Top comments (0)