Skip to content
datahacks.dev
Excel

Reading Excel in the Browser With SheetJS Without a Server

Parse an uploaded Excel file in the browser with SheetJS: read it, pick a sheet, convert to JSON, handle dates, limits and security, then export a workbook.

7 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the GNU Bash logo.

A web page that lets a user pick an Excel file, preview it, check it and upload only what is valid is a better experience than one that sends the raw file to a server and reports an error a minute later. SheetJS is the library that makes this possible in the browser: it parses xlsx, xls and csv in JavaScript, with no server involved. This post shows the whole path from file input to rows, the options that matter for dates and blanks, how to keep the page responsive, what to do about security and how to produce a workbook for download. It is a spoke of reading and writing Excel from JavaScript, Java and C Sharp.

Getting the library

Be careful where you install it from. The package called xlsx on npm was published at version 0.18.5 and not updated afterwards; that version has published security advisories, including a prototype pollution issue and a regular-expression denial of service. SheetJS now distributes current releases from its own server, and its installation page explains how to add them, for example by installing from a tarball URL on cdn.sheetjs.com or loading a script from there. Follow the vendor's current instructions and pin a version. Do not take a library that parses untrusted files from a stale package without checking its status.

For a page with no build step:

HTML
<input type="file" id="file" accept=".xlsx,.xls,.csv">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>

Check the version number against the vendor's page before using it. Self-hosting the script file gives you control over what loads.

Read the file

JavaScript
document.getElementById('file').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  if (file.size > 10 * 1024 * 1024) {
    alert('File is too large (10 MB limit)');
    return;
  }

  const buffer = await file.arrayBuffer();
  const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });

  console.log(workbook.SheetNames);        // ['Orders', 'Notes']
  const sheet = workbook.Sheets[workbook.SheetNames[0]];
  const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true });
  console.log(rows.slice(0, 3));
});

file.arrayBuffer() reads the whole file as bytes. XLSX.read with type: 'array' parses it into a workbook object whose SheetNames list the sheets and whose Sheets map holds each worksheet. The size check comes first, because parsing a very large file in the main thread freezes the page.

Convert a sheet to rows

sheet_to_json has several shapes of output, chosen by the header option.

OptionResult
(default)Array of objects, keys from the first row
header: 1Array of arrays, no key handling
header: ['a','b','c']Array of objects with your keys
JavaScript
const aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
const [header, ...body] = aoa;

defval: null fills empty cells with null; without it, missing cells are omitted from the object and the keys differ between rows. blankrows: false skips rows that are entirely empty. With default objects, duplicate header names are renamed (Amount, Amount_1), so check the headers explicitly if names matter (fixing duplicate column names when reading Excel). Use range to skip a title block: { range: 3 } starts at the fourth row, and a string like 'A5:F500' gives an explicit range.

Dates, numbers and text

Excel stores a date as a number. SheetJS gives you a choice.

  • **cellDates: true** (in read) makes date-formatted cells into JavaScript Date objects. Without it you get serial numbers.

  • **raw: false** (in sheet_to_json) returns formatted text, like 3/14/25, using the cell's number format. That text depends on the format, not on the user's locale, and it is rarely what you want for data.

  • **dateNF** sets a format for dates when producing text.

With cellDates, the Date is created in a way that can be off by the time zone: a cell showing 2025-03-14 may arrive as a Date equal to 2025-03-13T23:xx in a zone east of UTC, depending on the library version and the offset it assumes. The reliable approach is to format the date components you expect and check them against known cells, and to prefer reading dates as ISO text when you can. A test file with a date near midnight and a user in a far time zone shows whether your version behaves.

Numbers come as numbers. Large identifiers lose precision beyond 15 digits because they were stored that way (Excel number precision and the 15 digit limit); to keep an identifier like 00123, read with raw: false for that column or ask for the cell's text (cell.w). Individual cells are available as sheet['B2'], with .v for the value, .t for the type (n, s, b, d, e) and .w for the formatted text.

