Back to all postsA black felt square with a cream paper lightning bolt cut out of it

Add CSV and Excel Import to a Bolt App

Bolt turns a prompt into a working web application. StackBlitz built it on WebContainers, so the install, the dev server and the preview all run inside the browser tab. Bolt attaches a Bolt Database when the project needs one, and it offers Supabase as the alternative at project creation.

Once that application has customers, some of their records already sit somewhere else. A haulage client may keep a season of delivery bookings in a spreadsheet exported from the warehouse system they ran before, and those rows belong in your deliveries table.

Updog Importer adds that path. The example is a goods-in page backed by an empty deliveries table in Bolt Database.

The Bolt agent can build that import screen, or it can install one.

Bolt already reads files in chat

Attach a .csv file to the Bolt chat and the agent reads it while it builds the application. The Tables screen works in the other direction, where selected rows leave the database as CSV or JSON.

Both of those serve the person building the app.

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

Attaching a file to Bolt is therefore a step in building the product. The import screen below is part of the product.

The app already has a deliveries table

One prompt to the Bolt agent produced the starting warehouse app.

Build a small warehouse goods-in app with one page, on Vite, React and
TypeScript.
Use Bolt Database. Create a table named deliveries with these columns, and
leave the table empty.
delivery_ref text primary key
supplier_name text not null
carrier text
pallets integer not null
gross_weight_kg numeric(10,2) not null
booked_for date not null
dock text
status text
contact_email text
The app has no sign-in, so add row level security policies on deliveries that
let the anonymous role select, insert, update and delete. Say in the chat which
policies you created.
Render /deliveries as a page that reads every row through the database client
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 /deliveries.
Keep the styling plain, and give the app one primary color, #9c4221. No
authentication, no seed data, no extra pages.

Bolt applied a migration, created deliveries empty, and wrote four row level security policies scoped to the anonymous and authenticated roles, one per operation. It generated a Supabase client at src/lib/supabase.ts. The project carries no router, so the redirect went into App.tsx as a few lines of its own. The page rendered as 0 deliveries over an empty state.

Those nine column names carry through the whole example. The importer's column ids use the same snake_case names as the table, so a row the editor returns already has the shape the database expects, and no field gets renamed on the way.

The rest of the guide begins from that project, and from a file that knows nothing about it.

The file brings its own schema

The customer's file follows a schema your app does not control. Its headers may not match your field names, its dates may use another order, 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 depot
settle the date order per column writes 04/11/2026 and means the fourth of November
keep leading zeros holds 00417, and a plain reader makes it 417
match headers onto your fields says Slot date where your app says booked_for
match values onto your options says Marden Freight Ltd where your app says Marden Freight
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 line in that table needs an implementation rule. Written out as a prompt, the fields come first and each part of the import gets its own stage.

Build a CSV and Excel import screen for the deliveries page. A button on that
page opens it as a modal wizard, our customers walk that wizard with the exports
their old warehouse system 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 deliveries
page today and a despatches 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 deliveries we already store.
- Show the customer our label for a field, and hand my code back the field name.
- Take delivery_ref as the key, so a second upload of the same file finds those
deliveries 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 supplier 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 carrier, dock and status 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.

Prompting that screen into existence is the right call for a fixed set of columns, a file you produced yourself, and one import a month. The rules above stop being optional once the files come from customers you have never met.

If you build the importer yourself, every rule in that prompt becomes application code you own, and each one has to survive the next file the client exports.

Build or buy a CSV importer weighs that ownership over a longer horizon.

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

The second prompt installs the importer

Bolt runs the project in a WebContainer, a WebAssembly-based runtime from StackBlitz that boots Node inside the browser tab. An npm install happens there, and Code View opens a terminal on the same container where npm run build runs by hand.

