Back to all postsA green paper Supabase bolt cut from two triangles on a pale paper background

How to Import CSV Into Supabase

Supabase documents four ways to move a CSV into a table. The dashboard importer takes a file up to 100MB. pgloader carries a whole database across. COPY loads a file straight into a table from a direct database connection. The API writes rows from code you control. Every one of them starts from the seat of somebody who holds the project credentials. Your customer sits outside that seat, holding a price list exported from the system they are leaving.

Their export arrives looking like this.

price-list.csv
ABCDE
1Item CodeProduct NameCatUnit PriceLaunch
20041782 ThinkPad X13 Gen 4notebooks$1,299.0003/04/2026
30041783Dell U2723QEMONITORS$589.9917.04.2026
40041784USB-C Dock 90WPC accessoriesUSD 149.002026-05-02
1200 rows not shown
12050042985Logitech MX Master 3SPC accessories89.002026-05-14
1Item Code,Product Name,Cat,Unit Price,Launch20041782 ,ThinkPad X13 Gen 4,notebooks,"$1,299.00",03/04/202630041783,Dell U2723QE,MONITORS,"$589.99",17.04.202640041784,USB-C Dock 90W,PC accessories,USD 149.00,2026-05-021200 rows not shown12050042985,Logitech MX Master 3S,PC accessories,89.00,2026-05-14

Five headers, and none of them carry the names your table uses. The item codes carry leading zeros a numeric column would eat, and the first one trails a space. One launch date reads 03/04/2026 and the next reads 17.04.2026. Two prices carry a dollar sign and a thousands comma, and the third writes its currency in letters. The category column says notebooks, MONITORS and PC accessories.

The person drops that file into the importer inside your app. Updog Importer reads it in the browser, matches the headers to your schema, and puts every value in front of them. Your onComplete handler receives the rows. The handler posts them in chunks to a route you own. The route holds the Supabase secret key and calls upsert(). Supabase writes the table.

No Updog server sits anywhere in that chain.

Step 1. Create the table and its conflict target

Start in the SQL editor with the table the rows have to reach.

create table public.products (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null,
item_code text not null,
product_name text not null,
category text not null,
price numeric(10, 2),
launch_date date not null,
updated_at timestamptz not null default now(),
constraint products_tenant_item_key unique (tenant_id, item_code)
);
alter table public.products enable row level security;

item_code stays text, so 0041782 keeps the zeros a numeric column would eat. The unique constraint on tenant_id and item_code is the piece that makes a repeat import safe. Postgres puts the requirement plainly, "For ON CONFLICT DO UPDATE, a conflict_target must be provided", and the arbiter it infers has to be a non-deferrable unique index or unique constraint. Name a conflict target with nothing like that behind it and Postgres answers "there is no unique or exclusion constraint matching the ON CONFLICT specification".

A table you create in the SQL editor starts with row level security off, and Supabase warns that "a table in an exposed schema without RLS is readable and writable by anyone with your publishable key". The alter line closes that. The route below writes with the secret key, whose service_role carries BYPASSRLS, so the write path keeps working with the door shut.

Identity here takes two columns, because two of your customers can both stock item 0041782. Inside one import the tenant is fixed, so the item code alone identifies a product on the browser side.

Step 2. Describe the same shape to Updog Importer

The columns array is your table written for the person looking at the file. Each entry sets a title they read, an editor that decides how a cell is typed, and validators that mark what fails.

import type { DataEditorColumn } from "@updog/data-editor";
const CATEGORIES = ["Laptops", "Monitors", "Accessories"];
export const columns: DataEditorColumn[] = [
{
id: "itemCode",
title: "Item code",
size: 130,
transformer: (value) => String(value).trim(),
validators: [
{ type: "required" },
{ type: "unique" },
],
},
{
id: "productName",
title: "Product name",
size: 220,
validators: [{ type: "required" }],
},
{
id: "category",
title: "Category",
size: 150,
editor: { type: "select", options: CATEGORIES, enableCustomValue: false },
validators: [
{ type: "required" },
{ type: "oneOf", values: CATEGORIES },
],
},
{
id: "price",
title: "Unit price",
size: 130,
editor: { type: "number" },
validators: [
{ type: "number", min: 0, decimalPlaces: 2 },
],
},
{
id: "launchDate",
title: "Launch date",
size: 140,
editor: { type: "date" },
validators: [
{ type: "required" },
{ type: "date" },
],
},
];

