Back to all postsA black felt triangle with rounded corners on cream paper

Add CSV and Excel Import to a v0 App

v0 turns a prompt into a working full-stack application. It defaults to Next.js and works with React, Tailwind CSS and shadcn/ui. For persistence, its built-in integrations include Neon, Supabase, Upstash and Vercel Blob.

Once that application has customers, some of their data already lives somewhere else. A customer may have hundreds of subscriptions in a spreadsheet exported from their previous billing system and need those rows inside your subscriptions table.

This guide adds that path with Updog Importer. The example is a billing page backed by an empty subscriptions table in Neon Postgres.

There are two ways to get there. The v0 agent can build an import screen, or it can install one.

v0 already reads files in chat

Drop a CSV or spreadsheet into the v0 chat and the agent can read it while it builds the application. For a connected SQL database, v0 can also generate and run the SQL needed to create or change tables.

That workflow is for the person building the app. The file becomes context for the agent.

A customer import is a different path. The customer opens the finished subscriptions page, selects a file you have never seen, reviews the rows, and writes the accepted data into your subscriptions table.

Uploading a CSV to v0 is therefore not the same as uploading a CSV to your application. One helps build the product. The other is a feature of the product.

The app already has a subscriptions table

For this guide, I prepared a small v0 sandbox in advance. I attached the Neon integration and gave v0 one prompt to build the starting billing app.

Build a small subscription billing app with one page, on Next.js with the App
Router.
The Neon Postgres integration is already attached, so DATABASE_URL is set. Put a
SQL script in scripts/ that creates a table named subscriptions with these
columns, and leave the table empty.
subscription_ref text primary key
customer_name text not null
plan text
billing_cycle text
quantity integer not null
unit_price numeric(10,2) not null
start_date date not null
renewal_date date not null
billing_email text
Render /subscriptions as a server component that reads every row through
@neondatabase/serverless and shows those nine columns in a table, with the row
count above it and an empty state while the table holds nothing. Redirect / to
/subscriptions.
Keep the styling plain, and give the app one primary color, #2f5bd0. No
authentication, no seed data, no extra pages.

v0 wrote the SQL, ran it against Neon, and created /subscriptions as a server component that reads through the Neon driver. The subscriptions table starts empty.

The rest of the guide begins from that sandbox.

The table has nine columns, and those names carry through the entire example. The importer's column ids use the same snake_case names as Postgres, so a row returned by the editor already has the shape the database expects, to keep it simple.

The file brings its own schema

The customer's file uses a schema your app does not control. Its headers may not match your field names, its dates may use another format, and its values may not match the options your fields accept.

The screen has to Because the file
detect encoding and delimiter arrives as UTF-8, Windows-1252, comma or semicolon
find the header row carries a report title and an export date above it
read every sheet of a workbook is .xlsx with one tab per billing entity
settle the date order per column writes 04/11/2026 and means the fourth of November
keep leading zeros holds 00417, which Excel already turned into 417
match headers onto your fields says Account name where your app says customer_name
match values onto your options says Annual plan where your app says Annual
check and let the person fix holds a row your database will reject
stay usable at scale is a hundred thousand rows on a laptop
report new, changed and deleted rows is the second import of a file that landed once already

Each row in that table needs an implementation rule. Written as a prompt, with the fields on top and one stage for each part of the import, the same work reads like this.