Validate before upload

The point of parsing in the browser is to check first. Compare the header with what you expect, and report problems by row number:

JavaScript
const expected = ['order', 'date', 'customer', 'amount'];
const got = header.map(h => String(h ?? '').trim().toLowerCase());
if (JSON.stringify(got) !== JSON.stringify(expected)) {
  throw new Error(`Unexpected columns: ${got.join(', ')}`);
}

const errors = [];
body.forEach((r, i) => {
  const rowNo = i + 2;                       // header is row 1
  if (!r[0]) errors.push(`Row ${rowNo}: order is required`);
  if (!(r[1] instanceof Date) || isNaN(r[1])) errors.push(`Row ${rowNo}: date is not valid`);
  if (typeof r[3] !== 'number' || r[3] < 0) errors.push(`Row ${rowNo}: amount must be a positive number`);
});

Show the first twenty errors to the user and let them fix the file. Then send the validated rows as JSON to your server. The server must validate again: anything validated in a browser is a convenience, since a client can send whatever it likes. Never trust the checks you ran in JavaScript for security.

Keep the page responsive

Parsing runs on the main thread. A 20 MB workbook can lock the interface for seconds. Move the work to a Web Worker:

JavaScript
// worker.js
importScripts('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
onmessage = (e) => {
  const wb = XLSX.read(e.data, { type: 'array', cellDates: true });
  const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { defval: null });
  postMessage(rows);
};
JavaScript
const worker = new Worker('worker.js');
worker.onmessage = (e) => render(e.data);
worker.postMessage(buffer, [buffer]);        // transfer, do not copy

Transferring the buffer avoids copying it. A progress indicator while the worker runs makes the wait understandable. For huge files, a browser is the wrong place; upload to a server that streams (reading Excel files in Node with exceljs).

Security

The file is untrusted input, even though it stays on the user's machine. A malicious workbook might aim to exploit the parser (prototype pollution, regular expression denial of service, memory exhaustion by a zip bomb) or to inject content into your page. Rules:

  • Use a current, vendor-distributed version, not the stale npm package.

  • Limit file size before reading, and consider limiting rows after.

  • Never insert cell text into the page with innerHTML. Use textContent, or escape. A cell containing <img onerror=...> is a cross-site scripting attack if rendered as HTML.

  • Do not follow hyperlinks in cells automatically.

  • Do not evaluate any cell content as code.

  • Run the parsing in a Worker, which keeps a crash or hang from taking down the page.

Export a workbook

SheetJS writes too:

JavaScript
const ws = XLSX.utils.json_to_sheet(rows, { header: ['order', 'date', 'customer', 'amount'] });
ws['!cols'] = [{ wch: 12 }, { wch: 12 }, { wch: 24 }, { wch: 12 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Orders');
XLSX.writeFile(wb, 'orders.xlsx');

writeFile triggers a download in the browser. Dates written as Date objects need a number format for display (ws['B2'].z = 'yyyy-mm-dd'). The community edition has limited support for styling (colors, fonts and so on); richer formatting is part of the vendor's paid edition, or you can use another library. Neutralize values that start with =, +, - or @ when they come from users (formula injection in Excel exports describes the CSV case).

Check the result

Compare the row count in the preview with the sheet, and a total with Excel. Test a file with a date at midnight, a text-formatted number, an empty row in the middle, a duplicate header and a formula cell (SheetJS gives the cached value in .v and the formula in .f). Test on a file from Excel and one saved by a script. Test the error messages with a bad file: a PDF renamed to xlsx should produce a clear message and not a stack trace.

When parsing in the browser is the wrong place

The file is large. Past tens of megabytes the browser struggles; upload and process on a server.

The result must be trusted. The server must validate regardless.

You need exact types. A workbook carries few types; define the schema and convert on the server.

The user may be offline or on a phone. Memory limits on mobile browsers are tight.

See the SheetJS documentation and MDN's page on the File API. To convert a small result to JSON, use the CSV to JSON converter.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.