Parse Excel Sheets and Import to Supabase

Build a TypeScript CLI tool that parses Excel files and batch-inserts data into Supabase.

Introduction

30 Second Summary

Every company has that one crucial spreadsheet. Employee records, product inventories, customer lists. At some point, someone needs to get that data out of Excel and into a real database.

In this project, you will build a command-line TypeScript tool that reads an Excel spreadsheet, validates every row, and batch-inserts the clean data into a Supabase database.

What You'll Build

A TypeScript data pipeline that parses .xlsx files, maps messy Excel columns to a database schema, validates each row, and inserts clean data into Supabase while logging errors for invalid rows.

By the end of this project, you'll have:

  • A sample data generator script that creates a test Excel file with realistic employee data and intentionally bad rows.
  • A main import script with typed interfaces, column mapping, row validation, and batch insert logic.
  • A populated employees table in your Supabase project with clean, validated data.
  • Secret Mission: Extend the importer to process multiple Excel files from a directory and generate a consolidated summary report.

Are there any prerequisites?

You'll need a Supabase project already set up on the free tier. If you don't have one yet, create a free account at supabase.com and start a new project before beginning Step 1.

Set Up the Project

Before you can parse Excel files and push data to Supabase, you need a working TypeScript environment on your machine.

In this step, you will install Node.js, create a project folder, and pull in the packages that handle spreadsheet parsing and database connections.

In this step, get ready to:
  • Install Node.js to run TypeScript on your machine.
  • Create a project folder and install packages for Excel parsing and Supabase.
  • Configure TypeScript and connect your project to your Supabase credentials.
Install Node.js

Node.js is the runtime that lets you execute TypeScript (via a tool called tsx) directly on your machine.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Check if Node.js is already installed by running this command:
node --version

✔️ I see version 22 or higher

Your output looks something like v22.x.x. Node.js is ready to go.

  • Also confirm npm is available by running:
npm --version

You should see a version number printed. You are all set to continue.

ⓧ I see an older version

If your version is below 22, you need to upgrade. The macOS .pkg installer from the Node.js website replaces the old version automatically.

  • Open https://nodejs.org in your browser.
  • Download the Node.js 22 LTS macOS installer (.pkg file).
  • Open the downloaded file and follow the prompts to complete installation.
  • Close and reopen your VS Code terminal, then run node --version again to confirm it shows v22.x.x.

ⓧ Command not found

Node.js is not installed yet. Let's grab the official installer.

  • Open https://nodejs.org in your browser.
  • Download the Node.js 22 LTS macOS installer (.pkg file).
  • Open the downloaded file and follow the prompts to complete installation.
  • Close and reopen your VS Code terminal, then run node --version to confirm it shows v22.x.x.
  • Also run npm --version to confirm npm was installed alongside Node.js.

Still seeing the old version after installing?

Close all VS Code windows entirely and reopen the application. VS Code caches the terminal's PATH at launch, so it may not detect the new install until a full restart.

help me troubleshoot my Node.js installation

Create the project and install dependencies

Now you will create a dedicated folder for the project and pull in the libraries for Excel parsing and Supabase.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir excel-to-supabase
cd excel-to-supabase
  • Confirm the folder exists by running:
ls

You should see no output because the folder is empty. That is expected.

  • Initialize a new npm project by running:
npm init -y

What does this command do?

npm init -y creates a package.json file with sensible defaults. This file tracks your project's name, version, and all installed packages.

The project uses ES modules (the modern import/export syntax). You need to tell Node.js about this.

  • Open package.json in VS Code by clicking it in the Explorer sidebar.
  • Find this line near the top:
"version": "1.0.0",
  • Add a new line directly below it so it looks like this:
"version": "1.0.0",
"type": "module",
  • Save package.json.
  • Install the main project dependencies by running:
npm i --save https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz @supabase/supabase-js

What are these packages?

SheetJS (the xlsx package) reads and writes Excel files. It is installed from a CDN tarball because the npm registry version is outdated.

@supabase/supabase-js is the official JavaScript client for connecting to your Supabase database.

  • Install the development dependencies by running:
npm i -D tsx typescript @types/node

What are these dev dependencies?

tsx runs TypeScript files directly without a separate compile step. typescript provides the type-checking engine. @types/node adds type definitions for built-in Node.js modules like fs.

Seeing permission errors during install?

If you see EACCES or permission denied errors, your Node.js install may need elevated access. Try prefixing the command with sudo (macOS) or running your terminal as Administrator (Windows).

help me fix npm permission errors