Build a CSV and Excel import screen for the subscriptions page. A button on that
page opens it as a modal wizard, our customers walk that wizard with the exports
their old billing tool produced, and it leaves them in a spreadsheet where they
clean what came in.
Where the fields come from
- Read the field list from my code, so the same screen serves the subscriptions
page today and an invoices page later.
- Support text, whole numbers, decimals, dates and a fixed list of options, and
let one field carry several rules at once.
- Check required, a range, a number of decimal places, uniqueness inside the
file, and uniqueness against the subscriptions we already store.
- Show the customer our label for a field, and hand my code back the field name.
- Take subscription_ref as the key, so a second upload of the same file finds
those subscriptions and updates them.
- Drop columns the file carries beyond that list.
Opening the files
- Take several files at once, by drop or by dialog, and show a card per file.
- Detect the encoding, strip a byte order mark, and read a Windows-1252 export
without turning accented company names into question marks.
- Detect the delimiter. Commas, semicolons, tabs and pipes all arrive.
- Drop a first line shaped sep=; that Excel writes for some locales.
- Read .xlsx, .xls and .ods, open a workbook as one card per sheet, and let the
customer choose which sheets go on.
- Find the real header row when a report title and an export date sit above it,
and handle a file with duplicate headers or no header at all.
- Keep a row carrying one field too few, and say which fields it filled.
Reading what the cells hold
- Settle the date order per column, so 04/11/2026 does not become April.
- Read 1.240,50 and 1,240.50 as the same number.
- Keep 00417 as text, including a code Excel already turned into 417.
- Tell an empty cell apart from a cell holding the word null.
Matching the columns
- Map the file's headers onto my field list, one field per column.
- Match the obvious ones on arrival, and show the customer which of my fields
reached nothing so they can point each one at a column by hand.
- Show a few values under every header, so the customer tells two similar
columns apart.
- Remember the pairs the customer confirmed, so the next file of that shape
arrives matched.
Matching the values
- Collect the distinct values of plan and billing cycle as the file spells them.
- Match each one to an option we allow, and let the customer place the rest.
Cleaning before the write
- Hand the finished file to a spreadsheet the customer works in, with my labels
on top and every row in it.
- Mark what the customer changed. A new row, an edited cell and a deleted row
each read differently at a glance.
- Run every rule the field list carries, and say which cell failed and why.
- Let the customer fix a cell in place, with the editor that fits the field. A
date opens a calendar, a list opens its options, a number takes digits.
- Sort and filter, so the customer reaches the failing rows among the hundred
thousand that pass.
- Copy and paste blocks between it and Excel or Google Sheets, and undo an edit.
- Stay smooth at a hundred thousand rows, with the reading off the main thread
so the tab keeps responding.
Handing the rows over
- Tell me which rows are new, which changed, and which were deleted.
- Keep every row and every mapping on screen when my write to the database
fails.

Each rule expands once real files arrive. Date parsing alone has to separate written date orders, spreadsheet serial dates, and columns whose values stay ambiguous after both.

Parsing dates during import walks one of those columns to a verdict.

If you build the importer yourself, every line of that prompt becomes application code you own, and it has to keep working when later files use different encodings, headers, values, dates and workbook layouts.

The second approach gives those file-facing rules to an importer package.

The second prompt installs the importer

v0 runs a VM-backed chat in Vercel Sandbox, a lightweight virtual machine that runs a full Node environment. It carries pnpm, npm, yarn and bun out of the box, and any dependency your project needs installs there. The install runs as a terminal command inside that sandbox, under one of three permission modes, Ask, Auto and Full, with Auto the default.

Add a spreadsheet import screen to the subscriptions page with the npm package
@updog/data-editor. Read https://updog.tech/updog.md and
https://docs.updog.tech first, and use only props documented there.
1. Install @updog/data-editor and import "@updog/data-editor/styles.css".
2. Put "use client" at the top of the file that renders the editor. The package
ships no "use client" of its own, so a server component cannot hold it.
3. Add an "Import subscriptions" button above the table, holding one open state.
The editor opens as a modal, which is its default: pass open={open} and an
onClose that closes it. Wrap it in nothing and give it no height of its own,
because the modal sizes itself.
4. Render <DataEditor /> with apiKey="updog-v0-demo", which is all the key a v0
preview or a vercel.app host needs, variant="uploader",
primaryKey="subscription_ref", enableDeleteRow="all" and these nine columns,
written as id, title, then type and rules:
subscription_ref "Subscription ref" text, required, unique
customer_name "Customer" text, required
plan "Plan" select: Lite, Core, Agency
billing_cycle "Billing cycle" select: Monthly, Quarterly, Annual
quantity "Quantity" number, whole, minimum 1, required
unit_price "Unit price" number, two decimals, minimum 0, required
start_date "Start date" date, required
renewal_date "Renewal date" date, required
billing_email "Billing email" text, email
The ids are the subscriptions table's own column names on purpose, so no
field is renamed between the editor and the database.
5. In a stylesheet loaded after "@updog/data-editor/styles.css", set
--updog-brand on :root to this app's primary color, written as a plain color
value.
6. onComplete receives result.sources, and every entry in a source's rows is a
wrapper shaped { row, isNew, isChanged, isDeleted, isValid }. Read the four
flags off the wrapper and the cell values off entry.row. Skip an entry whose
isValid is false.
7. entry.row is keyed by those same ids, so send it to the database as it is.
Write no mapper and no Number call. Postgres casts a text parameter to the
column's own type.
8. POST the rows to a route handler at app/api/subscriptions/route.ts, the new
and changed ones as one list and the deleted subscription_ref values as
another. The handler upserts with INSERT ... ON CONFLICT (subscription_ref)
DO UPDATE and deletes by subscription_ref, both through the Neon serverless
driver.
9. Throw from onComplete when the POST fails. The editor clears its rows as soon
as onComplete resolves, so a swallowed error loses the import. When it
succeeds, close the modal and call router.refresh(), so the subscriptions
table reloads with the imported rows.
10. Do not build an uploader of your own, do not parse the file yourself, and do
not invent props.

