---
title: "exceljs alternative: an Excel export as a definition — quario"
description: "exceljs writes a workbook cell by cell from your code. quario renders one JSON definition to Excel, PDF and Word. The same export with each, side by side."
url: "https://getquario.com/compare/exceljs"
---

# exceljs alternative

exceljs is the library most JavaScript projects reach for to write an `.xlsx` file, and for good
reason. It’s MIT-licensed, and it runs in Node and the browser. It covers a lot of the format, too.
Styles, number formats, merged cells, frozen panes, and auto filters are all there. So are formulas,
data validation, conditional formatting, images, and a streaming writer for very large files. It
reads workbooks as well as writing them. quario’s own spreadsheet target ran on it until 0.9.0.

exceljs is a workbook API, though, not a report engine. It gives you cells and rows, and every
decision about what goes in them is code you write. That covers the header row, the column widths,
and the number formats. It covers the frozen pane, the filter range, and the total row with the merge
that makes its label read. This page shows one export both ways, and where each one fits.

## The same export, twice

Take an orders export for one month. It has a title and a header row, then one row per order with a
real date and a real amount, then a total. First with exceljs 4.4:

```ts title="orders-exceljs.ts"
import ExcelJS from 'exceljs'

type Order = { id: string; date: string; customer: string; amount: number }
const { period, orders } = (await getSales('2026-09')) as { period: string; orders: Order[] }

const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet('Orders', { views: [{ state: 'frozen', ySplit: 2 }] })

sheet.getRow(1).values = [`Orders, ${period}`]
sheet.getRow(1).font = { size: 16, bold: true }
sheet.getRow(2).values = ['Order', 'Date', 'Customer', 'Amount']
sheet.getRow(2).font = { bold: true }

for (const o of orders) sheet.addRow([o.id, new Date(o.date), o.customer, o.amount])

const sum = orders.reduce((total, o) => total + o.amount, 0)
const total = sheet.addRow(['Total', null, null, Math.round(sum * 100) / 100])
sheet.mergeCells(total.number, 1, total.number, 3)
total.font = { bold: true }
total.getCell(1).alignment = { horizontal: 'right' }

sheet.getColumn(2).numFmt = 'dd mmm yyyy'
sheet.getColumn(4).numFmt = '"EUR"#,##0.00'
;[10, 14, 28, 14].forEach((width, i) => { sheet.getColumn(i + 1).width = width })
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: 2 + orders.length, column: 4 } }

await workbook.xlsx.writeFile('orders.xlsx')
```

The program is correct, and the workbook it writes is a good one. But look at what it had to know.
The header lives on row 2 because the title took row 1, so the frozen pane and the filter both say
`2`. The widths are numbers someone guessed, and a longer customer name next month won’t change them.
The number formats are spreadsheet syntax, written per column. And none of it says how the same export
looks as a PDF, because exceljs only writes workbooks.