Configure TypeScript and environment variables

TypeScript needs a configuration file to know how to check your code. You also need a file to store your Supabase connection details.

  • Create a new file in VS Code by pressing Cmd+N (macOS) or Ctrl+N (Windows).
  • Paste the following TypeScript configuration:
{
  "compilerOptions": {
    "strict": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "target": "ES2022",
    "skipLibCheck": true
  }
}

What does this configuration do?

strict enables full type checking so TypeScript catches errors early. module and moduleResolution tell TypeScript to use modern ES module imports.

target sets the JavaScript version your code compiles to. skipLibCheck speeds up compilation by not type-checking third-party packages.

  • Save the file as tsconfig.json in the root of your excel-to-supabase folder by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • When the save dialog appears, name the file tsconfig.json and make sure you are saving inside the excel-to-supabase folder.
  • Create another new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
  • Paste your Supabase connection details:
SUPABASE_URL=your-project-url-here
SUPABASE_PUBLISHABLE_KEY=your-anon-key-here

Where do I find these values?

Open your Supabase project dashboard. Click Settings in the left sidebar, then API. Copy the Project URL and paste it as the value for SUPABASE_URL.

On the same page, find the Project API keys section and copy the publishable (anon) key. Paste it as the value for SUPABASE_PUBLISHABLE_KEY.

  • Replace your-project-url-here with your actual Supabase project URL.
  • Replace your-anon-key-here with your actual publishable key.
  • Save the file as .env in the root of your excel-to-supabase folder.

Keep your .env file private

Never commit .env files to version control. If you add a .gitignore later, include .env in it.

  • Create the two directories your project needs by running this in the terminal:
mkdir src data
  • Confirm everything is in place by running:
ls

You should see data, node_modules, package.json, src, tsconfig.json, and .env listed.

  • Verify that tsx is working by running:
npx tsx --version

✔️ I see a version number

You should see a version like 4.22.4 printed. Your TypeScript execution tool is ready.

ⓧ I see an error or command not found

This means the dev dependencies did not install correctly.

  • Make sure you are inside the excel-to-supabase folder (run pwd to check).
  • Re-run the dev dependency install:
npm i -D tsx typescript @types/node
  • Then try npx tsx --version again.

help me fix tsx not being found after npm install

✔️ Awesome, I've got everything!

Great. Make sure you have saved both tsconfig.json and .env before moving on.

ⓧ I'd like to double check the full code

Here is what tsconfig.json should look like:

{
  "compilerOptions": {
    "strict": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "target": "ES2022",
    "skipLibCheck": true
  }
}

Here is what .env should look like (with your real values substituted):

SUPABASE_URL=your-project-url-here
SUPABASE_PUBLISHABLE_KEY=your-anon-key-here

Here is what package.json should look like. Your file may have extra default fields from npm init. The important parts are type, dependencies, and devDependencies:

{
  "name": "excel-to-supabase",
  "version": "1.0.0",
  "type": "module",
  "scripts": {},
  "dependencies": {
    "@supabase/supabase-js": "^2.107.0",
    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.22.4",
    "typescript": "^5.7.0"
  }
}

Your project skeleton is ready. Next up, you will create a sample Excel file and set up the Supabase table that will receive the data.

Create Sample Data and Supabase Table

Your development environment is ready. Now you need two things before you can build the importer: a database table to receive the data, and a sample Excel file to test with.

In this step, you will create the employees table in your Supabase project and generate a realistic test spreadsheet. The test data includes intentionally invalid rows so you can verify that your importer handles errors gracefully later.

In this step, get ready to:
  • Create an employees table in Supabase with the correct schema.
  • Write a TypeScript script that generates a sample .xlsx file with realistic employee data.
  • Run the generator and confirm the Excel file was created.
Create the employees table in Supabase

Your importer needs a destination table with a defined schema. You will create this table using SQL in the Supabase dashboard.

  • Return to your Supabase project dashboard in the browser.
  • Click SQL Editor in the left sidebar.
  • Paste the following SQL into the editor:
-- Create the employees table to receive imported data
CREATE TABLE employees (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL,
  department TEXT NOT NULL,
  start_date DATE NOT NULL,
  salary NUMERIC NOT NULL
);

-- Grant API access so the publishable key can insert rows
GRANT SELECT, INSERT, UPDATE, DELETE ON employees TO anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON employees TO authenticated;
GRANT USAGE, SELECT ON SEQUENCE employees_id_seq TO anon;
GRANT USAGE, SELECT ON SEQUENCE employees_id_seq TO authenticated;
  • Click Run to execute the query.

