Skip to content

Import CSV into Supabase with React and Next.js

Upload a CSV, map columns, correct invalid values, and save contacts to Supabase with authenticated writes and Row Level Security.

View as Markdown

From CSV upload to Supabase records

This guide covers the React-to-Supabase delivery path: receive prepared CSV rows and write them using your existing Supabase setup. For a complete customer-facing upload screen with ownership policies and duplicate choices, use the customer upload guide below. For Lovable-specific setup, use the dedicated Lovable integration.

A CSV parser produces arrays. A database import also needs column mapping, data review, authorization, and retry behavior. In this tutorial CSVJetty handles the upload and review; a Next.js API route validates the records again and saves them under the signed-in Supabase user.

The example stores one contact per source row per import. Repeating a delivery updates that same record. Uploading the file again creates a new import and can create another contact with the same email; business-level deduplication is a separate rule.

1. Configure the importer and authentication

This guide uses Next.js Pages Router and an existing Supabase Auth login in the same application. Sign in through that application before opening the import page. Reuse your existing Supabase browser client if you have one; the client below uses the default persisted browser session. The importer key configures CSVJetty; it does not authenticate users to your database.

Create an importer in the CSVJetty dashboard, choose callback delivery, enable Require valid rows, and copy its key. Replace YOUR_IMPORTER_KEY in the page below. The columns prop supplies name, email, and company for this session.

bash
npm install csvjetty @supabase/supabase-js
dotenv
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
js
// lib/import-supabase.js
import { createClient } from "@supabase/supabase-js";

export const importSupabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
);
CSVJetty dashboard field configuration with labels, names, types, required values and examples
Dashboard configuration example. The screenshot uses a different schema from the contact examples below; configure the fields specified in each guide.

2. Create the table and ownership policies

Run this SQL once in a development Supabase project. The API uses the caller’s access token and a publishable key, so RLS applies to every write. No service-role key is needed.

sql
create table public.imported_contacts (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references auth.users(id) on delete cascade,
  import_id text not null,
  source_row integer not null check (source_row > 0),
  name text not null check (length(name) between 1 and 200),
  email text not null check (length(email) between 3 and 254),
  company text not null default '' check (length(company) <= 200),
  unique (owner_id, import_id, source_row)
);
alter table public.imported_contacts enable row level security;
revoke all on public.imported_contacts from anon, authenticated;
grant select, insert, update on public.imported_contacts to authenticated;

create policy "Read own imported contacts"
on public.imported_contacts for select to authenticated
using ((select auth.uid()) = owner_id);
create policy "Insert own imported contacts"
on public.imported_contacts for insert to authenticated
with check ((select auth.uid()) = owner_id);
create policy "Update own imported contacts"
on public.imported_contacts for update to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);

3. Validate records on the server

Save this helper. It accepts at most 1,000 rows per request, trims fields, normalizes email casing, and preserves source row numbers. The email check is a basic format check, not proof that an address exists.

js
// lib/validate-contact-import.js
export function validateContactImport(body) {
  if (!body || typeof body !== "object" || Array.isArray(body)) {
    throw new Error("Expected an import object.");
  }
  const { importId, rows, rowNumbers } = body;
  if (typeof importId !== "string" || !importId.length || importId.length > 200 ||
      !Array.isArray(rows) || rows.length < 1 || rows.length > 1000 ||
      !Array.isArray(rowNumbers) || rows.length !== rowNumbers.length ||
      rowNumbers.some(n => !Number.isSafeInteger(n) || n < 1) ||
      new Set(rowNumbers).size !== rowNumbers.length) {
    throw new Error("Invalid import ID, batch size, or source row numbers.");
  }
  const accepted = [];
  const errors = [];
  rows.forEach((row, index) => {
    const name = typeof row?.name === "string" ? row.name.trim() : "";
    const email = typeof row?.email === "string" ? row.email.trim().toLowerCase() : "";
    const company = row?.company == null ? "" :
      typeof row.company === "string" ? row.company.trim() : null;
    if (!name || name.length > 200 || email.length > 254 ||
        !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ||
        company === null || company.length > 200) {
      errors.push({ row: rowNumbers[index], message: "Enter a name, valid email, and company up to 200 characters." });
    } else {
      accepted.push({ name, email, company, sourceRow: rowNumbers[index] });
    }
  });
  return { importId, accepted, errors };
}

4. Save accepted rows through an authenticated endpoint