The package is still third-party code. Testing what you install stays part of your application work.

The importer mirrors the subscriptions table

The nine table columns become nine importer columns. Each column points to one database column through its id, while title gives the customer the name shown in the grid.

import {
DataEditor,
type DataEditorColumn,
type DataEditorResult,
} from "@updog/data-editor";
import "@updog/data-editor/styles.css";
type Subscription = {
subscription_ref: string;
customer_name: string;
plan: string;
billing_cycle: string;
quantity: string;
unit_price: string;
start_date: string;
renewal_date: string;
billing_email: string;
};
const columns: DataEditorColumn[] = [
{
id: "subscription_ref",
title: "Subscription ref",
validators: [{ type: "required" }, { type: "unique" }],
},
{
id: "customer_name",
title: "Customer",
validators: [{ type: "required" }],
},
{
id: "plan",
title: "Plan",
editor: {
type: "select",
options: ["Lite", "Core", "Agency"],
enableCustomValue: false,
},
},
{
id: "billing_cycle",
title: "Billing cycle",
editor: {
type: "select",
options: ["Monthly", "Quarterly", "Annual"],
enableCustomValue: false,
},
},
{
id: "quantity",
title: "Quantity",
editor: { type: "number" },
validators: [
{ type: "required" },
{ type: "number", min: 1, decimalPlaces: 0 },
],
},
{
id: "unit_price",
title: "Unit price",
editor: { type: "number" },
validators: [
{ type: "required" },
{ type: "number", min: 0, decimalPlaces: 2 },
],
},
{
id: "start_date",
title: "Start date",
editor: { type: "date" },
validators: [{ type: "required" }],
},
{
id: "renewal_date",
title: "Renewal date",
editor: { type: "date" },
validators: [{ type: "required" }],
},
{
id: "billing_email",
title: "Billing email",
validators: [{ type: "email" }],
},
];

The validators repeat rules the table already declares, and they run at a different boundary. subscription_ref is a primary key in Postgres, so a duplicate reference fails at the write with the whole statement. { type: "unique" } on the column flags the second row inside the file, before submit, on the row that carries it.

quantity and unit_price carry { type: "required" } for the same reason. Both map to a NOT NULL column, and a required rule stops an empty cell in the grid where the customer can fill it.

The page mounts the importer

The editor draws on canvas and needs the browser DOM, and the package ships no "use client" directive of its own. In an App Router project the file that renders it declares the directive itself.

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function SubscriptionImporter() {
const [open, setOpen] = useState(false);
const router = useRouter();
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Import subscriptions
</button>
<DataEditor<Subscription>
apiKey="updog-v0-demo"
variant="uploader"
open={open}
onClose={() => {
setOpen(false);
}}
columns={columns}
primaryKey="subscription_ref"
enableDeleteRow="all"
onComplete={handleComplete}
/>
</>
);
}

primaryKey names the column that identifies a subscription. A second import of a corrected file matches on that column and updates the row it finds. enableDeleteRow lets the customer remove a subscription, which gives the submit handler a deleted row to route.

The editor takes the app's primary color through one CSS variable.

/* loaded after @updog/data-editor/styles.css */
:root {
--updog-brand: #2f5bd0;
}

Match the importer to your product takes the theming further, through typography, the grid, shadows and a dark theme.

The file uses another system's vocabulary

The export carries 240 subscriptions out of the customer's old billing tool, on one sheet of a workbook.

