JavaScript is the one environment where you can generate an Excel file on the server, in a browser tab, or inside a Lambda with the same code. That is genuinely useful — it means an "Export to Excel" button never has to round-trip to a backend, and a nightly report job never has to shell out to Python.
This is the JavaScript counterpart to our JSON to Excel in Python guide. Same problems, different tools.
Choosing a library
| Need | Use |
|---|---|
| Array of objects, plain sheet, smallest code | SheetJS (xlsx) |
| Reading .xls, .xlsb, .ods and other legacy formats | SheetJS |
| Cell styling, number formats, frozen panes, formulas | ExcelJS |
| Hundreds of thousands of rows without exhausting memory | ExcelJS streaming writer |
| Runs in the browser with no build step | SheetJS |
Both are actively maintained and both are free. SheetJS is the one this site's own converter uses, because the browser bundle is small and the format coverage is unmatched.
The three-line version
npm install xlsxconst XLSX = require("xlsx");
const data = [
{ id: 1, name: "Ada Lovelace", role: "Engineer" },
{ id: 2, name: "Grace Hopper", role: "Admiral" },
];
const sheet = XLSX.utils.json_to_sheet(data);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, "People");
XLSX.writeFile(book, "people.xlsx");json_to_sheet reads the keys of the first object to build the header row. That last detail is the source of most surprises, and the next section deals with it.
Ragged records: the silent column loss
If your first record is missing a field that later records have, SheetJS never creates a column for it. Nothing warns you; the data is simply absent from the file.
const data = [
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace", rank: "Rear Admiral" }, // "rank" is dropped
];Pass an explicit header list built from the union of all keys:
const headers = [...new Set(data.flatMap(Object.keys))];
const sheet = XLSX.utils.json_to_sheet(data, { header: headers });flatMap(Object.keys) collects every key from every record and Set deduplicates while preserving first-seen order, so the columns come out in the order the fields first appeared rather than alphabetically. Rows missing a field get an empty cell.
Worth doing unconditionally. API responses omit null fields far more often than people expect — most JSON serialisers drop keys whose value is null or undefined. If your data came from an HTTP call, assume it is ragged.
Flattening nested objects
A nested object becomes the string [object Object] in the cell, which is Excel faithfully reporting that you handed it something it cannot represent. Flatten first:
function flatten(value, prefix = "", out = {}) {
for (const [key, v] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (v === null || v === undefined) {
out[path] = "";
} else if (Array.isArray(v)) {
// Arrays of scalars join cleanly; arrays of objects do not,
// so serialise those rather than losing them.
out[path] = v.every((x) => typeof x !== "object" || x === null)
? v.join("; ")
: JSON.stringify(v);
} else if (typeof v === "object" && !(v instanceof Date)) {
flatten(v, path, out);
} else {
out[path] = v;
}
}
return out;
}
const rows = data.map((r) => flatten(r));Three decisions are baked into that function, and you may want different ones. Nested objects become dot-notation columns. Arrays of scalars become a semicolon-joined string, which stays readable and survives a round trip. Arrays of objects are serialised to JSON rather than exploded into extra rows, because exploding changes what one row means and that should be a deliberate choice — see handling nested JSON in Excel.
The instanceof Date check matters: without it, typeof v === "object" is true for dates and the recursion tears them apart into their internal properties.
In the browser: download without a server
The same library runs client-side. This fetches JSON from an API and hands the user an Excel file without the data ever touching your backend:
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<script>
async function exportToExcel() {
const res = await fetch("/api/orders");
const data = await res.json();
const sheet = XLSX.utils.json_to_sheet(data);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, "Orders");
// Triggers the browser's download prompt.
XLSX.writeFile(book, "orders.xlsx");
}
</script>XLSX.writeFile detects the environment: in Node it writes to disk, in a browser it builds a Blob and triggers a download. No extra code path needed.
If you are bundling with Vite, webpack or similar, import * as XLSX from "xlsx" works the same way. Import from the package root rather than deep paths so tree-shaking can do its job — the full bundle is around 900 KB minified, which is worth being deliberate about on a public page.
Column widths, because the default is unusable
Every column comes out at the default width, so a URL or a long description shows as #### or spills across neighbours. SheetJS reads widths from a !cols property on the sheet:
const headers = [...new Set(rows.flatMap(Object.keys))];
sheet["!cols"] = headers.map((h) => ({
wch: Math.min(
50,
Math.max(h.length, ...rows.map((r) => String(r[h] ?? "").length)) + 2
),
}));wch is width in characters. Capping at 50 keeps one pathological value from producing a column wider than the screen; the + 2 is padding so text does not touch the cell border. This takes about thirty seconds to add and is the single biggest difference between an export that looks generated and one that looks made.
Dates, and why they usually arrive as text
JSON has no date type. A timestamp arrives as the string "2026-09-06T14:30:00Z", and a string is what Excel stores — left-aligned, not sortable as a date, useless in a date filter.
Convert to real Date objects and tell SheetJS to write them as dates:
const rows = data.map((r) => ({
...r,
created_at: r.created_at ? new Date(r.created_at) : null,
}));
const sheet = XLSX.utils.json_to_sheet(rows, { cellDates: true });To control how they display, set a number format on the cells. Excel stores the underlying value and the format separately, so this changes presentation only:
const range = XLSX.utils.decode_range(sheet["!ref"]);
const dateCol = headers.indexOf("created_at");
for (let row = range.s.r + 1; row <= range.e.r; row++) {
const addr = XLSX.utils.encode_cell({ r: row, c: dateCol });
if (sheet[addr]) sheet[addr].z = "yyyy-mm-dd hh:mm";
}range.s.r + 1 starts at the row after the header. The z property is the number format string, using the same syntax as Excel's Custom Format dialog.
ExcelJS: formatting and streaming
When you need styled headers, frozen panes, or a file too large to hold in memory, ExcelJS is the better tool:
npm install exceljsconst ExcelJS = require("exceljs");
async function write(data) {
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet("Orders");
sheet.columns = [
{ header: "Order ID", key: "id", width: 12 },
{ header: "Customer", key: "customer", width: 28 },
{ header: "Total", key: "total", width: 12, style: { numFmt: "#,##0.00" } },
{ header: "Placed", key: "placed", width: 20, style: { numFmt: "yyyy-mm-dd" } },
];
sheet.addRows(data);
sheet.getRow(1).font = { bold: true };
sheet.views = [{ state: "frozen", ySplit: 1 }]; // header stays visible
sheet.autoFilter = { from: "A1", to: "D1" }; // filter dropdowns
await book.xlsx.writeFile("orders.xlsx");
} Declaring columns up front solves the ragged-record problem by construction: rows are matched to columns by key, so a missing field is an empty cell rather than a shifted row, and an unexpected field is ignored rather than silently dropped from a header built off record zero.
Streaming for large exports
Building a workbook in memory costs roughly ten times the raw data size once every cell is an object. Past a few hundred thousand rows, stream instead:
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
filename: "large.xlsx",
});
const sheet = workbook.addWorksheet("Data");
sheet.columns = [
{ header: "ID", key: "id" },
{ header: "Value", key: "value" },
];
for await (const record of recordStream) {
sheet.addRow(record).commit(); // flushes this row to disk
}
sheet.commit();
await workbook.commit(); The .commit() calls are what make this streaming rather than buffered — each row is written out and released instead of accumulating. Forget them and you have the in-memory version with extra steps.
Excel's hard ceiling is 1,048,576 rows. Neither library will warn you when you cross it; the rows past the limit simply are not there when the file opens. If you might exceed a million rows, split across sheets, or write CSV instead and let the recipient decide how to load it. See working with large JSON files.
Multiple sheets from grouped data
One workbook, one sheet per group:
const byRegion = data.reduce((acc, row) => {
(acc[row.region] ??= []).push(row);
return acc;
}, {});
const book = XLSX.utils.book_new();
for (const [region, rows] of Object.entries(byRegion)) {
// Sheet names: max 31 chars, and : \ / ? * [ ] are forbidden.
const name = region.replace(/[:\\/?*\[\]]/g, "-").slice(0, 31);
XLSX.utils.book_append_sheet(book, XLSX.utils.json_to_sheet(rows), name);
}
XLSX.writeFile(book, "by-region.xlsx"); The sanitising step is not optional. Excel rejects the whole file if a sheet name is over 31 characters or contains a reserved character, and a region name like "EMEA / North" will do it.
Serving the file from an Express endpoint
app.get("/export", async (req, res) => {
const data = await db.query("SELECT * FROM orders");
const sheet = XLSX.utils.json_to_sheet(data);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, "Orders");
const buffer = XLSX.write(book, { type: "buffer", bookType: "xlsx" });
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
res.setHeader("Content-Disposition", 'attachment; filename="orders.xlsx"');
res.send(buffer);
});XLSX.write with type: "buffer" returns the bytes instead of touching the filesystem, which is what you want in a container or a serverless function where the disk is read-only or ephemeral. Get the content type wrong and browsers save the file as .zip — technically accurate, since XLSX is a zip archive, but not helpful.
Common errors
| Symptom | Cause |
|---|---|
[object Object] in cells | Nested object passed through unflattened |
| A column is missing entirely | Absent from the first record; pass an explicit header list |
| Dates left-aligned and unsortable | Still strings; convert to Date and set cellDates |
| File will not open, "unreadable content" | Invalid sheet name, or the response was sent as text |
JavaScript heap out of memory | Too many rows in memory; use the streaming writer |
Downloaded file is named .zip | Wrong Content-Type header |
Just need the file once?
Skip the npm install. Paste your JSON, get a clean XLSX with nested fields flattened — processed entirely in your browser.
Convert JSON to Excel Now →