What does this SQL do?

The CREATE TABLE statement defines the structure your importer will write to. Each column matches a field in the Excel spreadsheet: name, email, department, start date, and salary.

The GRANT statements allow the Supabase publishable key to read from and write to the table. Without these, your import script would get a permissions error.

  • Click Table Editor in the left sidebar.
  • You should see employees listed in your tables. Click it to confirm the columns appear: id, name, email, department, start_date, and salary.

Don't see the employees table?

Make sure you selected the public schema in the Table Editor dropdown. If the SQL returned an error, check for typos in column names or missing commas between column definitions.

help me debug why my employees table didn't get created in Supabase

Why no Row Level Security?

Supabase uses Row Level Security (RLS) to control who can access each row in a table. For this learning project, you are not enabling RLS so that your import script can freely insert data using the publishable key. In a production app, you would enable RLS and add policies to restrict access.

Write the sample data generator

Before you build the importer, you need test data to work with. You will write a script that generates an Excel file containing seven employee rows. Five rows are valid, and two contain intentional errors (missing email, missing name, invalid date, negative salary).

  • Switch back to VS Code.
  • Create a new file called generate-sample.ts inside the src folder.
  • Add the imports and sample data array by pasting this code:
import { writeFileSync, mkdirSync, existsSync } from "fs";
import * as XLSX from "xlsx";

// Sample employee data: 5 valid rows + 2 intentionally invalid rows
const employees = [
  { Name: "Alice Johnson", Email: "alice@company.com", Department: "Engineering", "Start Date": "2023-01-15", Salary: 95000 },
  { Name: "Bob Smith", Email: "bob@company.com", Department: "Marketing", "Start Date": "2022-06-01", Salary: 78000 },
  { Name: "Carol Williams", Email: "carol@company.com", Department: "Engineering", "Start Date": "2024-03-10", Salary: 102000 },
  { Name: "David Brown", Email: "david@company.com", Department: "Sales", "Start Date": "2021-11-20", Salary: 85000 },
  { Name: "Eva Martinez", Email: "eva@company.com", Department: "Marketing", "Start Date": "2023-08-05", Salary: 72000 },
  { Name: "Frank Lee", Email: "", Department: "Engineering", "Start Date": "2024-01-02", Salary: 98000 },
  { Name: "", Email: "ghost@company.com", Department: "Sales", "Start Date": "invalid-date", Salary: -5000 },
];

What does this data represent?

The first five objects are valid employee records with realistic names, emails, departments, dates, and salaries. The last two rows are intentionally broken.

Frank Lee has an empty email string. The final row has no name, an unparseable date string, and a negative salary. These will let you test your validation logic in the next step.

You will not see a change yet. The next chunk uses this data to write the Excel file.

  • Add the file-generation logic below the employees array in the same file:
// Ensure the data directory exists
if (!existsSync("./data")) {
  mkdirSync("./data");
}

// Convert the JSON array into a SheetJS worksheet
const worksheet = XLSX.utils.json_to_sheet(employees);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Employees");

// Write the workbook to a buffer, then save to disk
const xlsxBuffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
writeFileSync("./data/employees.xlsx", xlsxBuffer);

console.log("Sample file created: ./data/employees.xlsx");
console.log(`Contains ${employees.length} rows (${employees.length - 2} valid, 2 with errors)`);

What does this code do?

SheetJS converts your JavaScript array of objects into a proper Excel worksheet. json_to_sheet maps object keys to column headers automatically.

Instead of calling XLSX.writeFile() (which requires extra setup in ESM mode), the script uses XLSX.write() to get a buffer, then writes it with Node.js writeFileSync. This keeps the code simple and avoids configuration issues.

  • Save src/generate-sample.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before running it.

ⓧ I'd like to double check the full code

import { writeFileSync, mkdirSync, existsSync } from "fs";
import * as XLSX from "xlsx";

// Sample employee data: 5 valid rows + 2 intentionally invalid rows
const employees = [
  { Name: "Alice Johnson", Email: "alice@company.com", Department: "Engineering", "Start Date": "2023-01-15", Salary: 95000 },
  { Name: "Bob Smith", Email: "bob@company.com", Department: "Marketing", "Start Date": "2022-06-01", Salary: 78000 },
  { Name: "Carol Williams", Email: "carol@company.com", Department: "Engineering", "Start Date": "2024-03-10", Salary: 102000 },
  { Name: "David Brown", Email: "david@company.com", Department: "Sales", "Start Date": "2021-11-20", Salary: 85000 },
  { Name: "Eva Martinez", Email: "eva@company.com", Department: "Marketing", "Start Date": "2023-08-05", Salary: 72000 },
  { Name: "Frank Lee", Email: "", Department: "Engineering", "Start Date": "2024-01-02", Salary: 98000 },
  { Name: "", Email: "ghost@company.com", Department: "Sales", "Start Date": "invalid-date", Salary: -5000 },
];