larkmead-subscriptions.xlsx
ABCDEFGHI
1Subscription IDAccount namePlanBilling frequencyQuantityPrice per unitContract startRenewal dueBilling contact email
2LM-0001Ashvale Studio LtdCoreMonthly98.142024-01-142026-09-14[email protected]
3LM-0002Brindon Bakery GroupLiteQuarterly10560.382026-02-152026-11-15[email protected]
4 rows not shown
8LM-0007Glenmore Garage CoLiteMonthly2126.552026-01-152026-09-15[email protected]
6 rows not shown
15LM-0014Oakhanger Florists GroupAgencyAnnual1933.92024-06-182027-06-18[email protected]
26 rows not shown
42LM-0041Ashvale Print LtdAgencyMonthly1928.792025-01-282026-09-28[email protected]
77 rows not shown
120LM-0119Thurloxton Garage CoAgencyMonthly2670.32026-02-172026-09-17[email protected]
78 rows not shown
199LM-0198Southwick Bakery GroupCoreMonthly1128.732025-10-192026-09-19[email protected]
41 rows not shown
241LM-0240Wilbraham Bakery LLPCoreQuarterly579.82025-09-212026-09-21[email protected]
1Subscription ID,Account name,Plan,Billing frequency,Quantity,Price per unit,Contract start,Renewal due,Billing contact email2LM-0001,Ashvale Studio Ltd,Core,Monthly,9,8.14,2024-01-14,2026-09-14,[email protected]3LM-0002,Brindon Bakery Group,Lite,Quarterly,105,60.38,2026-02-15,2026-11-15,[email protected]4 rows not shown8LM-0007,Glenmore Garage Co,Lite,Monthly,21,26.55,2026-01-15,2026-09-15,[email protected]6 rows not shown15LM-0014,Oakhanger Florists Group,Agency,Annual,19,33.9,2024-06-18,2027-06-18,[email protected]26 rows not shown42LM-0041,Ashvale Print Ltd,Agency,Monthly,19,28.79,2025-01-28,2026-09-28,[email protected]77 rows not shown120LM-0119,Thurloxton Garage Co,Agency,Monthly,26,70.3,2026-02-17,2026-09-17,[email protected]78 rows not shown199LM-0198,Southwick Bakery Group,Core,Monthly,112,8.73,2025-10-19,2026-09-19,[email protected]41 rows not shown241LM-0240,Wilbraham Bakery LLP,Core,Quarterly,57,9.8,2025-09-21,2026-09-21,[email protected]

Seven of the nine headers use names that appear nowhere in the subscriptions table.

Subscription ID → Subscription ref
Account name → Customer
Billing frequency → Billing cycle
Price per unit → Unit price
Contract start → Start date
Renewal due → Renewal date
Billing contact email → Billing email

The matcher resolves all nine headers against the importer schema, and the wizard reports 9/9 matched. Plan and Billing frequency carry closed lists, and the value step matches the distinct values of both, 3/3 matched each. This export needs no manual mapping, so all 240 rows reach the grid with no validation errors.

The grid holds the parsed rows for review. The customer can filter validation errors, edit cells, undo changes, and paste blocks from a spreadsheet before submit. Submit shows the final row counts, and on this file it read 240 new rows will be created.

Submit returns the rows and their state

Submit returns the edited rows grouped by source. Each row carries four independent flags that describe whether it is new, changed, deleted, and valid. Your handler decides what those states mean for the database.