The owner ID comes from the verified access token. The server deliberately ignores any owner ID supplied in uploaded data. Invalid records return row errors; a database failure fails the request so the user can retry.

js
// pages/api/contacts/import.js
import { createClient } from "@supabase/supabase-js";
import { validateContactImport } from "../../../lib/validate-contact-import";

export const config = { api: { bodyParser: { sizeLimit: "2mb" } } };

export default async function handler(req, res) {
  res.setHeader("Cache-Control", "no-store");
  if (req.method !== "POST") {
    res.setHeader("Allow", "POST");
    return res.status(405).json({ error: "Use POST." });
  }
  const token = /^Bearer (\S+)$/.exec(req.headers.authorization || "")?.[1];
  if (!token) return res.status(401).json({ error: "Sign in first." });
  try {
    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL,
      process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
      {
        global: { headers: { Authorization: `Bearer ${token}` } },
        auth: { persistSession: false, autoRefreshToken: false },
      },
    );
    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
    if (authError || !user) return res.status(401).json({ error: "Sign in again." });
    let batch;
    try { batch = validateContactImport(req.body); }
    catch (error) { return res.status(400).json({ error: error.message }); }
    if (batch.accepted.length) {
      const { error } = await supabase.from("imported_contacts").upsert(
        batch.accepted.map(({ sourceRow, ...contact }) => ({
          ...contact, owner_id: user.id,
          import_id: batch.importId, source_row: sourceRow,
        })),
        { onConflict: "owner_id,import_id,source_row" },
      );
      if (error) throw error;
    }
    return res.status(200).json({ errors: batch.errors });
  } catch {
    return res.status(500).json({ error: "Could not save contacts. Retry the import." });
  }
}

5. Add the import page

jsx
// pages/import-contacts.jsx
import { useState } from "react";
import CSVJetty from "csvjetty";
import { importSupabase } from "../lib/import-supabase";

const columns = [
  { name: "name", label: "Full Name", type: "text", required: true },
  { name: "email", label: "Email", type: "email", required: true },
  { name: "company", label: "Company", type: "text" },
];

export default function ImportContacts() {
  const [status, setStatus] = useState("");
  return (
    <main>
      <h1>Import contacts</h1>
      <CSVJetty
        importerKey="YOUR_IMPORTER_KEY"
        columns={columns}
        onData={async ({ rows, rowNumbers, importId }) => {
          const { data: { session } } = await importSupabase.auth.getSession();
          if (!session) throw new Error("Sign in before importing contacts.");
          const response = await fetch("/api/contacts/import", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${session.access_token}`,
            },
            body: JSON.stringify({ rows, rowNumbers, importId }),
          });
          const result = await response.json();
          if (!response.ok) throw new Error(result.error || "Could not save contacts.");
          return result;
        }}
        onComplete={() => setStatus("Import finished. Your accepted contacts were saved.")}
      >
        Import contacts
      </CSVJetty>
      <p role="status">{status}</p>
    </main>
  );
}

Test the complete import

Download the contacts sample →

Start your app, sign in, and visit /import-contacts. Enable CSVJetty Development Mode while testing; it still delivers data, so use a test database. Upload the sample, check mappings, and finish. Confirm the records exist in your database. Repeat the same request after a simulated lost response to check retry behavior. A new file upload has a new import ID.

Source row 1 means the first data row after the header. Return row errors using rowNumbers, rather than array positions. Browser validation improves review; server validation and database constraints still decide what is saved.

Understand callback results →

Handle business duplicates →

Dashboard preview complete with 24 records ready and no records sent to the application
Dashboard preview only: records are prepared but none were delivered. Test the embedded component separately to verify persistence.

Resources

Supabase: verifying a user →

Supabase: Row Level Security →

Supabase: upsert →

Choose business-level duplicate handling

The API example above uses import ID plus source row for delivery idempotency. That does not merge a second upload by email. For a contact list that should keep one normalized email per account, use a unique owner/email key and choose explicitly whether to skip or update an existing contact.

Complete owner/email duplicate policy and saved-row counts →

Lovable-specific Supabase setup →

Dragging a CSV file into the CSVJetty upload area
Upload a CSV with headers. The demo image contains older CSV/TSV wording; the current documented implementation supports CSV only.
An invalid email being edited in a CSVJetty review table
Correct invalid values in the review table. The demo permits invalid rows; enable Require valid rows for the examples in this guide.

Build your importer

Configure the fields your app expects, then connect CSVJetty to your application.

Create an importer →