Back to all postsA felt heart in four bands of orange, pink, purple and blue on cream paper

Add CSV and Excel Import to a Lovable App

Lovable turns a prompt into a working application with a frontend and a database. Once that app has customers, some of their data already lives elsewhere. A customer may have hundreds of accounts in a CSV or Excel file and need those rows inside your accounts table.

This guide adds that path with Updog Importer. The example is a CRM with an accounts page and an empty accounts table in Lovable Cloud.

You get there two ways. The agent builds an import screen for you, or the agent installs one.

Lovable already reads files in chat

Drop a CSV or a spreadsheet into the chat and Lovable turns it into database tables, up to 20 MB each on the free plan, 256 MB on a paid one and 1 GB on Enterprise.

That workflow serves the person building the app. It is useful for files you already have while you create or administer the project.

The import screen serves the customers of the finished app. They open it from the accounts page, bring files you have never seen, review the rows, and write the accepted data into the accounts table.

The app already has an accounts table

The CRM in this guide starts from one Lovable prompt.

Build a small CRM with one page. Use Lovable Cloud for the database.
Create a table named accounts with these columns.
account_ref text, unique, not null
account_name text, not null
website text
tier text
seats integer
renewal_date date
region text
primary_contact text
contact_email text
Leave the table empty.
Render /accounts as a table of every row with those nine columns, the row count
above it, and an empty state while the table holds nothing. Redirect / to
/accounts. Keep the styling plain and give the app one primary color.

Lovable enables Cloud from the prompt, creates the accounts table with those nine columns, and renders the accounts page. The table starts empty.

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 title and an export date above it
read every sheet of a workbook is .xlsx with three tabs
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 Company name where your app says accountName
match values onto your options says Enterprise plan where your app says Enterprise
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 schema on top and a screen for each stage, it reads like this.

Build a CSV and Excel import screen for the accounts page. A button on that
page opens it as a modal wizard, our customers walk that wizard with the files
their old CRM produced, and it leaves them in a spreadsheet where they clean
what came in.
The schema it works from
- Take the fields from a list my code hands it, so the same screen serves the
accounts page today and any other page later.
- Support a field of text, number, date, time, or a list of options we allow,
and let one field carry several rules at once.
- Support required, a pattern, a range, uniqueness inside the file, and
uniqueness against the records we already store.
- Show the person our title for a field, and hand my code back its name.
- Take one field as the key, so a second import of the same records finds them
and updates them.
- Drop the columns a file carries beyond the schema.
Screen 1, 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
file without turning accented letters 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 person choose which sheets go on.
- Find the real header row when title rows sit above it, and handle a file
that carries duplicate headers or no header at all.
- Keep a row that carries one field too few, and say which fields it filled.
Reading the values
- 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.
Screen 2, the columns
- Map the file's headers onto the schema's fields, one field per column.
- Match the obvious ones on arrival, and show the person which of our fields
reached nothing so they can point each one at a column by hand.
- Show a few values under every header, so the person tells two similar
columns apart.
- Remember the pairs the person confirmed, so the next file of that shape
arrives matched.
Screen 3, the values
- Collect the distinct values of every list field as the file spells them.
- Match each one to an option we allow, and let the person place the rest.
Data cleaning
- Hand the finished file to a spreadsheet the person works in, with our field
titles on top and every row in it.
- Mark what the person changed. A new row, an edited cell and a deleted row
each read differently at a glance.
- Run every rule the schema carries, and say which cell failed and why.
- Let the person 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 person 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.
Submitting
- 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 distinguish written date orders, spreadsheet serial dates, and columns whose values remain ambiguous.

Common CSV import errors walks a few of those categories.

If you build the importer yourself, every rule in the prompt becomes application code you own. That code has to keep working when later files use different encodings, headers, values, dates, or workbook layouts.

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

The second prompt installs the importer

Add a spreadsheet import screen to the accounts 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. Render <DataEditor /> with apiKey="updog-lovable-demo", which is all the
key a lovable.app host needs, variant="uploader", primaryKey="accountRef"
and these columns:
accountRef (text, required, unique), accountName (text, required),
website (text), tier (select: Starter, Growth, Enterprise),
seats (number), renewalDate (date), region (select: EMEA, AMER, APAC),
primaryContact (text), contactEmail (text, email).
3. 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.
4. In onComplete, walk result.sources, split each source's rows on isNew,
isChanged and isDeleted, write them to the accounts table, and close the
modal once the write returns.
5. Throw from onComplete when that write fails. The editor clears its rows
as soon as onComplete resolves, so a swallowed error loses the import.
6. Do not build an uploader of your own, do not parse the file yourself,
and do not invent props.