Now the same export as a quario definition. This is the file the card on the
[guides page](https://getquario.com/guides.md) renders:

```json title="orders.report.json"
{
  "data": "$.orders[*]",
  "aggregates": { "total": "sum:=@.amount" },
  "style": { "size": 10 },
  "header": [
    { "type": "text",
      "value": "Orders, {{ $.input.period }}",
      "style": { "size": 16, "bold": true, "spaceAfter": 12 } }
  ],
  "detail": {
    "header": { "style": { "bold": true,
      "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
    "columns": [
      { "header": "Order", "value": "{{ @.id }}", "width": 16 },
      { "header": "Date", "value": "{{ @.date }}", "style": { "format": "date" }, "width": 20 },
      { "header": "Customer", "value": "{{ @.customer }}", "width": 40 },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ @.amount }}",
        "style": { "align": "right", "format": "currency" }, "width": 24 }
    ],
    "total": {
      "style": { "align": "right", "bold": true },
      "rows": [
        { "cells": [
          { "value": "Total", "span": 3 },
          { "value": "{{ round($.total, 2) }}", "style": { "format": "currency" } }
        ] }
      ]
    }
  }
}
```

And the code that renders it:

```ts title="orders-quario.ts"
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { xlsx } from '@quario/xlsx'
import definition from './orders.report.json' with { type: 'json' }

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)

const data = await getSales('2026-09')             // your JSON, from an API or DB
const bytes = await report.render(xlsx({ filter: true }), data)

writeFileSync('orders.xlsx', bytes)
```

The [XLSX target](https://getquario.com/docs/diving-deeper/xlsx-target.md) makes the decisions the exceljs program spelled
out. The table’s header row anchors the frozen pane, wherever the title leaves it. Each column takes
its width from what it actually holds. `"format": "date"` reads the `2026-09-02` string and writes a
typed date. `"format": "currency"` leaves the amount a number under a currency number format, so `SUM`
still works on the column. The total’s `span` becomes a merged range, and `filter: true` covers the
header and data rows but never the total.

## What changes when you switch

**The export becomes data.** The exceljs program is the export. A quario definition is JSON you
store, version, and review. It can come from an editor or an AI agent as safely as from your own
repository. Expressions parse into closures. There’s no `eval`, no `new Function` and no code
generation, so a definition someone else wrote can’t run anything.
[How that holds →](https://getquario.com/docs/diving-deeper/security.md)

**One definition reaches four document shapes.** The definition above isn’t spreadsheet-specific.
Hand it a different target and the same orders come out as an HTML fragment, a paginated PDF, or a
Word document:

```ts title="every-shape.ts"
import { quario } from 'quario'
import { html } from '@quario/html'
import { pdf } from '@quario/pdf'
import { xlsx } from '@quario/xlsx'
import { docx } from '@quario/docx'

const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)

const page = await report.render(html(), data)
const paper = await report.render(pdf({ page: { size: 'A4' } }), data)
const book = await report.render(xlsx(), data)
const word = await report.render(docx({ page: { size: 'A4' } }), data)
```

The screen, the PDF attachment and the workbook stop drifting apart, because there’s only one
description of the report to change.

**Grouping comes with it.** A group in the definition gives you headers, subtotals, and a worksheet
per instance when the group declares a `break`. With exceljs, each of those is a loop you write.
[JSON to Excel](https://getquario.com/guides/json-to-excel.md) walks through what the workbook gets.

**The dependency gets smaller.** exceljs 4.4 depends on nine packages directly. quario’s spreadsheet
writer is werkmap, quario’s own, at around 90 kB installed with no dependencies.

## When to keep exceljs

exceljs is the better choice when:

- **You read or edit workbooks.** quario writes documents and doesn’t open them. Importing a
customer’s spreadsheet or filling in a template workbook is exceljs’s job.
- **The workbook needs formulas.** quario never writes one: every cell is a value, which is what makes
a cell built from user data inert. exceljs writes formulas, though it never calculates them, so you
supply the cached result.
- **You need data validation or conditional formatting.** These are exceljs features a quario
definition doesn’t declare.
- **The export is a raw dump of a very large table.** exceljs’s streaming writer commits rows as it
goes. A report definition is for documents a person reads, not for a million-row extract.
- **The project is open source, or the budget is zero.** exceljs is MIT. quario is commercial past
evaluation.

Reach for quario when the workbook is a report, such as an invoice, an orders summary, a statement,
or a payroll run. It earns its place when the same report also has to exist as a PDF or a Word
document.

## FAQ

### When did exceljs last ship a release?

Its latest release on npm is 4.4.0, published in October 2023. It still works, and many projects
depend on it. Weigh that date as you would for any dependency you plan to keep for years.

### Can a cell become a formula in quario?

No. The XLSX target writes a string cell as a string, whatever it starts with. A customer name of
`=HYPERLINK(…)` arrives as text, with no escaping to remember.

### Does quario run in the browser like exceljs?

Yes. `@quario/xlsx` is a pure JavaScript dependency that runs in Node, in the browser, and on edge
runtimes, so the browser can build a download itself. The
[JSON to Excel converter](https://getquario.com/tools/json-to-excel.md) does exactly that.

### Can I use quario for free?

Evaluating it’s free, with no time limit. You get the full engine and need no key, and evaluation
output carries a watermark. Shipping it in an application or sending exports to real customers needs a
license for each production deployment. Its users, agents, and renders are never counted, and replicas
of one service count once. [See pricing →](https://getquario.com/pricing.md)

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Comparisons","item":"https://getquario.com/compare"},{"@type":"ListItem","position":3,"name":"exceljs alternative","item":"https://getquario.com/compare/exceljs"}]}
```