// Ensure the data directory exists
if (!existsSync("./data")) {
  mkdirSync("./data");
}

// Convert the JSON array into a SheetJS worksheet
const worksheet = XLSX.utils.json_to_sheet(employees);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Employees");

// Write the workbook to a buffer, then save to disk
const xlsxBuffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
writeFileSync("./data/employees.xlsx", xlsxBuffer);

console.log("Sample file created: ./data/employees.xlsx");
console.log(`Contains ${employees.length} rows (${employees.length - 2} valid, 2 with errors)`);
  • Run the generator script by entering this command in your VS Code terminal:
npx tsx ./src/generate-sample.ts
  • You should see this output in the terminal:
Sample file created: ./data/employees.xlsx
Contains 7 rows (5 valid, 2 with errors)
  • Confirm the file exists by running:
ls ./data
  • You should see employees.xlsx in the output.

Script failed or no file created?

Make sure your terminal is in the excel-to-supabase directory. If you see a module not found error for xlsx, re-run the install command from Step 1.

If you see a syntax error, check that your tsconfig.json has "module": "ESNext" and that package.json has "type": "module".

help me fix my generate-sample.ts script that isn't producing the xlsx file

Your database table is waiting and your test data is generated. Next up, you will write the TypeScript code that reads this Excel file, maps its columns to the database schema, and transforms each row into a clean record.

Parse Excel and Map to Schema

You have a sample Excel file sitting in your data/ folder, and a Supabase table waiting for clean rows.

The problem is that Excel column names rarely match database column names. "Start Date" needs to become start_date, values might have trailing spaces, and some rows might be missing required data entirely. In this step, you will build the parsing and mapping layer that transforms raw spreadsheet data into validated, database-ready objects.

In this step, get ready to:
  • Define TypeScript interfaces for Excel rows and database records.
  • Parse an Excel file into typed JSON using SheetJS.
  • Map and validate each row before it reaches the database.
Define TypeScript interfaces

Before writing any logic, you need two contracts: one describing what SheetJS gives you (the raw Excel row), and one describing what Supabase expects (the clean database record).

  • Create a new file called src/import-single.ts inside your excel-to-supabase project folder.
  • Add the following imports and interface definitions:
import { readFileSync } from "fs";
import * as XLSX from "xlsx";
import { createClient } from "@supabase/supabase-js";

// Types matching Excel column headers
interface ExcelRow {
  Name: string;
  Email: string;
  Department: string;
  "Start Date": string;
  Salary: number;
}

// Types matching Supabase table schema
interface Employee {
  name: string;
  email: string;
  department: string;
  start_date: string;
  salary: number;
}

interface ValidationResult {
  valid: Employee[];
  invalid: { row: number; reason: string }[];
}

What does this code do?

ExcelRow mirrors the exact column headers in your Excel file (capitalized, with spaces). Employee mirrors your Supabase table columns (lowercase, with underscores).

ValidationResult will hold two arrays: rows that passed validation and rows that failed (with the reason why). The createClient import is for the next step when you connect to Supabase.

  • Save src/import-single.ts.

Why two separate interfaces?

Excel files are authored by people who use human-friendly names ("Start Date", "Salary"). Databases use machine-friendly names (start_date, salary). By defining both shapes upfront, TypeScript catches any mismatch at compile time rather than letting bad data slip into your database at runtime.

Parse the Excel file

With your types defined, you can write a function that reads the .xlsx file from disk and converts its first sheet into a typed array of JavaScript objects.

  • In src/import-single.ts, add the following function below the interfaces:
// Parse Excel file into typed rows
function parseExcel(filePath: string): ExcelRow[] {
  const fileBuffer = readFileSync(filePath);
  const workbook = XLSX.read(fileBuffer);
  const sheetName = workbook.SheetNames[0];
  const worksheet = workbook.Sheets[sheetName];
  return XLSX.utils.sheet_to_json<ExcelRow>(worksheet);
}