Lovable can add public npm packages from chat. There is no terminal step and no package.json to edit, which keeps the second prompt short. Updog Importer ships as a React component and a stylesheet, so the agent can install the dependency and mount the importer inside the app it already generated.

The package is still third-party code. Lovable's documentation warns that a package built for another environment installs without an error and then does nothing in the published app, so testing what you install stays part of your application work.

The importer mirrors the accounts table

The nine accounts fields become nine importer columns. Each column points to one field 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 Account = {
accountRef: string;
accountName: string;
website: string;
tier: string;
seats: string;
renewalDate: string;
region: string;
primaryContact: string;
contactEmail: string;
};
const columns: DataEditorColumn[] = [
{
id: "accountRef",
title: "Account ref",
validators: [{ type: "required" }, { type: "unique" }],
},
{
id: "accountName",
title: "Account name",
validators: [{ type: "required" }],
},
{
id: "website",
title: "Website",
},
{
id: "tier",
title: "Tier",
editor: {
type: "select",
options: ["Starter", "Growth", "Enterprise"],
enableCustomValue: false,
},
},
{
id: "seats",
title: "Seats",
editor: { type: "number" },
},
{
id: "renewalDate",
title: "Renewal date",
editor: { type: "date" },
},
{
id: "region",
title: "Region",
editor: {
type: "select",
options: ["EMEA", "AMER", "APAC"],
enableCustomValue: false,
},
},
{
id: "primaryContact",
title: "Primary contact",
},
{
id: "contactEmail",
title: "Contact email",
validators: [{ type: "email" }],
},
];

accountRef identifies an account inside the editor. The database stores the same value as account_ref and enforces its own unique constraint there. The two rules solve related problems at different boundaries.

The page mounts the importer

primaryKey names the column that identifies a row. A second import of a corrected file matches on that column and updates the row it finds. The editor renders wherever the accounts page opens it.

<DataEditor<Account>
apiKey="updog-lovable-demo"
variant="uploader"
open={open}
onClose={() => {
setOpen(false);
}}
columns={columns}
primaryKey="accountRef"
onComplete={handleComplete}
/>

The editor also inherits the app's primary color through --updog-brand.

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

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 180 accounts out of the customer's old system, on one sheet of a workbook.

calderbrook-accounts.xlsx
ABCDEFGHI
1Account refCompany nameWebsite URLSubscription tierSeatsRenewal dateRegionMain contactContact email
2CB-0001Alverston Analytics Ltdalverstonanaly.exampleStarter3102027-02-13AMERAdela Ashgrove[email protected]
3CB-0002Bramfield Instruments GmbHbramfieldinstr.exampleEnterprise3062027-04-27AMERDelphine Lymington[email protected]
4 rows not shown
8CB-0007Garsdale Networks Ltdgarsdalenetwor.exampleGrowth2732026-10-31APACCorin Crandale[email protected]
6 rows not shown
15CB-0014Northwold Optics GmbHnorthwoldoptic.exampleStarter6512027-04-10AMERHalvard Pennington[email protected]
26 rows not shown
42CB-0041Ottermill Labs Groupottermilllabsg.exampleEnterprise42027-08-18EMEAIsolde Ivenshaw[email protected]
34 rows not shown
77CB-0076Yeadon Systems Holdingsyeadonsystemsh.exampleEnterprise6872027-07-27AMERBram Jerrold[email protected]
42 rows not shown
120CB-0119Ottermill Networks Groupottermillnetwo.exampleGrowth1302027-05-06AMERCorin Crandale[email protected]
60 rows not shown
181CB-0180Yeadon Freight PLCyeadonfreightp.exampleGrowth8242026-12-05AMERJorin Bellweather[email protected]
1Account ref,Company name,Website URL,Subscription tier,Seats,Renewal date,Region,Main contact,Contact email2CB-0001,Alverston Analytics Ltd,alverstonanaly.example,Starter,310,2027-02-13,AMER,Adela Ashgrove,[email protected]3CB-0002,Bramfield Instruments GmbH,bramfieldinstr.example,Enterprise,306,2027-04-27,AMER,Delphine Lymington,[email protected]4 rows not shown8CB-0007,Garsdale Networks Ltd,garsdalenetwor.example,Growth,273,2026-10-31,APAC,Corin Crandale,[email protected]6 rows not shown15CB-0014,Northwold Optics GmbH,northwoldoptic.example,Starter,651,2027-04-10,AMER,Halvard Pennington,[email protected]26 rows not shown42CB-0041,Ottermill Labs Group,ottermilllabsg.example,Enterprise,4,2027-08-18,EMEA,Isolde Ivenshaw,[email protected]34 rows not shown77CB-0076,Yeadon Systems Holdings,yeadonsystemsh.example,Enterprise,687,2027-07-27,AMER,Bram Jerrold,[email protected]42 rows not shown120CB-0119,Ottermill Networks Group,ottermillnetwo.example,Growth,130,2027-05-06,AMER,Corin Crandale,[email protected]60 rows not shown181CB-0180,Yeadon Freight PLC,yeadonfreightp.example,Growth,824,2026-12-05,AMER,Jorin Bellweather,[email protected]