Every editor earns its place against the incoming file. The date editor turns 03/04/2026 and 17.04.2026 into 2026-04-03 and 2026-04-17, which is the shape a Postgres date column accepts as it stands. The number editor strips the dollar sign, the thousands comma and accounting parentheses, so $1,299.00 lands as 1299.00. The USD 149.00 in the third row stays raw on purpose, because stripping letters would corrupt a code like abc123. The number validator flags that cell for the person to fix. The select editor with enableCustomValue off holds the category column to three options and sends everything else to the value matching step.

required sits on every column the table declares not null. The other built-in validators all pass an empty cell, so a blank category or a blank launch date would leave the grid clean, reach the route, and take the whole chunk down on a not-null violation. price is nullable in the table, so it stays open and arrives as null.

The synonyms prop teaches matching the words your customers already use, and the mount ties the whole thing together.

<DataEditor<Product>
apiKey="your-license-key"
variant="uploader"
open={open}
onClose={closeImporter}
columns={columns}
primaryKey="itemCode"
synonyms={{
columns: { itemCode: ["item code", "sku", "article no"] },
values: { Laptops: ["notebooks", "notebook", "laptop pc"] },
}}
onComplete={onComplete}
/>

Column synonyms map Item Code onto itemCode, and they cover the SKU and Article No the next supplier sends. Value synonyms map notebooks onto Laptops, a pair no score reaches on its own, since the two words share no letters in the same order. MONITORS and PC accessories land without help, because matching lowercases the value and drops spaces before it scores. Whatever the person fixes by hand comes back on the result as learnedSynonyms, ready to store and feed back next time. How to remember CSV import mappings between uploads covers that round trip. For the install and the modal wiring underneath this snippet, see how to import a CSV file into a React app.

Every snippet here is React. The web component build carries the same props, so a Vue, Angular or Svelte app writes the same schema and the same handler.

Step 3. Point the primary key at the same identity

primaryKey decides how an imported row meets a row already in the grid. Set it to itemCode and a matching row merges, so one item code stays one row. Values are compared after surrounding whitespace is trimmed, so the space trailing the first item code costs nothing. A row with an empty key merges with nothing and arrives as new.

That prop and the onConflict target have to name the same identity. primaryKey sets the isNew flag your handler reads, onConflict decides whether Postgres writes a row or updates one. Point them at different columns and the flags on the result stop describing what the table did.

Step 4. Post the result in chunks

On submit, Updog Importer hands your handler every row grouped by source, each one carrying isNew, isChanged, isDeleted and isValid. Rows nobody touched stay out. One person can drop three files in a single import, and each file arrives as its own source entry, so the handler flattens before it slices.

import type { DataEditorResult } from "@updog/data-editor";
const CHUNK_SIZE = 500;
const onComplete = useCallback(async (result: DataEditorResult<Product>) => {
const rows = result.sources
.flatMap((source) => source.rows)
.filter((entry) => entry.isValid && !entry.isDeleted)
.map((entry) => entry.row);
for (let start = 0; start < rows.length; start += CHUNK_SIZE) {
const response = await fetch("/api/products/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows: rows.slice(start, start + CHUNK_SIZE) }),
});
if (!response.ok) {
const failure = await response.json();
throw new Error(failure.message);
}
}
}, []);

Neither Supabase nor Vercel publishes a batch size, so 500 is a conservative choice of ours, sitting inside two ceilings that are published. The first ceiling is the body your own route accepts. A Vercel function caps the request body at 4.5 MB and answers a larger one with 413: FUNCTION_PAYLOAD_TOO_LARGE. One product row of these five fields serializes to 122 bytes.

{"itemCode":"0041782","productName":"ThinkPad X13 Gen 4","category":"Laptops","price":"1299.00","launchDate":"2026-04-03"}

Five hundred of those reach 60 KB, and 4.5 MB holds around 38,000. Run that division against your own widest row before you raise the number. A table of forty text columns changes the answer.

The second ceiling is time. Supabase publishes a statement timeout per database role, 3 seconds for anon, 8 seconds for authenticated, and none for service_role, which falls back to the authenticator role's 8 seconds. One upsert of 500 rows is one statement, so the whole chunk lives or dies inside that budget. Raise the ceiling when your rows are wide and your writes are slow.

alter role authenticator set statement_timeout = '30s';
notify pgrst, 'reload config';

The notify line is what makes PostgREST pick the change up, and it is documented alongside the timeouts. Counting bytes against the body cap is arithmetic you can do at your desk. The timeout moves with row width, indexes, triggers and whatever else the database is doing, so that one gets measured against your own table.

