CSV-to-XLSX in Node.js means choosing between a formatting-first library (xlsx-populate) and a ubiquitous but complex one (SheetJS). xlsx-populate has a cleaner API for styled output. SheetJS handles the full conversion pipeline in fewer lines. Both run in Node without system dependencies. For edge functions or environments without npm, the ChangeThisFile API handles the conversion via a standard fetch() call.
TL;DR
| Method | Install | Best for |
|---|---|---|
| xlsx-populate | npm install xlsx-populate | Styled output, clean API, full cell-level control |
| SheetJS (xlsx) | npm install xlsx | Fastest path, well-documented, widely used |
| ChangeThisFile API | None | Edge functions, no npm, or zero-dependency requirement |
Method 1: xlsx-populate (clean API, formatting-first)
xlsx-populate creates and modifies XLSX files with a straightforward row/column API. Styling is first-class — set bold headers, column widths, and cell alignment without verbose XML manipulation.
npm install xlsx-populateconst fs = require('fs');
const XlsxPopulate = require('xlsx-populate');
function parseCSV(text) {
// Handles quoted fields, commas inside quotes, escaped quotes
const rows = [];
let row = [], field = '', inQuote = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuote) {
if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; }
else if (ch === '"') inQuote = false;
else field += ch;
} else if (ch === '"') {
inQuote = true;
} else if (ch === ',') {
row.push(field); field = '';
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
if (ch === '\r') i++;
row.push(field); field = '';
if (row.some(v => v !== '')) rows.push(row);
row = [];
} else {
field += ch;
}
}
if (field || row.length) { row.push(field); if (row.some(v => v !== '')) rows.push(row); }
return rows;
}
async function csvToXlsx(csvPath, xlsxPath) {
const text = fs.readFileSync(csvPath, 'utf-8').replace(/^\ufeff/, '');
const rows = parseCSV(text);
const wb = await XlsxPopulate.fromBlankAsync();
const ws = wb.sheet(0).name('Data');
rows.forEach((row, ri) => {
row.forEach((value, ci) => {
const cell = ws.cell(ri + 1, ci + 1).value(value);
if (ri === 0) {
cell.style({
bold: true,
fill: { type: 'solid', color: 'D9E1F2' },
horizontalAlignment: 'center',
});
}
});
});
// Set column widths based on max content length (capped at 50)
if (rows.length > 0) {
rows[0].forEach((_, ci) => {
const maxLen = Math.max(...rows.map(r => (r[ci] || '').toString().length));
ws.column(ci + 1).width(Math.min(maxLen + 2, 50));
});
}
await wb.toFileAsync(xlsxPath);
}
csvToXlsx('data.csv', 'data.xlsx').then(() => console.log('Done'));
The custom CSV parser above handles quoted fields with embedded commas — don't use line.split(',') for real CSV data. For production use, replace it with a library like csv-parse which handles all RFC 4180 edge cases.
Method 2: SheetJS (xlsx) — most popular library
SheetJS is the most widely used JS spreadsheet library. It handles CSV parsing internally and produces XLSX in a handful of lines.
npm install xlsxconst fs = require('fs');
const XLSX = require('xlsx');
function csvToXlsx(csvPath, xlsxPath) {
// SheetJS can parse CSV directly
const wb = XLSX.readFile(csvPath, { type: 'file', raw: false });
XLSX.writeFile(wb, xlsxPath, { bookType: 'xlsx' });
}
csvToXlsx('data.csv', 'data.xlsx');
console.log('Done');
// With header styling and column widths
function csvToXlsxStyled(csvPath, xlsxPath) {
const csvText = fs.readFileSync(csvPath, 'utf-8');
const ws = XLSX.utils.sheet_from_csv(csvText);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Data');
// Set column widths based on content
const range = XLSX.utils.decode_range(ws['!ref'] || 'A1');
const colWidths = [];
for (let c = range.s.c; c <= range.e.c; c++) {
let maxLen = 10;
for (let r = range.s.r; r <= range.e.r; r++) {
const cell = ws[XLSX.utils.encode_cell({ r, c })];
if (cell && cell.v) maxLen = Math.max(maxLen, cell.v.toString().length);
}
colWidths.push({ wch: Math.min(maxLen + 2, 50) });
}
ws['!cols'] = colWidths;
XLSX.writeFile(wb, xlsxPath, { bookType: 'xlsx' });
}
csvToXlsxStyled('data.csv', 'data.xlsx');
SheetJS's readFile detects the file type from the extension — it parses CSV automatically when given a .csv path. The community edition (MIT) is free; the Pro edition adds streaming for huge files. For CSVs under 50MB, the community edition is sufficient.
Method 3: ChangeThisFile API (built-in fetch, no npm package)
POST the CSV with Node's built-in fetch (Node 18+) or the https module (Node 16). Pass target=xlsx — source is auto-detected from the filename. Free tier: 1,000 conversions/month, no card needed.
# Test with curl first
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key" \
-F "file=@data.csv" \
-F "target=xlsx" \
--output data.xlsx
// Node 18+ (built-in fetch)
const fs = require('fs');
const path = require('path');
const { FormData, Blob } = require('node:buffer');
const API_KEY = 'ctf_sk_your_key_here';
async function csvToXlsxApi(csvPath, xlsxPath) {
const fileBuffer = fs.readFileSync(csvPath);
const blob = new Blob([fileBuffer], { type: 'text/csv' });
const form = new FormData();
form.append('file', blob, path.basename(csvPath));
form.append('target', 'xlsx');
const res = await fetch('https://changethisfile.com/v1/convert', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: form,
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
const arrayBuffer = await res.arrayBuffer();
fs.writeFileSync(xlsxPath, Buffer.from(arrayBuffer));
}
csvToXlsxApi('data.csv', 'data.xlsx').then(() => console.log('Done'));
Node 16 and earlier don't have built-in fetch. Use the https module with multipart body construction, or install the node-fetch package.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| xlsx-populate | Formatted output with clean cell-level API | Less popular than SheetJS; fewer community examples |
| SheetJS | Quickest path, large community, browser + Node support | Pro features (streaming) require commercial license |
| ChangeThisFile API | Edge runtimes, no npm, serverless functions | Network call; 25MB file limit on free tier |
Production tips
- Strip UTF-8 BOM before parsing. Excel-exported CSVs often start with \ufeff. Strip it before parsing:
text.replace(/^\ufeff/, ''). Both SheetJS and xlsx-populate handle it silently, but a manual parser will break on it. - Never split CSV lines on comma. Use a proper CSV parser (csv-parse, SheetJS's built-in parser) to handle quoted fields.
'a,"b,c",d'.split(',')gives 4 elements, not 3. - Use streams for large CSVs in Node. For CSVs over 50MB, pipe a ReadStream through csv-parse and write rows incrementally rather than loading the whole file into memory.
- Freeze the header row. xlsx-populate:
ws.freezeRows(1). SheetJS: setws['!freeze'] = { ySplit: 1 }. Makes the XLSX usable for non-technical recipients who will scroll large datasets. - For Cloudflare Workers, use the API. SheetJS and xlsx-populate both have Node.js dependencies that don't run in the Workers runtime. The fetch-based API approach works in any edge runtime.
For Node.js projects, SheetJS is the quickest path and has the largest community. xlsx-populate has a cleaner API when you need styled output. The ChangeThisFile API requires no npm package and works in any runtime that supports fetch. Free tier: 1,000 conversions/month.