What does this code do?

  • readFileSync reads the entire .xlsx file into a buffer (binary data in memory).
  • XLSX.read(fileBuffer) parses that buffer into a SheetJS workbook object containing all sheets.
  • XLSX.utils.sheet_to_json<ExcelRow>(worksheet) converts the first sheet into an array of objects. The generic <ExcelRow> tells TypeScript what shape each object has.
  • Save src/import-single.ts.

Seeing a red underline on the XLSX import?

Make sure your tsconfig.json has "esModuleInterop": true. Without it, the namespace import (import * as XLSX) may not resolve correctly.

Help me fix my XLSX import in TypeScript

Map columns to database schema

Parsing gives you raw Excel data, but those objects still have uppercase keys, potential whitespace, and no validation. You need three more functions: one to map column names, one to validate values, and one to orchestrate both.

  • In src/import-single.ts, add the mapToSchema function below parseExcel:
// Map Excel columns to Supabase schema
function mapToSchema(row: ExcelRow): Employee {
  return {
    name: (row.Name ?? "").trim(),
    email: (row.Email ?? "").trim(),
    department: (row.Department ?? "").trim(),
    start_date: row["Start Date"] ?? "",
    salary: Number(row.Salary) || 0,
  };
}

What does this code do?

  • Each property is renamed from the Excel convention ("Name") to the database convention (name).
  • The ?? operator provides a fallback empty string if a cell is completely missing.
  • .trim() removes leading and trailing whitespace from strings.
  • Number(row.Salary) || 0 safely coerces the salary to a number, defaulting to 0 if it cannot be parsed.
  • Now add the validateEmployee function below mapToSchema:
// Validate a single employee record
function validateEmployee(employee: Employee): string | null {
  if (!employee.name) return "Name is required";
  if (!employee.email || !employee.email.includes("@")) return "Valid email is required";
  if (!employee.department) return "Department is required";
  if (isNaN(Date.parse(employee.start_date))) return "Invalid start date";
  if (employee.salary <= 0) return "Salary must be positive";
  return null;
}

What does this code do?

This function checks each field against a business rule. It returns a human-readable error string if something is wrong, or null if the record passes all checks. This pattern makes it easy to collect specific failure reasons per row rather than crashing on the first bad value.

  • Finally, add the processRows function and a temporary console.log call to verify everything works:
// Process all rows with validation
function processRows(rows: ExcelRow[]): ValidationResult {
  const valid: Employee[] = [];
  const invalid: ValidationResult["invalid"] = [];

  rows.forEach((row, index) => {
    const mapped = mapToSchema(row);
    const error = validateEmployee(mapped);
    if (error) {
      invalid.push({ row: index + 2, reason: error });
    } else {
      valid.push(mapped);
    }
  });

  return { valid, invalid };
}

// Temporary: log mapped rows for verification
const rows = parseExcel("./data/employees.xlsx");
console.log(rows.map(mapToSchema));

What does this code do?

  • processRows loops through every Excel row, maps it to the database schema, then validates it.
  • index + 2 gives the actual Excel row number (row 1 is the header, data starts at row 2).
  • The temporary console.log at the bottom lets you see the mapped output right now. You will replace this with the Supabase insert logic in the next step.
  • Save src/import-single.ts.
  • Run the script to verify the transformation by running:
npx tsx ./src/import-single.ts

You should see an array of 7 objects printed to the terminal, each with lowercase keys (name, email, department, start_date, salary) instead of the original Excel headers.

Seeing an empty array or an error?

  • Make sure data/employees.xlsx exists. If it does not, re-run npx tsx ./src/generate-sample.ts to recreate it.
  • Check that your file path uses ./data/employees.xlsx (relative to the project root, not the src/ folder).
  • If you see a module resolution error, confirm your package.json includes "type": "module".

Help me debug my import-single.ts script

✔️ Awesome, I've got everything!

Great. Double check you have saved src/import-single.ts and confirmed the mapped output in the terminal.

ⓧ I'd like to double check the full code

Here is the complete src/import-single.ts file at this point:

import { readFileSync } from "fs";
import * as XLSX from "xlsx";
import { createClient } from "@supabase/supabase-js";

// Types matching Excel column headers
interface ExcelRow {
  Name: string;
  Email: string;
  Department: string;
  "Start Date": string;
  Salary: number;
}

// Types matching Supabase table schema
interface Employee {
  name: string;
  email: string;
  department: string;
  start_date: string;
  salary: number;
}

interface ValidationResult {
  valid: Employee[];
  invalid: { row: number; reason: string }[];
}