Throw when a chunk fails. Updog awaits your handler and clears the editor the moment it resolves. A handler that catches its own error and returns reads as success, and the grid empties with the rows unsaved. The confirm dialog holds the whole round trip behind a spinner, which is the other reason chunks stay small. Throwing keeps every row, every mapping and every fix on the screen, so the person presses submit again on data they can still see.

Anything on the result you want to keep gets copied inside the handler. After the promise resolves the editor drops its rows, its sources, its history and its learned synonyms.

That isValid filter carries a cost worth naming. A failed validator marks the cell and lets the person submit anyway, so the rows it flagged reach your handler and the filter drops them on the floor. Set blockSubmitOnError on the editor and submit stays disabled until the grid is clean. Keeping the filter open works too, once you split the rows by isValid and post the failures to a table your support team can read.

Step 5. Write the rows from your own route

The route is where the secret lives and where the browser stops.

import { createClient } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false } },
);
export async function POST(request: Request) {
const session = await getVerifiedSession();
if (!session) {
return NextResponse.json({ message: "Not signed in" }, { status: 401 });
}
const { rows } = await request.json();
const records = rows.map((row) => ({
tenant_id: session.tenantId,
item_code: row.itemCode,
product_name: row.productName,
category: row.category,
price: row.price === "" ? null : Number(row.price),
launch_date: row.launchDate,
updated_at: new Date().toISOString(),
}));
const { error } = await supabase
.from("products")
.upsert(records, { onConflict: "tenant_id,item_code" });
if (error) {
return NextResponse.json(
{ message: error.message, hint: error.hint, code: error.code },
{ status: 400 },
);
}
return NextResponse.json({ written: records.length });
}

getVerifiedSession() stands in for your own server-side authentication check. When Supabase Auth owns the session, reach for getClaims() or getUser(), because Supabase warns that getSession() reads credentials out of storage and gives no guarantee that the token was revalidated.

The secret key holds full access to your project's data through the built-in service_role, which carries the BYPASSRLS attribute. Supabase says "Never use in a browser, even on localhost", and a browser that sends one gets HTTP 401 back, because Supabase matches on the User-Agent header. In August 2026 both namings are live, sb_secret_... on the new keys and service_role on the legacy ones.

Bypassing row level security moves the decision to your code, so tenant_id comes off the session and never off the request body. The rest of the mapping is small. price arrives as the string 1299.00, and converting it before it enters the record keeps the payload matching a numeric column. launch_date arrives as 2026-04-03 and needs nothing.

upsert() takes the whole array in one call and returns no rows by default, so leaving .select() off keeps the response small. onConflict goes in as the comma-separated list of unique columns PostgREST expects.

An app whose users already hold Supabase sessions can keep RLS in the write path. Build the client inside the route from the publishable key and the person's own access token, then give the authenticated role the table privileges and the policies this write needs. An upsert takes the insert path or the update path, so it needs an insert policy with a with check expression and an update policy with both using and with check. Supabase adds that an update also needs a matching select policy. All three tie tenant_id to a claim in the token, and no secret sits in the chain at all.

Step 6. Send the failure back up the chain

Supabase Data API queries return a { data, error } pair by default. Chain .throwOnError() and the promise rejects instead. On the default an unchecked error reads exactly like a successful write.

The error object carries hint, code, details and message. A write that breaks a unique constraint your conflict target does not cover returns 23505, which PostgREST maps to HTTP 409 and describes as a uniqueness violation. A malformed body returns PGRST102. Pass message back, and pass hint along whenever Postgres supplies one, because that is where the actionable half lands.

Chunks that already landed stay landed. upsert against the same conflict target writes the same values a second time, so a retry after a failed chunk costs a rewrite of what already exists.

What Updog leaves to you

Updog Importer integrates with nobody. There is no Supabase connector, no destination list, no webhook and no server of ours. onComplete hands your code an object, and the route in the middle is work you do.

Supabase already ships its own import for the other case. The dashboard takes a CSV up to 100MB into a new or an existing table, pgloader handles a database migration, and psql loads a local file with \COPY over a direct connection. Its documentation adds that bulk imports through the API are worth avoiding. When the file is yours, those are the shorter paths. Everything above exists for the file that belongs to somebody else, arriving through a browser, in a session your app issued. Client-side and server-side CSV import sets out where each model earns its keep.

What you built

You wrote a table with a conflict target, a schema with five columns, a handler that chunks, and a route that holds one secret. The file stays on the machine that opened it. The rows travel from your own front end to your own route, and from there into Supabase, and the only party you added to the chain is yourself. Point the same setup at a Next.js CSV importer page or a plain React modal and the middle stays the same.

The person who sent that file will send another one later. That time the mappings are already stored, the dates already parse, and they close the tab on a table that matches yours.