Add a spreadsheet import screen to the deliveries 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. Add an "Import deliveries" 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. Do not pass mode="inline". Wrap it in nothing and
give it no height of its own, because the modal sizes itself.
3. Render <DataEditor /> with apiKey="updog-bolt-demo", which is all the key a
Bolt preview or a bolt.host site needs, variant="uploader",
primaryKey="delivery_ref", enableDeleteRow="all" and these nine columns,
written as id, title, then type and rules:
delivery_ref "Delivery ref" text, required, unique
supplier_name "Supplier" text, required
carrier "Carrier" select: Ravenhill Haulage, Copsey Transport,
Marden Freight, Delwyn Logistics
pallets "Pallets" number, whole, minimum 1, required
gross_weight_kg "Gross weight" number, two decimals, minimum 0, required
booked_for "Booking date" date, required
dock "Dock door" select: Dock 1, Dock 2, Dock 3
status "Status" select: Booked, Arrived, Unloaded
contact_email "Contact email" text, email
The ids are the deliveries table's own column names on purpose, so no field
is renamed between the editor and the database.
4. 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.
5. 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.
6. 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.
7. Write with the database client this project already has. Send the new and
changed rows through one upsert on deliveries with delivery_ref as the
conflict target, and delete the removed ones by delivery_ref. Both calls go
straight from the browser, so they run under the row level security policies
on the table.
8. Throw from onComplete when the write fails, and include the error the client
returned. The editor clears its rows as soon as onComplete resolves, so a
swallowed error loses the import. When it succeeds, close the modal and
reload the deliveries list.
9. Do not build an uploader of your own, do not parse the file yourself, and do
not invent props.

The agent read updog.md, then the documentation site, then the package's own type declarations, installed the dependency and wrote the screen in one pass. The build and the typecheck passed with no prop corrected by hand.

A package is still code you did not write. What it does to a customer's file is yours to test before the first customer opens it.

The importer mirrors the deliveries table

The nine table columns become nine importer columns. Each column points at one database column through its id, and 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 Delivery = {
delivery_ref: string;
supplier_name: string;
carrier: string;
pallets: string;
gross_weight_kg: string;
booked_for: string;
dock: string;
status: string;
contact_email: string;
};
const columns: DataEditorColumn[] = [
{
id: "delivery_ref",
title: "Delivery ref",
validators: [{ type: "required" }, { type: "unique" }],
},
{
id: "supplier_name",
title: "Supplier",
validators: [{ type: "required" }],
},
{
id: "carrier",
title: "Carrier",
editor: {
type: "select",
options: [
"Ravenhill Haulage",
"Copsey Transport",
"Marden Freight",
"Delwyn Logistics",
],
enableCustomValue: false,
},
},
{
id: "pallets",
title: "Pallets",
editor: { type: "number" },
validators: [
{ type: "required" },
{ type: "number", min: 1, decimalPlaces: 0 },
],
},
{
id: "gross_weight_kg",
title: "Gross weight",
editor: { type: "number" },
validators: [
{ type: "required" },
{ type: "number", min: 0, decimalPlaces: 2 },
],
},
{
id: "booked_for",
title: "Booking date",
editor: { type: "date" },
validators: [{ type: "required" }],
},
{
id: "dock",
title: "Dock door",
editor: {
type: "select",
options: ["Dock 1", "Dock 2", "Dock 3"],
enableCustomValue: false,
},
},
{
id: "status",
title: "Status",
editor: {
type: "select",
options: ["Booked", "Arrived", "Unloaded"],
enableCustomValue: false,
},
},
{
id: "contact_email",
title: "Contact email",
validators: [{ type: "email" }],
},
];

Some validators repeat rules the table already declares, and they run at a different boundary. delivery_ref is a primary key in Postgres, so two rows carrying one reference fail at the write and take the whole statement with them. { type: "unique" } flags every row that shares a reference inside the file, before submit, on the rows that carry it.

The three option lists have no counterpart in the table, which stores plain text in those columns. pallets and gross_weight_kg map to NOT NULL columns, so both carry { type: "required" }. The numeric(10,2) column gets decimalPlaces: 2 beside it, which flags a third decimal in the grid where Postgres would round it silently. Those nine entries are one value now, ready for the page to pass.

The page mounts the importer

The editor draws on canvas and needs the browser DOM. The Bolt project here is a Vite React bundle that runs in the browser already, so the component mounts with no directive of its own.

export function DeliveryImporter({ onImported }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Import deliveries
</button>
<DataEditor<Delivery>
apiKey="updog-bolt-demo"
variant="uploader"
open={open}
onClose={() => {
setOpen(false);
}}
columns={columns}
primaryKey="delivery_ref"
enableDeleteRow="all"
onComplete={handleComplete}
/>
</>
);
}

