Skip to content

How to read an Excel file in JavaScript

Browsers cannot open Excel files on their own. An .xlsx file is a zip archive of XML documents, and an .xls file is a binary format from Excel 97-2003, so you need a parser that turns the bytes into JavaScript values. This guide shows how to do that in the browser with TabularJS, from the three places a file usually comes from: a file input, a drag and drop zone, and a URL.

Everything runs on the client. The file is never uploaded, which makes this a good fit for previews, import screens and offline tools.

If you are reading files on a server, see Read an Excel file in Node.js instead.

With a bundler (Vite, webpack, Next.js…):

Terminal window
npm install tabularjs
import tabularjs from 'tabularjs';

Without a build step, load the script from a CDN. It defines a global tabularjs function:

<script src="https://cdn.jsdelivr.net/npm/tabularjs/dist/index.min.js"></script>

Both give you the same function: tabularjs(file, options), which returns a Promise.

This is the most common case. The user picks a file, the change event gives you a File object, and you pass it straight to tabularjs.

<input type="file" id="file" accept=".xlsx,.xls" />
<pre id="output"></pre>
<script src="https://cdn.jsdelivr.net/npm/tabularjs/dist/index.min.js"></script>
<script>
document.getElementById('file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const result = await tabularjs(file);
const sheet = result.worksheets[0];
document.getElementById('output').textContent =
`${sheet.worksheetName}: ${sheet.data.length} rows\n` +
JSON.stringify(sheet.data.slice(0, 5), null, 2);
} catch (err) {
document.getElementById('output').textContent = 'Could not read the file: ' + err.message;
}
});
</script>

The accept attribute only filters the file picker. Users can still choose “All files”, so keep the try/catch.

You do not need FileReader. TabularJS reads the File itself and picks the parser from the file name (.xlsx or .xls), falling back to the content when the name has no known extension.

Take a workbook with two sheets, Orders and Customers. The Orders sheet looks like this in Excel:

Order Customer Date Qty Price Total Paid
1001 Acme Corp 2024-03-15 3 19.99 =D2*E2 TRUE
1002 Globex 2024-03-18 8 5.5 =D3*E3 FALSE

tabularjs(file) resolves with one entry per sheet in worksheets:

{
worksheets: [
{
worksheetName: 'Orders',
data: [
['Order', 'Customer', 'Date', 'Qty', 'Price', 'Total', 'Paid'],
[1001, 'Acme Corp', 45366, 3, 19.99, '=D2*E2', true],
[1002, 'Globex', 45369, 8, 5.5, '=D3*E3', false]
],
columns: [{ title: 'A', width: 100, type: 'text' }, /* ... */],
cells: { C2: { format: 'yyyy-mm-dd' }, C3: { format: 'yyyy-mm-dd' } },
// rows, style, mergeCells, comments, minDimensions...
},
{
worksheetName: 'Customers',
data: [['Name', 'Country'], ['Acme Corp', 'US'], ['Globex', 'DE']],
// ...
}
],
style: ['font-family: Calibri; font-size: 11pt']
}

The parts you will use most:

  • worksheet.data is a 2D array: data[row][col], both zero-based. data[0] is the first row of the sheet, which is usually the header.
  • Numbers come back as numbers and TRUE/FALSE as booleans. Text stays a string.
  • The grid is rectangular. Empty cells are '', including empty rows in the middle of the sheet.
  • Formulas are returned as text with the leading = ('=D2*E2'). They are not calculated. See Formulas below.
  • worksheet.worksheetName is the tab name. There is no name property.

The full list of properties (styles, merged cells, comments, column widths) is in Output structure.

Once you have data, rendering it is plain DOM work. Use textContent rather than innerHTML so a cell that contains <script> stays text.

function renderTable(rows, container) {
const table = document.createElement('table');
rows.forEach((row, r) => {
const tr = table.insertRow();
row.forEach((value) => {
const cell = document.createElement(r === 0 ? 'th' : 'td');
cell.textContent = value;
tr.appendChild(cell);
});
});
container.replaceChildren(table);
}
const result = await tabularjs(file);
renderTable(result.worksheets[0].data, document.getElementById('preview'));

For large sheets, render the first few hundred rows as a preview, or pass the whole result to a grid component. The result can be given directly to Jspreadsheet (jspreadsheet(element, result)), which keeps styles, merged cells and formulas.

worksheets[0] is not always the sheet the user cares about, and it can even be a hidden sheet. Hidden sheets are included in the result with worksheetState set to 'hidden' or 'veryHidden'. A small select fixes both problems:

