
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 andTypeScript.
Use Bolt Database. Create a table named deliveries with these columns, andleave 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 thatlet the anonymous role select, insert, update and delete. Say in the chat whichpolicies you created.
Render /deliveries as a page that reads every row through the database clientand shows those nine columns in a table, with the row count above it and anempty state while the table holds nothing. Redirect / to /deliveries.
Keep the styling plain, and give the app one primary color, #9c4221. Noauthentication, 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 thatpage opens it as a modal wizard, our customers walk that wizard with the exportstheir old warehouse system produced, and it leaves them in a spreadsheet wherethey 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 andhttps://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.
| A | B | C | D | E | F | G | H | I | |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Delivery no. | Supplier name | Carrier name | Pallets received | Gross weight (kg) | Slot date | Dock | Load status | Booking contact email |
| 2 | AG-0001 | Tarnwick Packaging Ltd | Marden Freight | 28 | 16162.76 | 2026-09-19 | Dock 1 | Booked | [email protected] |
| 3 | AG-0002 | Beckhurst Coatings PLC | Delwyn Logistics | 27 | 14327.37 | 2026-10-24 | Dock 2 | Booked | [email protected] |
| 5 rows not shown | |||||||||
| 9 | AG-0008 | Halverne Glassworks PLC | Delwyn Logistics | 22 | 17602.38 | 2026-12-19 | Dock 1 | Unloaded | [email protected] |
| 7 rows not shown | |||||||||
| 17 | AG-0016 | Pelsham Adhesives Works | Ravenhill Haulage | 3 | 15168.71 | 2026-11-22 | Dock 2 | Booked | [email protected] |
| 26 rows not shown | |||||||||
| 44 | AG-0043 | Skelbrook Textiles Ltd | Marden Freight | 13 | 7248.45 | 2026-10-16 | Dock 2 | Booked | [email protected] |
| 81 rows not shown | |||||||||
| 126 | AG-0125 | Elverton Seals LLP | Marden Freight | 9 | 8414.99 | 2026-12-28 | Dock 2 | Booked | [email protected] |
| 81 rows not shown | |||||||||
| 208 | AG-0207 | Orrell Insulation Group | Marden Freight | 3 | 8641.15 | 2027-02-28 | Dock 1 | Arrived | [email protected] |
| 72 rows not shown | |||||||||
| 281 | AG-0280 | Pelsham Adhesives Works | Marden Freight | 5 | 10460.29 | 2027-01-26 | Dock 1 | Booked | [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 refSupplier name → SupplierCarrier name → CarrierPallets received → PalletsGross weight (kg) → Gross weightSlot date → Booking dateDock → Dock doorLoad status → StatusBooking contact email → Contact emailThe 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", }, 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.