Four headers use names that do not appear in the accounts schema.

Company name → Account name
Website URL → Website
Subscription tier → Tier
Main contact → Primary contact

The matcher resolves all nine headers against the importer schema. Region and Tier carry closed lists, and the value step matches the distinct values of those two columns as well. This export needs no manual mapping, so all 180 rows reach the grid without validation errors.

A file that mixes date formats follows the same path but leaves a decision unresolved before the rows reach the grid.

Parsing dates during import and building a column mapping screen cover those two steps.

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 180 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<Account>) => {
const inserts: AccountRecord[] = [];
const updates: AccountRecord[] = [];
const deletes: string[] = [];
for (const source of result.sources) {
for (const resultRow of source.rows) {
const record = toAccountRecord(resultRow.row);
if (resultRow.isDeleted) {
if (!resultRow.isNew) deletes.push(record.account_ref);
} else if (resultRow.isValid && resultRow.isNew) {
inserts.push(record);
} else if (resultRow.isValid && resultRow.isChanged) {
updates.push(record);
}
}
}
await writeImportedAccounts({ data: { inserts, updates, deletes } });
setOpen(false);
};

toAccountRecord renames each field to its column in the table. A number column hands back text, so seats arrives as "310" and the mapper runs it through Number. A date column hands back ISO, so renewalDate arrives as "2027-02-13", independent of how the date appeared in the grid.

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

{
sources: [
{
sourceId: "source_…",
sourceName: "calderbrook-accounts.xlsx",
rows: [
{
row: {
accountRef: "CB-0001",
accountName: "Alverston Analytics Ltd",
website: "alverstonanaly.example",
tier: "Starter",
seats: "310",
renewalDate: "2027-02-13",
region: "AMER",
primaryContact: "Adela Ashgrove",
contactEmail: "[email protected]",
},
isNew: true,
isChanged: false,
isDeleted: false,
isValid: true,
},
// 179 more rows
],
},
],
counts: { new: 180, changed: 0, deleted: 0, invalid: 0 },
learnedSynonyms: { columns: [], values: [] },
}

The database decides what a repeat import means

Lovable Cloud runs on Supabase's foundation, and the code the agent generated reaches it with @supabase/supabase-js. The handler sends the rows to a server function, which writes them with a Supabase upsert.

const upserts = [...data.inserts, ...data.updates];
if (upserts.length > 0) {
const { error } = await supabaseAdmin
.from("accounts")
.upsert(upserts, { onConflict: "account_ref" });
if (error) throw new Error(`Failed to save accounts: ${error.message}`);
}

The editor state and the database state are separate. On this run every row has isNew: true because the editor opened empty. PostgreSQL can still find the same account_ref in the table and update that record through upsert, so a second pass of the same file leaves the table at 180.

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 unique key, or loadData can bring stored accounts into the editor so 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. Close the modal only after the write succeeds.

The Lovable 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 Lovable suffixes are on it, .lovableproject.com for the editor's own preview and .lovable.app for the URL you open and the one you publish. The prompt can leave the placeholder API key in place while the app runs there.

A custom domain changes that. If the CRM 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 from the Lovable chat as an npm package. Its columns mirror the accounts table, the customer resolves the file before submit, and onComplete returns the edited rows and their state to your code.

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 how to handle 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 Lovable code focused on the application and its write path.