JavaScript Excel handling has two serious libraries: SheetJS (the xlsx package) and exceljs. SheetJS is more popular and runs everywhere. exceljs is more modern with cleaner async APIs. Both handle the basic XLSX-to-CSV case in a few lines.
Method 1: SheetJS / xlsx (works everywhere)
SheetJS is the canonical choice. It runs in browsers, Node, Deno, and React Native. The community edition is free and MIT-licensed.
npm install xlsx
import * as XLSX from "xlsx";
import fs from "node:fs";
function xlsxToCsv(xlsxPath, csvPath, sheetName = null) {
const workbook = XLSX.readFile(xlsxPath);
const targetSheet = sheetName || workbook.SheetNames[0]; // first sheet by default
const sheet = workbook.Sheets[targetSheet];
const csv = XLSX.utils.sheet_to_csv(sheet);
fs.writeFileSync(csvPath, csv, "utf8");
}
xlsxToCsv("sales.xlsx", "sales.csv");
For multi-sheet workbooks, write one CSV per sheet:
function xlsxToCsvs(xlsxPath, outDir) {
const workbook = XLSX.readFile(xlsxPath);
for (const name of workbook.SheetNames) {
const csv = XLSX.utils.sheet_to_csv(workbook.Sheets[name]);
const safeName = name.replace(/[\/\\? *]/g, "_");
fs.writeFileSync(`${outDir}/${safeName}.csv`, csv, "utf8");
}
}
xlsxToCsvs("workbook.xlsx", "sheets/");
For browser usage, take the file from an input:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
const buffer = await e.target.files[0].arrayBuffer();
const workbook = XLSX.read(buffer);
const csv = XLSX.utils.sheet_to_csv(workbook.Sheets[workbook.SheetNames[0]]);
// download:
const blob = new Blob([csv], { type: "text/csv" });
const a = Object.assign(document.createElement("a"), {
href: URL.createObjectURL(blob),
download: "sheet.csv",
});
a.click();
});
Method 2: exceljs (modern, streaming)
exceljs has a cleaner async API and supports streaming for huge workbooks.
npm install exceljs
import ExcelJS from "exceljs";
import fs from "node:fs/promises";
async function xlsxToCsv(xlsxPath, csvPath, sheetName = null) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(xlsxPath);
const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0];
const rows = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
rows.push(row.values.slice(1)); // .slice(1) drops 1-based first element
});
const csv = rows.map(r => r.map(formatCell).join(",")).join("\n");
await fs.writeFile(csvPath, csv, "utf8");
}
function formatCell(v) {
if (v === null || v === undefined) return "";
if (v instanceof Date) return v.toISOString().slice(0, 10);
const s = String(v);
// Quote if contains comma, quote, or newline:
return /[,"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
await xlsxToCsv("sales.xlsx", "sales.csv");
For multi-GB workbooks, use exceljs's streaming reader:
const reader = new ExcelJS.stream.xlsx.WorkbookReader(xlsxPath, { entries: "emit" });
for await (const worksheet of reader) {
for await (const row of worksheet) {
// process row without buffering whole workbook
}
}
Method 3: ChangeThisFile API (no library, edge-runtime friendly)
If you can't ship SheetJS (~300KB minified) or are running in an edge runtime, the API is the simplest path.
import fs from "node:fs";
const API_KEY = "sk_test_your_key_here";
async function xlsxToCsv(xlsxPath, csvPath) {
const buffer = fs.readFileSync(xlsxPath);
const form = new FormData();
form.append("file", new Blob([buffer]), "input.xlsx");
form.append("source", "xlsx");
form.append("target", "csv");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
fs.writeFileSync(csvPath, await response.text());
}
await xlsxToCsv("user_upload.xlsx", "clean.csv");
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| SheetJS / xlsx | Default for most JS projects, browser + Node | ~300KB minified bundle |
| exceljs | Streaming huge workbooks, modern async API | Larger API surface to learn |
| ChangeThisFile API | Edge runtimes, varied user input, zero deps | Per-call cost |
Common pitfalls
- SheetJS dates come back as numbers. Excel internally stores dates as numbers (days since 1900). SheetJS's sheet_to_csv handles this if cellDates: true is set on the read call: XLSX.read(buf, { cellDates: true }).
- Empty rows at the end. Some tools write empty trailing rows. Both SheetJS and exceljs include them in output by default. Filter or pass options to skip empty rows.
- Number formatting. Excel can format the same number 1234.5 as "$1,234.50" via cell formatting. The CSV gets the underlying number 1234.5, not the formatted string. To get formatted values, use sheet_to_csv with rawNumbers: false.
- Bundle size in browsers. SheetJS is ~300KB. If that's too much, the API removes the bundle entirely.
SheetJS is the right default for most JS Excel work. exceljs wins on streaming and modern async. The API wins when you want zero parsing dependencies in your bundle. Free tier gives 1,000 conversions/month.