// Parse Excel file into typed rows
function parseExcel(filePath: string): ExcelRow[] {
  const fileBuffer = readFileSync(filePath);
  const workbook = XLSX.read(fileBuffer);
  const sheetName = workbook.SheetNames[0];
  const worksheet = workbook.Sheets[sheetName];
  return XLSX.utils.sheet_to_json<ExcelRow>(worksheet);
}

// Map Excel columns to Supabase schema
function mapToSchema(row: ExcelRow): Employee {
  return {
    name: (row.Name ?? "").trim(),
    email: (row.Email ?? "").trim(),
    department: (row.Department ?? "").trim(),
    start_date: row["Start Date"] ?? "",
    salary: Number(row.Salary) || 0,
  };
}

// Validate a single employee record
function validateEmployee(employee: Employee): string | null {
  if (!employee.name) return "Name is required";
  if (!employee.email || !employee.email.includes("@")) return "Valid email is required";
  if (!employee.department) return "Department is required";
  if (isNaN(Date.parse(employee.start_date))) return "Invalid start date";
  if (employee.salary <= 0) return "Salary must be positive";
  return null;
}

// Process all rows with validation
function processRows(rows: ExcelRow[]): ValidationResult {
  const valid: Employee[] = [];
  const invalid: ValidationResult["invalid"] = [];

  rows.forEach((row, index) => {
    const mapped = mapToSchema(row);
    const error = validateEmployee(mapped);
    if (error) {
      invalid.push({ row: index + 2, reason: error });
    } else {
      valid.push(mapped);
    }
  });

  return { valid, invalid };
}

// Temporary: log mapped rows for verification
const rows = parseExcel("./data/employees.xlsx");
console.log(rows.map(mapToSchema));

Your parsing and mapping layer is working. You can take messy spreadsheet data and produce clean, validated objects. Next up, you will connect to Supabase and batch-insert the valid rows into your database.

Validate and Insert into Supabase

Your import script can now parse an Excel file and validate every row against your database schema. The two invalid rows (missing email, empty name) are caught cleanly.

In this step, you will connect your script to your live Supabase database and send the validated rows across. You will also add npm scripts so the entire pipeline runs with a single command.

In this step, get ready to:
  • Remove temporary verification code and add a batch insert function.
  • Write the main function that orchestrates parsing, validation, and insertion.
  • Add npm scripts and run the full import to see data appear in Supabase.
Remove the temporary verification code

The temporary console.log at the bottom of import-single.ts was useful for confirming your schema mapping works. Now that you have verified the output, you will replace it with real database logic.

  • In src/import-single.ts, scroll to the very bottom of the file.
  • Find these lines:
// Temporary: log mapped rows for verification
const rows = parseExcel("./data/employees.xlsx");
console.log(rows.map(mapToSchema));
  • Delete these three lines entirely.
Connect to Supabase and batch-insert

Production data imports send rows to the database in batches rather than all at once. Batching prevents API timeouts when files grow to thousands of rows.

  • In src/import-single.ts, add the following insertBatch function below the processRows function:
// Insert into Supabase in batches
async function insertBatch(
  supabase: ReturnType<typeof createClient>,
  employees: Employee[],
  batchSize: number = 500
): Promise<void> {
  for (let i = 0; i < employees.length; i += batchSize) {
    const batch = employees.slice(i, i + batchSize);
    const { error } = await supabase.from("employees").insert(batch);
    if (error) {
      throw new Error(`Batch insert failed at row ${i + 1}: ${error.message}`);
    }
    console.log(`  Inserted batch ${Math.floor(i / batchSize) + 1} (${batch.length} rows)`);
  }
}

What does this code do?

  • The function accepts a Supabase client instance, an array of validated employees, and an optional batch size (defaulting to 500 rows per batch).
  • It slices the array into chunks and inserts each chunk with supabase.from("employees").insert(batch).
  • If any batch fails, it throws an error with the row number so you know exactly where the problem is.

Now add the main function that ties everything together. This function reads your environment variables, creates the Supabase client, and orchestrates the full pipeline.

  • Add the following code directly below the insertBatch function:
// Main
async function main() {
  const supabaseUrl = process.env.SUPABASE_URL;
  const supabaseKey = process.env.SUPABASE_PUBLISHABLE_KEY;

  if (!supabaseUrl || !supabaseKey) {
    console.error("Missing SUPABASE_URL or SUPABASE_PUBLISHABLE_KEY in .env file");
    process.exit(1);
  }

  const supabase = createClient(supabaseUrl, supabaseKey);

  console.log("Parsing Excel file...");
  const rows = parseExcel("./data/employees.xlsx");
  console.log(`Found ${rows.length} rows in spreadsheet`);

  console.log("Validating and mapping data...");
  const { valid, invalid } = processRows(rows);

  if (invalid.length > 0) {
    console.log(`\nSkipped ${invalid.length} invalid row(s):`);
    invalid.forEach(({ row, reason }) => {
      console.log(`  Row ${row}: ${reason}`);
    });
  }

  if (valid.length > 0) {
    console.log(`\nInserting ${valid.length} valid row(s) into Supabase...`);
    await insertBatch(supabase, valid);
    console.log("\nImport complete!");
  } else {
    console.log("\nNo valid rows to insert.");
  }
}

main();

What does this code do?

  • It reads SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY from the environment and exits early with a clear message if either is missing.
  • It calls parseExcel to read the spreadsheet, then processRows to split valid and invalid rows.
  • Invalid rows are logged with their row number and reason, so you can fix the source data.
  • Valid rows are passed to insertBatch for insertion into the employees table.
  • Save src/import-single.ts.

Seeing a red underline on createClient?

Make sure the import at the top of your file still reads import { createClient } from "@supabase/supabase-js". If you accidentally deleted it when removing the temp code, add it back.

Help me debug this

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before continuing.

ⓧ I'd like to double check the full code

import { readFileSync } from "fs";
import * as XLSX from "xlsx";
import { createClient } from "@supabase/supabase-js";

// Types matching Excel column headers
interface ExcelRow {
  Name: string;
  Email: string;
  Department: string;
  "Start Date": string;
  Salary: number;
}

// Types matching Supabase table schema
interface Employee {
  name: string;
  email: string;
  department: string;
  start_date: string;
  salary: number;
}

interface ValidationResult {
  valid: Employee[];
  invalid: { row: number; reason: string }[];
}

// Parse Excel file into typed rows
function parseExcel(filePath: string): ExcelRow[] {
  const fileBuffer = readFileSync(filePath);
  const workbook = XLSX.read(fileBuffer);
  const sheetName = workbook.SheetNames[0];
  const worksheet = workbook.Sheets[sheetName];
  return XLSX.utils.sheet_to_json<ExcelRow>(worksheet);
}

// Map Excel columns to Supabase schema
function mapToSchema(row: ExcelRow): Employee {
  return {
    name: (row.Name ?? "").trim(),
    email: (row.Email ?? "").trim(),
    department: (row.Department ?? "").trim(),
    start_date: row["Start Date"] ?? "",
    salary: Number(row.Salary) || 0,
  };
}

// Validate a single employee record
function validateEmployee(employee: Employee): string | null {
  if (!employee.name) return "Name is required";
  if (!employee.email || !employee.email.includes("@")) return "Valid email is required";
  if (!employee.department) return "Department is required";
  if (isNaN(Date.parse(employee.start_date))) return "Invalid start date";
  if (employee.salary <= 0) return "Salary must be positive";
  return null;
}

// Process all rows with validation
function processRows(rows: ExcelRow[]): ValidationResult {
  const valid: Employee[] = [];
  const invalid: ValidationResult["invalid"] = [];

  rows.forEach((row, index) => {
    const mapped = mapToSchema(row);
    const error = validateEmployee(mapped);
    if (error) {
      invalid.push({ row: index + 2, reason: error });
    } else {
      valid.push(mapped);
    }
  });

  return { valid, invalid };
}

// Insert into Supabase in batches
async function insertBatch(
  supabase: ReturnType<typeof createClient>,
  employees: Employee[],
  batchSize: number = 500
): Promise<void> {
  for (let i = 0; i < employees.length; i += batchSize) {
    const batch = employees.slice(i, i + batchSize);
    const { error } = await supabase.from("employees").insert(batch);
    if (error) {
      throw new Error(`Batch insert failed at row ${i + 1}: ${error.message}`);
    }
    console.log(`  Inserted batch ${Math.floor(i / batchSize) + 1} (${batch.length} rows)`);
  }
}