const handleComplete = async (result: DataEditorResult<Subscription>) => {
const rows = result.sources.flatMap((source) => source.rows);
const upserts = rows
.filter((entry) => entry.isValid && !entry.isDeleted)
.filter((entry) => entry.isNew || entry.isChanged)
.map((entry) => entry.row);
const deletes = rows
.filter((entry) => entry.isDeleted && !entry.isNew)
.map((entry) => entry.row.subscription_ref);
const response = await fetch("/api/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ upserts, deletes }),
});
if (!response.ok) {
throw new Error(\`Import failed with \${response.status}\`);
}
setOpen(false);
router.refresh();
};

Nothing sits between the flags and the request body. The keys of entry.row are the table's column names, so the row is already the record, and upserts goes to the route handler as it is.

A number column hands back text. quantity arrives as "9" and unit_price as "8.14", and Postgres casts a text parameter to the column's own type, so neither value passes through Number on the way. A date column hands back ISO, so start_date arrives as "2024-01-14", independent of how the date appeared in the grid.

This is the whole result object from that run, with one of the 240 rows kept.

{
sources: [
{
sourceId: "source_…",
sourceName: "larkmead-subscriptions.xlsx",
rows: [
{
row: {
subscription_ref: "LM-0001",
customer_name: "Ashvale Studio Ltd",
plan: "Core",
billing_cycle: "Monthly",
quantity: "9",
unit_price: "8.14",
start_date: "2024-01-14",
renewal_date: "2026-09-14",
billing_email: "[email protected]",
},
isNew: true,
isChanged: false,
isDeleted: false,
isValid: true,
},
// 239 more rows
],
},
],
counts: { new: 240, changed: 0, deleted: 0, invalid: 0 },
learnedSynonyms: { columns: [], values: [] },
}

Import, edit and delete rows through a REST API covers the routing of those four flags in more shapes than this one.

The route handler writes to Postgres

The Neon integration writes DATABASE_URL into the project, and @neondatabase/serverless reads it. sql.transaction takes an array of queries and runs them inside one non-interactive transaction.

import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export async function POST(request: Request) {
const { upserts, deletes } = await request.json();
if (upserts.length > 0) {
await sql.transaction(
upserts.map((row) => {
return sql\`
INSERT INTO subscriptions (
subscription_ref, customer_name, plan, billing_cycle,
quantity, unit_price, start_date, renewal_date, billing_email
)
VALUES (
\${row.subscription_ref}, \${row.customer_name}, \${row.plan},
\${row.billing_cycle}, \${row.quantity}, \${row.unit_price},
\${row.start_date}, \${row.renewal_date}, \${row.billing_email}
)
ON CONFLICT (subscription_ref) DO UPDATE SET
customer_name = EXCLUDED.customer_name,
plan = EXCLUDED.plan,
billing_cycle = EXCLUDED.billing_cycle,
quantity = EXCLUDED.quantity,
unit_price = EXCLUDED.unit_price,
start_date = EXCLUDED.start_date,
renewal_date = EXCLUDED.renewal_date,
billing_email = EXCLUDED.billing_email
\`;
}),
);
}
if (deletes.length > 0) {
await sql\`
DELETE FROM subscriptions WHERE subscription_ref = ANY(\${deletes})
\`;
}
return Response.json({
upserted: upserts.length,
deleted: deletes.length,
});
}

ON CONFLICT (subscription_ref) DO UPDATE needs a conflict target the table declares, and subscription_ref is the primary key. Without that constraint a second import of the same file writes 240 more rows.

Neon caps a query over HTTP at 64 MB of request and response, so a file past a few tens of thousands of rows sends its upserts in chunks.

Import CSV into PostgreSQL covers the write side, including what a duplicate key inside one statement does.

The database decides what a repeat import means

The editor state and the database state stay separate. On this run every row carries isNew: true because the editor opened empty. Postgres still finds the same subscription_ref in the table and updates that record, so a second pass of the same file leaves the table at 240.

isNew means new to the editor. It does not mean absent from the database.

Your application chooses where repeat imports are reconciled. The database can resolve them at write time through the key, or loadData can bring stored subscriptions into the editor so the matching happens before submit. Updog Importer supplies the row state and chooses neither policy.

Let write errors reject onComplete. A rejected promise keeps the rows and mappings in the editor. A resolved promise tells Updog Importer that submission finished, so close the modal and refresh the page only after the write succeeds.

The v0 URL runs for free

Updog Importer makes one request to its license endpoint when the editor starts. It sends the API key and the page hostname. No rows, headers, or file contents go with it.

Updog keeps a list of development and preview hosts that can run the importer without a paid production domain. Both v0 hosts are on it, .v0.build for the chat preview and .vercel.app for the URL you publish to. The prompt can leave the placeholder API key in place while the app runs there.

A custom domain changes that. If the billing app moves to app.yourcompany.com, add that hostname at console.updog.tech. New accounts get 14 days free with no credit card. After the trial, a production domain costs $19 a month, with no per-row or per-import charge.

The app now has an import path

Updog Importer installs into a v0 project as an npm package. Its columns mirror the subscriptions table, the customer resolves the file before submit, and onComplete returns the edited rows and their state to a route handler you own.

The importer handles the file-facing work, parsing CSV and Excel, matching columns and values, validating cells, and letting the customer correct the data. Your application still decides what those rows mean in the database, including updates, deletions, and repeat imports.

You can build the same flow yourself. The first prompt in this guide lists what that requires. Installing the package keeps those behaviors inside the importer and leaves the v0 code focused on the billing page and its write path.