primaryKey names the column that identifies a delivery inside the editor, and the editor requires it. enableDeleteRow lets the customer remove a booking, which gives the submit handler a deleted row to route, and it defaults to false, so leaving it out keeps deletion away from the customer.

One CSS variable moves the app's primary color into the editor.

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

Match the importer to your product goes further into typography, the grid, shadows and a dark theme.

The file uses another system's vocabulary

The export carries 280 delivery bookings out of the client's old warehouse system, on one sheet of a workbook.

aldermere-goods-in.xlsx
ABCDEFGHI
1Delivery no.Supplier nameCarrier namePallets receivedGross weight (kg)Slot dateDockLoad statusBooking contact email
2AG-0001Tarnwick Packaging LtdMarden Freight2816162.762026-09-19Dock 1Booked[email protected]
3AG-0002Beckhurst Coatings PLCDelwyn Logistics2714327.372026-10-24Dock 2Booked[email protected]
5 rows not shown
9AG-0008Halverne Glassworks PLCDelwyn Logistics2217602.382026-12-19Dock 1Unloaded[email protected]
7 rows not shown
17AG-0016Pelsham Adhesives WorksRavenhill Haulage315168.712026-11-22Dock 2Booked[email protected]
26 rows not shown
44AG-0043Skelbrook Textiles LtdMarden Freight137248.452026-10-16Dock 2Booked[email protected]
81 rows not shown
126AG-0125Elverton Seals LLPMarden Freight98414.992026-12-28Dock 2Booked[email protected]
81 rows not shown
208AG-0207Orrell Insulation GroupMarden Freight38641.152027-02-28Dock 1Arrived[email protected]
72 rows not shown
281AG-0280Pelsham Adhesives WorksMarden Freight510460.292027-01-26Dock 1Booked[email protected]
1Delivery no.,Supplier name,Carrier name,Pallets received,Gross weight (kg),Slot date,Dock,Load status,Booking contact email2AG-0001,Tarnwick Packaging Ltd,Marden Freight,28,16162.76,2026-09-19,Dock 1,Booked,[email protected]3AG-0002,Beckhurst Coatings PLC,Delwyn Logistics,27,14327.37,2026-10-24,Dock 2,Booked,[email protected]5 rows not shown9AG-0008,Halverne Glassworks PLC,Delwyn Logistics,22,17602.38,2026-12-19,Dock 1,Unloaded,[email protected]7 rows not shown17AG-0016,Pelsham Adhesives Works,Ravenhill Haulage,3,15168.71,2026-11-22,Dock 2,Booked,[email protected]26 rows not shown44AG-0043,Skelbrook Textiles Ltd,Marden Freight,13,7248.45,2026-10-16,Dock 2,Booked,[email protected]81 rows not shown126AG-0125,Elverton Seals LLP,Marden Freight,9,8414.99,2026-12-28,Dock 2,Booked,[email protected]81 rows not shown208AG-0207,Orrell Insulation Group,Marden Freight,3,8641.15,2027-02-28,Dock 1,Arrived,[email protected]72 rows not shown281AG-0280,Pelsham Adhesives Works,Marden Freight,5,10460.29,2027-01-26,Dock 1,Booked,[email protected]

Every one of the nine headers is written differently from the column it belongs to.

Delivery no. → Delivery ref
Supplier name → Supplier
Carrier name → Carrier
Pallets received → Pallets
Gross weight (kg) → Gross weight
Slot date → Booking date
Dock → Dock door
Load status → Status
Booking contact email → Contact email

The matcher resolves all nine against the importer schema, and the wizard reports 9/9 matched. Seven headers score on the containment tier, where one normalized string of at least four characters sits inside the other, and Delivery no. and Slot date score on word overlap, where half the words are shared. Carrier, Dock door and Status carry closed lists, and the value step settles those as 4/4, 3/3 and 3/3, with nothing left to place by hand.

Building a column mapping screen covers what happens on the headers that reach nothing.

All 280 rows then reach the grid, with no validation error and no empty cell. The customer can filter, edit cells, undo changes, and paste blocks from a spreadsheet before submit. Submit prints the counts it is about to send, and on this file it read 280 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 describing whether it is new, changed, deleted and valid. Your handler decides what those states mean for the database.

