# Build a product catalog CSV importer with SKU validation

Import product catalogs from CSV with SKU mapping, exact price-to-cents conversion, whole-number inventory, owner-scoped writes and duplicate protection.

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

## Define the product import contract

A catalog import must preserve product identity and represent prices consistently. This example inserts new products into an existing Supabase-backed React application. It uses owner plus SKU as the unique key, a single application-defined currency, integer cents and whole-number stock. Existing SKUs are kept. It does not update inventory, manage variants or synchronize a storefront.

CSVJetty supports React 18/19 and CSV files up to 20 MB. The free plan permits 10,000 rows per upload; paid permits 20,000. Configure at most 30 destination fields. Callback mode keeps row data out of CSVJetty servers while configuration and import metadata still use the hosted service. It is not a fully offline or self-hosted importer.

| Field | Import representation | Database representation |
| --- | --- | --- |
| sku | Text, trimmed and uppercased | Unique per owner; leading zeroes preserved |
| name | Required text | Trimmed text up to 200 characters |
| price | Decimal text, such as 12.50 | Integer cents; no floating-point multiplication of a parsed decimal |
| stock | Nonnegative whole number | Integer quantity up to 1000000000 |

![CSVJetty column definition screen with names, types and examples](https://www.csvjetty.com/1_DemoAdminColumnDefinition.png)

Configure destination fields and their types. This recording frame uses the demo schema; use the schema specified in this guide.

## Configure the importer and download a template

Create an importer in the CSVJetty dashboard, choose OnData Callback, and copy its public importer key. Enable Require valid rows so customers must fix browser validation errors before delivery. Development Mode still calls your save handler; use test data and a test database. Dashboard preview does not deliver rows.

Keep SKU as text. A SKU such as 00123 must not become the number 123. Price is text so the example can reject currency symbols, exponent notation and more than two decimal places before conversion. The sample assumes a dot decimal separator and no thousands separator.

[Clean product sample](https://www.csvjetty.com/samples/products-clean.csv)

[Product sample with duplicates and invalid values](https://www.csvjetty.com/samples/products-invalid.csv)

![CSVJetty prepare-file screen listing required fields and examples](https://www.csvjetty.com/2_DemoUserPrep.png)

The preparation step explains the expected fields before upload. The fields shown belong to the demo importer.

![Dragging a CSV file into the CSVJetty upload area](https://www.csvjetty.com/3_DemoUserUploadStart.png)

Upload a CSV with headers. The demo image contains older CSV/TSV wording; the current documented implementation supports CSV only.

## Create the owner-scoped product table

Run this migration in a development project. RLS isolates each user’s catalog. Database constraints enforce SKU format, price bounds and whole-number quantities. There is no UPDATE grant because this example preserves existing products.

File: sql/products.sql

```sql
create table public.csvjetty_products (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references auth.users(id) on delete cascade,
  sku text not null check (sku ~ '^[A-Z0-9][A-Z0-9_-]{0,63}$'),
  name text not null check (length(name) between 1 and 200 and name = btrim(name)),
  price_cents integer not null check (price_cents between 0 and 1000000000),
  stock integer not null check (stock between 0 and 1000000000),
  unique (owner_id, sku)
);
alter table public.csvjetty_products enable row level security;
revoke all on public.csvjetty_products from anon, authenticated;
grant select, insert on public.csvjetty_products to authenticated;
create policy "Read own catalog" on public.csvjetty_products
for select to authenticated using ((select auth.uid()) = owner_id);
create policy "Insert own products" on public.csvjetty_products
for insert to authenticated with check ((select auth.uid()) = owner_id);
```

## Add the validators and complete component

```bash
npm install csvjetty @supabase/supabase-js
```

[Download all example files (ZIP)](https://www.csvjetty.com/samples/import-product-catalog-csv-example.zip)

File: components/csvjetty-example/ProductCatalogImport.jsx

```jsx
"use client";
import { useState } from "react";
import CSVJetty from "csvjetty";
import { parseProduct } from "./product-validation";

export default function ProductCatalogImport({ supabase, importerKey = "YOUR_IMPORTER_KEY" }) {
  const [status, setStatus] = useState("");
  return (
    <section>
      <h2>Add products to your catalog</h2>
      <CSVJetty
        importerKey={importerKey}
        accentColor="#2563eb"
        columns={[
          { name: "sku", label: "SKU", type: "text", required: true,
            validate: value => /^[A-Z0-9][A-Z0-9_-]{0,63}$/i.test(String(value).trim()) || "Enter a valid SKU." },
          { name: "name", label: "Product name", type: "text", required: true },
          { name: "price", label: "Price", type: "text", required: true,
            validate: value => /^\d{1,8}(\.\d{1,2})?$/.test(String(value).trim()) || "Use a price such as 12.50." },
          { name: "stock", label: "Stock", type: "number", required: true },
        ]}
        onData={async ({ rows, rowNumbers }) => {
          const { data: { user }, error: authError } = await supabase.auth.getUser();
          if (authError || !user) throw new Error("Sign in before importing.");
          const errors = [];
          let inserted = 0;
          for (let offset = 0; offset < rows.length; offset += 250) {
            const unique = new Map();
            rows.slice(offset, offset + 250).forEach((row, index) => {
              try {
                const product = parseProduct(row);
                if (!unique.has(product.sku)) unique.set(product.sku, { ...product, owner_id: user.id });
              } catch (issue) {
                errors.push({ row: rowNumbers[offset + index], message: issue.message });
              }
            });
            if (!unique.size) continue;
            const { data, error } = await supabase.from("csvjetty_products")
              .upsert([...unique.values()], { onConflict: "owner_id,sku", ignoreDuplicates: true })
              .select("id");
            if (error) throw new Error("Could not save this batch. Earlier batches may already be saved; retry to insert missing SKUs.");
            inserted += data.length;
          }
          setStatus(`Last acknowledged callback: ${inserted} new products, ${errors.length} rejected rows. Existing SKUs were kept.`);
          return { errors };
        }}
      >Import product CSV</CSVJetty>
      <p role="status">{status}</p>
    </section>
  );
}
```

File: components/csvjetty-example/product-validation.js

```js
export function parseProduct(row) {
  const sku = typeof row?.sku === "string" ? row.sku.trim().toUpperCase() : "";
  const name = typeof row?.name === "string" ? row.name.trim() : "";
  const price = String(row?.price ?? "").trim();
  const quantity = String(row?.stock ?? "").trim();
  if (!/^[A-Z0-9][A-Z0-9_-]{0,63}$/.test(sku)) throw new Error("Use a SKU of 1–64 letters, digits, underscores or hyphens.");
  if (!name || name.length > 200) throw new Error("Enter a product name up to 200 characters.");
  if (!/^\d{1,8}(\.\d{1,2})?$/.test(price)) throw new Error("Use a nonnegative decimal price, for example 12.50, without currency symbols.");
  const [whole, decimal = ""] = price.split(".");
  const priceCents = Number(whole) * 100 + Number(decimal.padEnd(2, "0"));
  if (priceCents > 1000000000) throw new Error("Price must be at most 10000000.00.");
  if (!/^\d{1,10}$/.test(quantity) || Number(quantity) > 1000000000) throw new Error("Stock must be a whole number from 0 to 1000000000.");
  return { sku, name, price_cents: priceCents, stock: Number(quantity) };
}
```

Render ProductCatalogImport inside your existing browser screen and pass the signed-in Supabase client plus the public CSVJetty importer key. Reuse the existing authentication setup. Custom validators are synchronous; any database lookup belongs in delivery logic. The database remains authoritative if a caller bypasses the UI.

## Map exported headings to product fields

![Destination column selector above the CSV data preview](https://www.csvjetty.com/4_DemoColumnMapping.png)

Review header matches and assign destination fields. The demo schema differs from this guide’s example.

For the provided CSV, map SKU to sku, Product name to name, Price to price and Stock to stock. The screenshot shows the contact demo’s headings, not a separate product screenshot. For real customer exports, configure aliases for known headings such as Item Code or Unit Price, and require users to confirm ambiguous matches.

[Header aliases and dynamic schemas](https://www.csvjetty.com/guides/csv-column-mapping)

## Correct values before writing

![An invalid email being edited in a CSVJetty review table](https://www.csvjetty.com/5_DemoUserDataHealing.png)

Correct invalid values in the review table. The demo permits invalid rows; enable Require valid rows for the examples in this guide.

In the invalid sample, remove the dollar symbol from $8.99 and change the fractional stock value 2.5 to the intended whole quantity. Resolve these source-data decisions before importing. The parser converts a valid price by separating dollars and fractional digits, so 12.50 becomes exactly 1250 cents.

The helper deduplicates within each 250-row chunk and the database unique key handles conflicts across chunks and files. If a later chunk fails, earlier chunks remain committed. Retrying inserts missing SKUs without replacing products already stored. A future stock-adjustment workflow needs an idempotency ledger; incrementing stock on every retry would be unsafe.

## Verify SKU identity and stored prices

Start with an empty test account. The clean file should insert three products. Verify that SKU 00123 still has its leading zeroes and price_cents is 1250. Repeat the file and expect zero additional products. With the invalid sample corrected, existing SKUs should remain unchanged. Test another user’s account to verify ownership isolation.

```sql
select sku, name, price_cents, stock
from public.csvjetty_products
where owner_id = auth.uid()
order by sku;
```

Run that query in an authenticated database context, or filter by the known test user ID in the admin SQL editor. auth.uid() is normally null in an unimpersonated admin session.

![CSVJetty dashboard preview complete with no records delivered](https://www.csvjetty.com/6_DemoUserImportComplete.png)

Dashboard preview only: 24 records are prepared, but none were sent to an application. Verify database persistence separately from this screen.

## Resources

[Supabase upsert and conflict handling](https://supabase.com/docs/reference/javascript/upsert)

[Supabase Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
