# Handle duplicate contacts and CSV import retries

Distinguish duplicate contacts from repeated deliveries, enforce tenant-scoped unique keys, and return useful row errors from a Next.js import endpoint.

Canonical HTML: https://www.csvjetty.com/guides/csv-import-duplicates

## Two different kinds of duplicate

A contact can appear twice in one file, reappear in another upload, or be delivered again because the first response was lost. Those cases need different keys. This guide uses owner ID plus normalized email for contact identity, and owner ID plus import ID plus source row for delivery identity.

The working example rejects existing emails and keeps the first accepted contact. Repeating a successful source row is a no-op. Rejected rows do not receive success receipts, so users can correct them and retry. A correction to an already saved row does not update that contact.

| Situation | Example behavior |
| --- | --- |
| Same email twice in a file | First accepted row wins; later row gets an error. |
| Same email in a new upload | Existing contact stays unchanged; new row gets an error. |
| Same successful source row delivered again | No extra write and no duplicate error. |
| Rejected source row corrected and retried | Validate and attempt the new value. |

## 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,
);
```

## 2. Add business identity and delivery identity

```bash
npm install pg
```

```dotenv
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASE
```

Run the SQL on a development PostgreSQL database using a backend-only role. The Supabase access token authenticates the caller; the server connects to PostgreSQL and scopes every statement to that verified owner ID. These tables are not intended for direct browser access.

```sql
create table csvjetty_contacts (
  id bigint generated always as identity primary key,
  owner_id uuid not null,
  email varchar(254) not null,
  name varchar(200) not null,
  company varchar(200) not null,
  unique (owner_id, email)
);
create table csvjetty_contact_import_rows (
  owner_id uuid not null,
  import_id text not null,
  source_row integer not null,
  contact_id bigint not null references csvjetty_contacts(id),
  primary key (owner_id, import_id, source_row)
);
-- Backend-only tables: grant access only to your application's server DB role.
```

```js
// lib/import-pg.js
import pg from "pg";
const globalForPg = globalThis;
export const importPool = globalForPg.importPool || new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  connectionTimeoutMillis: 3000,
  statement_timeout: 5000,
});
if (process.env.NODE_ENV !== "production") globalForPg.importPool = importPool;
```

## 3. Verify identity and normalize records

Lowercasing and trimming email is an application policy here. Do not remove dots or plus suffixes: doing so can merge distinct addresses. For products or inventory, use a tenant-scoped external ID or SKU instead of email.

```js
// lib/require-import-user.js
import { createClient } from "@supabase/supabase-js";

export async function requireImportUser(req) {
  const match = /^Bearer (\S+)$/.exec(req.headers.authorization || "");
  if (!match) return null;
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
  const { data: { user }, error } = await supabase.auth.getUser(match[1]);
  return error ? null : user;
}
```

```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 contacts and success receipts together

The endpoint performs contact and receipt writes inside one transaction. A unique database constraint is the final authority for duplicate emails. The owner-scoped transaction lock keeps concurrent imports from racing over successful source-row receipts. This bounded, sequential implementation favors clarity; large imports need measured batch optimization and suitable request timeouts.

```js
// pages/api/contacts/import.js
import { importPool } from "../../../lib/import-pg";
import { requireImportUser } from "../../../lib/require-import-user";
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." });
  }
  let db;
  try {
    const user = await requireImportUser(req);
    if (!user) return res.status(401).json({ error: "Sign in first." });
    let batch;
    try { batch = validateContactImport(req.body); }
    catch (error) { return res.status(400).json({ error: error.message }); }
    const errors = [...batch.errors];
    db = await importPool.connect();
    await db.query("BEGIN");
    // Serialize this example's imports for the same owner. Released at commit.
    await db.query("select pg_advisory_xact_lock(hashtextextended($1,0))", [user.id]);
    for (const contact of batch.accepted) {
      const prior = await db.query(
        `select contact_id from csvjetty_contact_import_rows
         where owner_id=$1 and import_id=$2 and source_row=$3`,
        [user.id, batch.importId, contact.sourceRow],
      );
      if (prior.rowCount) continue;
      const inserted = await db.query(
        `insert into csvjetty_contacts (owner_id,email,name,company)
         values ($1,$2,$3,$4) on conflict (owner_id,email) do nothing
         returning id`,
        [user.id, contact.email, contact.name, contact.company],
      );
      if (!inserted.rowCount) {
        errors.push({ row: contact.sourceRow, message: "A contact with this email already exists." });
        continue;
      }
      await db.query(
        `insert into csvjetty_contact_import_rows
         (owner_id,import_id,source_row,contact_id) values ($1,$2,$3,$4)`,
        [user.id, batch.importId, contact.sourceRow, inserted.rows[0].id],
      );
    }
    await db.query("COMMIT");
    return res.status(200).json({ errors });
  } catch {
    if (db) await db.query("ROLLBACK").catch(() => {});
    return res.status(500).json({ error: "Could not save contacts. Retry the import." });
  } finally { db?.release(); }
}
```

## 5. Connect the importer

Use this page with a callback-mode importer. The server returns errors using original source row numbers, so CSVJetty can associate duplicate messages with the correct records.

```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>
  );
}
```

## 6. Test collisions and lost responses

[Download the duplicate-email sample](https://www.csvjetty.com/samples/duplicate-contacts.csv)

Start with empty tutorial tables. Import the sample: Ada’s first row and Grace’s row should save; Ada’s second email should be rejected even though its casing differs. Correct the rejected email to ada.secondary@example.com and retry; it should now save. Successful rows should not be inserted again.

Upload the original sample as a new import. All original emails should now be rejected as existing contacts. Then simulate a lost response by returning 500 immediately after COMMIT once in a test environment. Retry the same import: successful source rows should be recognized from their receipts. Restore the normal response afterward.

![Review table filtered to rows with errors, including an editable invalid email](https://www.csvjetty.com/5_DemoUserDataHealing.png)

Correct invalid values in the review table. This demo permits invalid rows to continue; enable blocking or filtering if your application requires it.

## When to skip or update instead

Skipping duplicates is appropriate when existing records should win. Upserting is appropriate when the incoming file is authoritative for a defined set of fields. Choose the policy before accepting the upload; do not silently replace customer data.

For update mode, use an atomic ON CONFLICT (owner_id,email) DO UPDATE statement and write the delivery receipt in the same transaction. Decide how blank cells behave, which fields the importer may change, and how two uploads updating the same contact should be ordered. Keep delivery receipts even when business records already exist.

[Build the simpler Supabase integration](https://www.csvjetty.com/guides/import-csv-supabase)

[Review callback error handling](https://www.csvjetty.com/docs/callbacks)