// Main
async function main() {
  const supabaseUrl = process.env.SUPABASE_URL;
  const supabaseKey = process.env.SUPABASE_PUBLISHABLE_KEY;

  if (!supabaseUrl || !supabaseKey) {
    console.error("Missing SUPABASE_URL or SUPABASE_PUBLISHABLE_KEY in .env file");
    process.exit(1);
  }

  const supabase = createClient(supabaseUrl, supabaseKey);

  console.log("Parsing Excel file...");
  const rows = parseExcel("./data/employees.xlsx");
  console.log(`Found ${rows.length} rows in spreadsheet`);

  console.log("Validating and mapping data...");
  const { valid, invalid } = processRows(rows);

  if (invalid.length > 0) {
    console.log(`\nSkipped ${invalid.length} invalid row(s):`);
    invalid.forEach(({ row, reason }) => {
      console.log(`  Row ${row}: ${reason}`);
    });
  }

  if (valid.length > 0) {
    console.log(`\nInserting ${valid.length} valid row(s) into Supabase...`);
    await insertBatch(supabase, valid);
    console.log("\nImport complete!");
  } else {
    console.log("\nNo valid rows to insert.");
  }
}

main();
Run the complete import

Instead of typing the full tsx command each time, you will add npm scripts that anyone on your team can run.

  • Open package.json in VS Code.
  • Find the empty scripts object:
"scripts": {},
  • Replace it with these two scripts:
"scripts": {
  "generate": "tsx ./src/generate-sample.ts",
  "import": "tsx --env-file=.env ./src/import-single.ts"
},

What do these scripts do?

  • The generate script runs your sample data generator to create test Excel files.
  • The import script uses the --env-file=.env flag to load your Supabase credentials into process.env before executing the import. This eliminates the need for a separate dotenv package.
  • Save package.json.
  • Run the import by executing this command in your terminal:
npm run import

You should see output similar to this in your terminal:

Parsing Excel file...
Found 7 rows in spreadsheet
Validating and mapping data...

Skipped 2 invalid row(s):
  Row 7: Valid email is required
  Row 8: Name is required

Inserting 5 valid row(s) into Supabase...
  Inserted batch 1 (5 rows)

Import complete!

What should I see?

The script reports 7 total rows, skips 2 with clear reasons (Frank Lee has no email, the last row has no name), and inserts the remaining 5 into your Supabase table.

  • Switch back to the Supabase dashboard in your browser.
  • Click Table Editor in the left sidebar.
  • Select the employees table.

You should see 5 rows populated with data for Alice Johnson, Bob Smith, Carol Williams, David Brown, and Eva Martinez.

Seeing 'Missing SUPABASE_URL or SUPABASE_PUBLISHABLE_KEY'?

Open your .env file and confirm both values are filled in (not the placeholder text). Copy your project URL and publishable key from the Supabase dashboard under Settings, then API.

Help me troubleshoot my .env configuration

Seeing 'Batch insert failed' with a permission error?

This means Row Level Security is blocking the insert. Return to the SQL Editor in your Supabase dashboard and run:

ALTER TABLE employees DISABLE ROW LEVEL SECURITY;

Then run npm run import again.

Help me fix my RLS error

Secret mission

Process Multiple Excel Files with a Summary Report

In the real world, data doesn't arrive in a single tidy spreadsheet. Extend your importer to scan an entire directory of Excel files, process each one independently, and print a formatted summary table showing per-file import stats.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses Supabase's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Supabase employees table with sample data (free tier).
  • Local excel-to-supabase project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment further.

  • Your Supabase employees table stays available for queries and future imports. The free tier has no time limit.
  • Your local excel-to-supabase folder stays on your Desktop ready to run.
  • Node.js remains installed on your machine for other projects.

Pause - I'll come back to this later

Free up resources while keeping your files intact. Supabase free projects pause automatically after 1 week of inactivity but can be resumed.

  • No action needed for Supabase. Your project will auto-pause after a week of inactivity and can be unpaused from the dashboard.
  • Your local files stay on your Desktop. You can pick up right where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Drop the Supabase table by opening the SQL Editor in your Supabase Dashboard and running this query:
DROP TABLE employees;
  • Delete the local project folder by running this command in your terminal:
cd ~/Desktop
rm -rf excel-to-supabase

On Windows, use this instead:

cd ~\Desktop
rmdir /s /q excel-to-supabase

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade data import pipeline that reads Excel spreadsheets, validates every row, and loads clean data into Supabase.

You've learned how to:

  • Parsed .xlsx files using SheetJS and converted raw spreadsheet data into typed TypeScript objects.
  • Built a schema-mapping layer with TypeScript interfaces that transforms messy Excel columns into database-ready rows, plus a validation layer that catches bad data before it reaches your database.
  • Batch-inserted clean employee data into Supabase using the official JS client, with clear error reporting for skipped rows.
  • Secret Mission: Extended the importer to scan a directory of Excel files and produce a consolidated summary report with per-file stats.

Ready to quiz yourself?