const handleComplete = async (result: DataEditorResult<Delivery>) => {
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.delivery_ref);
await writeDeliveries(upserts, deletes);
setOpen(false);
await onImported();
};

No mapper sits between the flags and the write. The keys of entry.row are the table's own column names, so upserts goes to the database as it is.

A number column hands back text. pallets arrives as "28" and gross_weight_kg as "16162.76", and Postgres reads that text into the column's own type, so neither value passes through Number on the way. A date column hands back ISO, so booked_for arrives as "2026-09-19", independent of how the date appeared in the grid.

This is the whole result object, with one of the 280 rows kept.

{
sources: [
{
sourceId: "source_…",
sourceName: "aldermere-goods-in.xlsx",
rows: [
{
row: {
delivery_ref: "AG-0001",
supplier_name: "Tarnwick Packaging Ltd",
carrier: "Marden Freight",
pallets: "28",
gross_weight_kg: "16162.76",
booked_for: "2026-09-19",
dock: "Dock 1",
status: "Booked",
contact_email: "[email protected]",
},
isNew: true,
isChanged: false,
isDeleted: false,
isValid: true,
},
// 279 more rows
],
},
],
counts: { new: 280, changed: 0, deleted: 0, invalid: 0 },
learnedSynonyms: { columns: [], values: [] },
}

Nothing in that object names a table or a statement, so the handler turns those four flags into a write of your own.

Import, edit and delete rows through a REST API routes those four flags against a backend that answers over HTTP.

The write reaches Bolt Database

Bolt applied a Postgres migration for this table, and the Tables screen shows the row level security policies each table carries. The write below runs in the customer's browser under the anonymous role, so those policies decide whether a row lands.

const writeDeliveries = async (
upserts: Delivery[],
deletes: string[],
) => {
if (upserts.length > 0) {
const { error } = await supabase
.from("deliveries")
.upsert(upserts, { onConflict: "delivery_ref" });
if (error) {
throw new Error(error.message);
}
}
if (deletes.length > 0) {
const { error } = await supabase
.from("deliveries")
.delete()
.in("delivery_ref", deletes);
if (error) {
throw new Error(error.message);
}
}
};

supabase is the client Bolt generated for the project, at src/lib/supabase.ts. The write is that client's call, and it runs under the policies the Tables screen lists.

onConflict: "delivery_ref" needs a constraint the table declares, and delivery_ref is the primary key. Postgres refuses the upsert when no such constraint exists.

A policy that turns the write away answers through the same error field. The handler cannot tell a refused row from a malformed one, and it does not need to, because both leave the data where the customer can still see it.

An error from either call becomes a thrown Error, and the rows stay on screen because of it. A resubmit then starts from the same grid, against a table that may already hold some of those rows.

The database decides what a repeat import means

Editor state and database state answer different questions. Every row on this run carries isNew: true, because the grid opened with nothing in it. The table already held the same delivery_ref on the second import, so the upsert updated those records and the count stayed at 280.

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

You pick where a repeat import gets reconciled. The key settles it at write time in the database, or loadData pulls stored deliveries into the grid and settles it before submit. Updog Importer reports the row state and picks neither.

Let write errors reject onComplete. A rejected promise keeps the rows in the editor, and it undoes nothing the database already accepted. A resolved promise tells Updog Importer that submission finished, so close the modal and reload the page only after the write succeeds.

The Bolt URL runs for free

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

Updog keeps a list of development and preview hosts that run the importer without a paid production domain. Both Bolt hosts are on it, .webcontainer-api.io for the in-editor preview and .bolt.host for the site you publish. The prompt can leave the placeholder API key in place while the app runs there.

The preview host is worth knowing by sight, because it is the first place the editor opens and it carries neither the word Bolt nor the project's name. Bolt forwards the WebContainer's port to a URL shaped <hash>--5173--<hash>.local-credentialless.webcontainer-api.io, where 5173 is the Vite port the project serves on.

A custom domain changes that on both sides. Bolt sells custom domains on its paid plans, and a production hostname needs a slot 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 Bolt project as an npm package. Its columns carry the deliveries table's own names, the customer resolves the file before submit, and onComplete returns the edited rows and their state to a 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, and the first prompt above names every rule that takes. Installing the package keeps those rules inside the importer, and leaves the Bolt project holding the goods-in page and its write path.