Export
Excel and CSV, written from scratch with zero dependencies. What it does, and the traps it avoids.
Why not SheetJS
Its npm package is deprecated and carries advisories, and CI here gates on npm audit --audit-level=high. An .xlsx is a ZIP of XML parts, so the alternative is about 120 lines of archive writer plus the minimum OOXML — a format we can read, rather than a dependency we are told not to install.
Entries are stored, not deflated: no DEFLATE implementation, and every reader including Excel, Numbers and LibreOffice handles stored entries. The output was validated against openpyxl — an independent OOXML reader — with warnings escalated to errors.
Using it directly
import { createXlsx, createCsvBlob, downloadBlob } from "@dashboardpack/core/lib/export";
const columns = ["Name", "Units", "Joined"];
const rows = [["Ada", 42, new Date(2026, 0, 15)]];
downloadBlob(createXlsx({ columns, rows, name: "Orders" }), "orders.xlsx");
downloadBlob(createCsvBlob(columns, rows), "orders.csv");Cells keep their types: numbers stay summable, dates stay sortable, booleans stay booleans. Workbooks get bold headers, a frozen header row, an autofilter and content-derived column widths — the format has no autofit, so a writer that omits widths produces a file where long text shows as ###.
Per-column control
{
accessorKey: "lastActive",
header: ({ column }) => <DataTableColumnHeader column={column} title="Last Active" />,
meta: {
exportHeader: "Last seen", // the visible header is a component
exportValue: (row) => row.status.label, // accessor holds an object
exportHidden: true, // a sparkline has no value to export
},
}Without exportHeader the header falls back to a humanised column id — lastActivebecomes “Last Active”, id becomes “ID”. That is usually right, so most columns need nothing.
Server-paged tables need getAllRows
A server-driven table holds one page, so the export can only see that page. This was a real defect: on a 25-record table paging at ten, the file contained ten rows and the toast read “Exported 10 rows to CSV” — true about what it wrote, silent about the fifteen it skipped.
<DataTable
server={{ mode: "server", rowCount: total, /* … */ }}
getAllRows={async () => {
// Re-query unpaginated, carrying the CURRENT filter and sort so the file
// matches what is on screen rather than the whole collection.
const result = await provider.getList("users", {
perPage: "all",
filter,
search,
sort,
});
return result.data;
}}
/>Omit it and the export still works, but the menu and the toast say “this page only”. The table cannot fetch what it was never given, so it says so rather than implying completeness.
CSV: four things that are easy to get wrong
- Formula injection. A field beginning
=,+,-,@, tab or CR is evaluated as a formula on open — so a value one user stores runs in another user's spreadsheet. Neutralised with a leading apostrophe, which spreadsheets strip on display, so-44 7700 900123still reads correctly. - The UTF-8 BOM. Without it, Excel on Windows decodes UTF-8 as the system codepage and every accented name becomes mojibake. On by default.
- CRLF. RFC 4180 specifies it. Excel copes with bare LF; a number of older importers do not.
- Non-scalar values.
String(value)on an object produces a column of[object Object]. JSON-encoded instead, which is at least recoverable.
XLSX has no injection problem by construction: a string cell is t="inlineStr", so text beginning = is stored as text.