Skip the CSV export library: 12 lines of vanilla JS
Add to Google
Compare WordPress, VPS, cloud, serverless, dedicated and email hosting side by side. Real specs, pricing, reviews.
Somebody always asks for it. The dashboard shows the numbers, and the person reading them wants those numbers in a spreadsheet so they can pivot, chart, and email them around. The usual answer is a new backend endpoint, or a library that ships a spreadsheet engine to the browser to produce a text file.
The table is already on the page. The browser can turn it into a download without asking the server for anything.
The fix
function downloadCSV(filename, rows) {
const csv = rows
.map((row) => row.map((cell) => {
const value = String(cell ?? '');
return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
}).join(','))
.join('\r\n');
const url = URL.createObjectURL(new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' }));
const link = Object.assign(document.createElement('a'), { href: url, download: filename });
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
Feed it an array of arrays. If the data is already rendered in a <table>, that’s one line, the same rows and cells walk that made the table sortable:
const rows = [...table.rows].map((tr) => [...tr.cells].map((td) => td.textContent.trim()));
downloadCSV('orders.csv', rows);
Demo:
| Order | Customer | Total | Note |
|---|---|---|---|
| #1041 | Ana Petrović | 129.00 | Gift wrap, blue ribbon |
| #1042 | John Smith | 49.50 | Called it "urgent" |
| #1043 | Émile Durand | 310.25 | Leave at the back door |
| #1044 | =HYPERLINK("x") | 18.00 | Looks like a formula |
The rows contain a comma, a quote, an accent, and a leading =. Open the file:
the first three come through exactly as shown, and the formula row is prefixed with an
apostrophe so the spreadsheet treats it as text.
The download attribute on a link is what makes the browser save the file instead of navigating to it, and a link never has to be in the DOM for click() to work.
Where this comes up
- Admin dashboards: every client eventually asks to “get this into Excel”.
- Reports and invoices lists: accounting wants a file, not a screenshot.
- Analytics tables: exporting beats building the one chart somebody needs once a quarter.
- Debugging: dumping a table of API results into a spreadsheet is often the fastest way to spot the odd row.
The four details that make it correct
Escaping. A cell containing a comma, a quote, or a line break breaks the file if you just join with commas. The rule from the CSV spec is small: wrap the cell in quotes, and double any quote inside it. That regex plus one replaceAll covers it.
The BOM. \uFEFF at the start is a byte order mark: one invisible character, three bytes in the file. Without it, Excel on Windows opens a UTF-8 file in the system’s legacy code page, and every name with an accent turns into mojibake. It’s the difference between “works” and “your export is broken”.
CRLF line endings. \r\n is what the CSV spec (RFC 4180) requires and what Excel itself writes. Use it and you never have to think about it again.
Revoking the object URL. Each createObjectURL pins the blob in memory until the page unloads or you revoke it. On a dashboard where someone exports twenty times, that adds up. Do it in a setTimeout, though: revoking in the same tick as the click can cancel the download before it starts.
One safety line worth adding
A spreadsheet treats any cell starting with =, +, -, or @ as a formula. If your data contains user-submitted text, someone can put a formula in their profile name, and it runs when a colleague opens the export. It’s called CSV injection, and the fix is one line inside the cell mapper:
let value = String(cell ?? '');
if (/^[=+\-@]/.test(value)) value = `'${value}`;
The leading apostrophe makes the spreadsheet treat the cell as text. It also catches negative numbers like -12, so if you have numeric columns, apply it to the text ones only. If your export contains anything a user typed, add it.
Nice touches
Timestamp the filename so exports don’t overwrite each other in the downloads folder:
downloadCSV(`orders-${new Date().toISOString().slice(0, 10)}.csv`, rows);
Export the data behind the table rather than the rendered text, when you have it. Rendered cells contain “€1,240.00” and “3 days ago”; the underlying values are 1240 and a real date, and those are what a spreadsheet can actually add up:
const rows = [
['Order', 'Customer', 'Total', 'Created'],
...orders.map((o) => [o.id, o.customer, o.total, o.createdAt]),
];
If the export is filtered, export what the user is looking at, not the whole dataset. That’s what they mean when they click the button.
Why this matters
- No endpoint to build: no route, no auth check, no serializer, no test, no deploy to wait on.
- Hundreds of kilobytes for string joining: the spreadsheet libraries people install for this ship an entire file format engine to produce a text file.
- It’s the request that keeps coming back: every client eventually wants the table in Excel, and now that’s an afternoon instead of a ticket in the next sprint.
Find the busiest table in your admin panel and put an Export button above it today. It is the single cheapest feature request you’ll close this month.
Happy coding!
Marko