const result = await tabularjs(file);
const visible = result.worksheets.filter((s) => !s.worksheetState);
const select = document.getElementById('sheet');
select.replaceChildren(...visible.map((s, i) => new Option(s.worksheetName, i)));
select.onchange = () => renderTable(visible[select.value].data, preview);
renderTable(visible[0].data, preview);

A drop event carries the files in e.dataTransfer.files. Each entry is a File, so the TabularJS call is the same as with the input. The one thing you must not forget is preventDefault() on dragover; without it the browser opens the file in the tab instead of firing drop.

<div id="drop" style="border: 2px dashed #999; padding: 40px; text-align: center">
Drop an Excel file here
</div>
<script>
const zone = document.getElementById('drop');
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.style.background = '#eef';
});
zone.addEventListener('dragleave', () => {
zone.style.background = '';
});
zone.addEventListener('drop', async (e) => {
e.preventDefault();
zone.style.background = '';
const file = e.dataTransfer.files[0];
if (!file) return;
try {
const result = await tabularjs(file);
console.log(result.worksheets.map((s) => s.worksheetName));
} catch (err) {
alert('Could not read ' + file.name + ': ' + err.message);
}
});
</script>

To accept several files at once, loop over e.dataTransfer.files and await tabularjs(f) for each one. A complete page is in the drag and drop example.

When the file lives on your server or a storage bucket, download it with fetch, read the body as an ArrayBuffer and pass that. Raw bytes have no file name, so TabularJS detects the format from the content: XLSX and XLS are both recognized.

async function loadWorkbook(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
return tabularjs(bytes);
}
const result = await loadWorkbook('/reports/q3-sales.xlsx');
console.log(result.worksheets[0].data);

Check response.ok. A missing file often returns a 404 HTML page, and TabularJS will try to parse that page as HTML and reject with No table elements found in HTML, which is a confusing message if you were expecting a spreadsheet.

The same code works in Node.js 18+, Deno, Bun and edge runtimes, since they all have fetch. See the URL response example.

Excel does not store dates as dates. It stores a number of days since 1899-12-30, and the cell’s number format makes Excel display it as a date. TabularJS returns that stored number, and puts the number format in worksheet.cells:

const sheet = result.worksheets[0];
sheet.data[1][2]; // 45366
sheet.cells.C2.format; // 'yyyy-mm-dd'

To turn the serial number into a JavaScript Date:

function excelDateToJS(serial) {
return new Date(Math.round((serial - 25569) * 86400 * 1000));
}
excelDateToJS(45366).toISOString(); // '2024-03-15T00:00:00.000Z'
excelDateToJS(45371.60416666666).toISOString(); // '2024-03-20T14:30:00.000Z'

The fractional part is the time of day. The result is in UTC because Excel dates have no time zone; format it with toISOString().slice(0, 10) or Intl.DateTimeFormat with timeZone: 'UTC' to avoid it shifting by a day in your user’s time zone. The Excel to JSON guide shows how to convert every date column automatically using the cell formats.

A cell with =D2*E2 comes back as the string '=D2*E2', not the value Excel displayed. TabularJS never evaluates formulas, which keeps it safe for untrusted files and preserves the formula itself. If you need computed values, you have two options: load the result into a spreadsheet component that has a formula engine (such as Jspreadsheet), or ask users to paste values (Paste Special > Values) before exporting. More in Formulas.

Password-protected workbooks. An .xls file encrypted with a password to open rejects with Workbook is encrypted with a password. Show the user a clear message asking for an unprotected copy. Files that are only “read-only recommended” open normally.

Wrong file type. A PDF or image renamed or chosen by mistake rejects with Unsupported file type: .... Catch errors around every call; see the list of errors.

Large files freeze the page. Parsing happens on the main thread. For workbooks of tens of megabytes, show a loading state before calling tabularjs and consider running it in a Web Worker so the UI stays responsive.

Opening the page from file://. fetch('/data.xlsx') does not work from a page opened by double-clicking the HTML file. Serve the folder with any local web server (npx serve, for example). The file input and drag and drop work either way.

Cross-origin downloads. Fetching a file from another domain needs CORS headers on that server. If you control the bucket, allow your origin; otherwise download it through your own backend.

.xlsm and CSV. Macro-enabled .xlsm workbooks use the same zip layout as .xlsx: they are detected from the content and the macros are ignored. If your users also upload CSV files, the same call handles them, but every value is a string: see Parse CSV in JavaScript.

TabularJS only reads files. If you need to generate an Excel file in the browser, use a library that writes XLSX, such as ExcelJS or SheetJS. If your data is already in a Google Sheet, its API or a published CSV link is simpler than downloading an .xlsx.