
Add CSV and Excel Import to a Base44 App
Base44 turns a prompt into a working app with a UI, database, backend logic and authentication. Its data lives in entities, so a procurement app might have a Supplier entity behind its suppliers page.
Once that app has customers, some of their data already lives elsewhere. A buyer may have hundreds of approved vendors in a CSV or Excel file and need those rows inside Supplier.
This guide adds that path with Updog Importer. There are two ways to get there. You can ask the Base44 agent to build the import flow, or have it install and wire up the importer.
Base44 already has two ways to import data
Base44 has two built-in import paths for the person building the app. Upload a CSV, Excel or JSON file to the AI chat and ask it to create or extend an entity and load the rows. CSV and JSON files can be up to 10 MB, while Excel files can be up to 15 MB. You can also open Dashboard, then Data, choose a table, and import a CSV from More Actions.
Both imports append records. Base44 does not update or overwrite existing rows during an import. Replacing a table means clearing its records first and importing the file again.
Those tools are for bringing data into the app while you build or administer it. The import screen in this guide serves the people who use the finished product. They open it inside the procurement app, bring vendor files you have never seen, review the rows, and write the accepted data into Supplier.
The app already has a Supplier entity
The procurement app in this guide starts from one Base44 prompt.
Build a small procurement app with one page.
Create an entity named Supplier with these fields. supplier_ref text, required supplier_name text, required category text, enum: Packaging, Logistics, Raw materials, Maintenance incoterm text, enum: EXW, FOB, DAP, DDP lead_time_days integer, minimum 0 approved_on text, format date status text, enum: Approved, Pending, Suspended contact_name text contact_email text, email formatLeave the entity empty.
Render /suppliers as a table of every record with those nine fields, the recordcount above it, and an empty state while the entity holds nothing. Redirect / to/suppliers. Keep the styling plain, and give the app one primary color, #1f6f5c.Base44 creates the Supplier entity with nine fields and renders the suppliers page around it. The entity starts empty. The import screen gives the buyer a way to fill it from the vendor list they already have.
The file brings its own schema
The buyer's file comes from a purchasing system you have never seen. Its headers use that system's names. Its dates use that system's format. Its values may not match the options your Supplier 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 buying site |
| 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 Vendor code where your app says supplierRef |
| match values onto your options | says Raw mats where your app says Raw materials |
| check and let the person fix | holds a row your entity 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 a rule. Written as a prompt, with the entity fields first and one stage for each part of the import, the same work looks like this.
Build a CSV and Excel import screen for the suppliers page. A button on thatpage opens it as a modal wizard, our buyers walk that wizard with the vendorlists their old purchasing system produced, and it leaves them in aspreadsheet where they clean what came in.
Where the fields come from- Read the field list from my code, so the same screen serves the suppliers page today and a contracts page later.- Support text, whole numbers, dates and a fixed list of options, and let one field carry several rules at once.- Check required, a pattern, a range, uniqueness inside the file, and uniqueness against the vendors we already store.- Show the buyer our label for a field, and hand my code back the field name.- Take supplier_ref as the key, so a second upload of the same vendors finds them 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 buyer 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 buyer 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 buyer tells two similar columns apart.- Remember the pairs the buyer confirmed, so the next file of that shape arrives matched.
Matching the values- Collect the distinct values of category, incoterm and status as the file spells them.- Match each one to an option we allow, and let the buyer place the rest.
Cleaning before the write- Hand the finished file to a spreadsheet the buyer works in, with my labels on top and every row in it.- Mark what the buyer 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 buyer 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 buyer 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 entity fails.The prompt still hides the variations inside each field. A lead time may arrive as 14, 14 days, 2 weeks, or an empty cell because the supplier never agreed to one. A status may use the buyer's own vocabulary. A country may arrive as a name, code, or abbreviation.
Those differences belong to the source file, not to your Supplier entity. The import has to map them before the rows are written.
CSV import for procurement software follows a supplier export header by header and shows where those mappings come from.
If you build the flow yourself, every rule in the prompt becomes application code you own. The other path gives the same requirements to an importer package.
The second prompt installs the importer
Add a spreadsheet import screen to the suppliers 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. Render <DataEditor /> with apiKey="updog-base44-demo", which is all the key a base44.app host needs, variant="uploader", primaryKey="supplierRef", enableDeleteRow="all" and these nine columns, written as id, title, then type and rules: supplierRef "Supplier ref" text, required, unique supplierName "Supplier name" text, required category "Category" select: Packaging, Logistics, Raw materials, Maintenance incoterm "Incoterm" select: EXW, FOB, DAP, DDP leadTimeDays "Lead time" number approvedOn "Approved" date status "Status" select: Approved, Pending, Suspended contactName "Contact" text contactEmail "Contact email" text, email3. 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. 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.5. entry.row is keyed by the column id in camelCase, and the Supplier entity fields are snake_case, so write one mapper that renames all nine fields explicitly and passes lead_time_days through Number.6. Route the rows into the entity. New ones through bulkCreate. Changed ones need their record id, so read the stored records back with a filter on supplier_ref and send them through bulkUpdate. Deleted ones through deleteMany on supplier_ref. Batch every call at 500, skip an empty batch, and close the modal once the writes return.7. Throw from onComplete when a write fails. The editor clears its rows as soon as onComplete resolves, so a swallowed error loses the import.8. Do not build an uploader of your own, do not parse the file yourself, and do not invent props.Base44 can install public npm packages from the AI chat. Name the package, approve the installation when Base44 asks, and the dependency becomes part of the app. You do not open a terminal or edit the dependency list by hand.
That keeps the second prompt short. Updog Importer ships as a React component with its stylesheet, and Base44 apps use React with Vite, so the agent can install the package and mount the importer inside the project it already generated.
The package is still third-party code. Base44 does not guarantee its quality, reliability, security, or compatibility, and its documentation leaves testing and validation to you.
The importer mirrors the entity
The nine Supplier fields become nine importer columns. Each column points to one field through its id, while title gives the buyer the name they see in the grid.
import { DataEditor, type DataEditorColumn, type DataEditorResult,} from "@updog/data-editor";import "@updog/data-editor/styles.css";
type SupplierRow = { supplierRef: string; supplierName: string; category: string; incoterm: string; leadTimeDays: string; approvedOn: string; status: string; contactName: string; contactEmail: string;};
const columns: DataEditorColumn[] = [ { id: "supplierRef", title: "Supplier ref", validators: [{ type: "required" }, { type: "unique" }], }, { id: "supplierName", title: "Supplier name", validators: [{ type: "required" }], }, { id: "category", title: "Category", editor: { type: "select", options: ["Packaging", "Logistics", "Raw materials", "Maintenance"], }, }, { id: "incoterm", title: "Incoterm", editor: { type: "select", options: ["EXW", "FOB", "DAP", "DDP"], }, }, { id: "leadTimeDays", title: "Lead time", editor: { type: "number" }, validators: [{ type: "number", min: 0, decimalPlaces: 0 }], }, { id: "approvedOn", title: "Approved", editor: { type: "date" }, }, { id: "status", title: "Status", editor: { type: "select", options: ["Approved", "Pending", "Suspended"], }, }, { id: "contactName", title: "Contact", }, { id: "contactEmail", title: "Contact email", validators: [{ type: "email" }], },];Most of these rules repeat constraints the app already knows. Base44 entity schemas support rules such as minLength, pattern, format, enum, minimum and maximum. Its documented schema options do not include a uniqueness constraint. Every record does receive a unique Base44 id, but that does not stop two Supplier records from carrying the same supplier_ref.
That check therefore happens before the import reaches the entity. { type: "unique" } on supplierRef flags a reference that already belongs to another supplier. It runs after the other validators on the column have passed.
The mapper crosses the naming seam
The editor and the entity use different names for the same fields. An editor row uses camelCase keys such as supplierRef and leadTimeDays. The Base44 entity stores supplier_ref and lead_time_days. Number fields also leave the editor as text.
One small mapper keeps those conversions in one place.
const toSupplierRecord = (row: SupplierRow) => ({ supplier_ref: row.supplierRef, supplier_name: row.supplierName, category: row.category, incoterm: row.incoterm, lead_time_days: Number(row.leadTimeDays), approved_on: row.approvedOn, status: row.status, contact_name: row.contactName, contact_email: row.contactEmail,});It turns an edited row into the shape Base44 expects, and lead_time_days passes through Number on the way. Nothing outside that function needs to know how the entity names its fields.
The page mounts the importer
primaryKey="supplierRef" tells the editor which column identifies a supplier when an imported row meets a row the grid already holds. enableDeleteRow lets the buyer remove a supplier, which gives the submit handler a deleted row to route back to Base44.
<DataEditor<SupplierRow> apiKey="updog-base44-demo" variant="uploader" open={open} onClose={() => { setOpen(false); }} columns={columns} primaryKey="supplierRef" enableDeleteRow="all" onComplete={handleComplete}/>The first prompt gave the app one primary color, #1f6f5c. One CSS variable carries it into the editor's buttons, selected cells, wizard cards and matched values.
/* loaded after @updog/data-editor/styles.css */:root { --updog-brand: #1f6f5c;}The file uses somebody else's vocabulary
The buyer exports 640 approved vendors from their old purchasing system in one Excel sheet.
| A | B | C | D | E | F | G | H | I | |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Vendor code | Vendor name | Spend category | Incoterm | Lead time (days) | Approved on | Status | Buyer contact | |
| 2 | HW-0001 | Ambleforth Cartons Ltd | Packaging | DDP | 12 | 2024-03-26 | Pending | Anselm Ambleside | [email protected] |
| 3 | HW-0002 | Barrowden Couriers Oy | Logistics | DAP | 31 | 2025-05-21 | Approved | Deryn Lockhart | [email protected] |
| 4 | HW-0003 | Cheswick Glassworks SRL | Raw materials | DAP | 46 | 2024-09-26 | Approved | Gwilym Gadsby | [email protected] |
| 4 rows not shown | |||||||||
| 9 | HW-0008 | Hartshorne Polymers Oy | Raw materials | FOB | 37 | 2024-09-19 | Approved | Ffion Nettleford | [email protected] |
| 6 rows not shown | |||||||||
| 16 | HW-0015 | Oswaldkirk Filters SRL | Maintenance | DDP | 15 | 2024-07-21 | Approved | Kester Kinnaird | [email protected] |
| 84 rows not shown | |||||||||
| 101 | HW-0100 | Vaynor Timber BV | Raw materials | DAP | 58 | 2025-03-12 | Pending | Jolanta Barrowclough | [email protected] |
| 216 rows not shown | |||||||||
| 318 | HW-0317 | Edgeworth Pigments Group | Raw materials | DAP | 50 | 2025-08-06 | Suspended | Eamon Elverton | [email protected] |
| 322 rows not shown | |||||||||
| 641 | HW-0640 | Penrhos Timber BV | Raw materials | EXW | 35 | 2025-03-29 | Approved | Nessa Fanshawe | [email protected] |
1Vendor code,Vendor name,Spend category,Incoterm,Lead time (days),Approved on,Status,Buyer contact,E-mail2HW-0001,Ambleforth Cartons Ltd,Packaging,DDP,12,2024-03-26,Pending,Anselm Ambleside,[email protected]3HW-0002,Barrowden Couriers Oy,Logistics,DAP,31,2025-05-21,Approved,Deryn Lockhart,[email protected]4HW-0003,Cheswick Glassworks SRL,Raw materials,DAP,46,2024-09-26,Approved,Gwilym Gadsby,[email protected]⋮4 rows not shown9HW-0008,Hartshorne Polymers Oy,Raw materials,FOB,37,2024-09-19,Approved,Ffion Nettleford,[email protected]⋮6 rows not shown16HW-0015,Oswaldkirk Filters SRL,Maintenance,DDP,15,2024-07-21,Approved,Kester Kinnaird,[email protected]⋮84 rows not shown101HW-0100,Vaynor Timber BV,Raw materials,DAP,58,2025-03-12,Pending,Jolanta Barrowclough,[email protected]⋮216 rows not shown318HW-0317,Edgeworth Pigments Group,Raw materials,DAP,50,2025-08-06,Suspended,Eamon Elverton,[email protected]⋮322 rows not shown641HW-0640,Penrhos Timber BV,Raw materials,EXW,35,2025-03-29,Approved,Nessa Fanshawe,[email protected]Seven of the nine headers use names that do not appear in your Supplier schema.
Vendor code → Supplier refVendor name → Supplier nameSpend category → CategoryLead time (days) → Lead timeApproved on → ApprovedBuyer contact → ContactE-mail → Contact emailThe matcher resolves eight of the nine columns. Vendor code stays open because code and ref share no useful lexical match. The buyer maps it to Supplier ref from the dropdown, and the import reaches 9 of 9 columns.
Building a column mapping screen covers the matching state behind that step.
Spend category, Incoterm, and Status use closed lists. The importer matches the distinct values in all three columns against the options your schema allows. A value such as Raw mats has no accepted target, so it stays unresolved until the buyer maps it.
Mapping values onto a fixed list shows how those value matches are scored.
This export is internally consistent, so the mapping screens do most of the work. A file that mixes date formats or introduces unknown values follows the same path, but leaves more decisions for the buyer before the rows reach the grid.
The grid then holds all 640 rows. The buyer can sort, filter to validation errors, edit a cell, undo a change, or paste a block from a spreadsheet. Submit shows the final row counts before the import is written.
Submit returns the rows and their state
Submit returns the edited rows grouped by source. Each row carries four independent flags that tell your handler whether it is new, changed, deleted, and valid.
const handleComplete = async (result: DataEditorResult<SupplierRow>) => { const rows = result.sources.flatMap((source) => source.rows);
const inserts = rows .filter((r) => r.isValid && r.isNew && !r.isDeleted) .map((r) => toSupplierRecord(r.row));
const updates = rows .filter((r) => r.isValid && r.isChanged && !r.isNew && !r.isDeleted) .map((r) => toSupplierRecord(r.row));
const deletes = rows .filter((r) => r.isDeleted && !r.isNew) .map((r) => r.row.supplierRef);
await writeSuppliers({ inserts, updates, deletes }); setOpen(false);};toSupplierRecord is the mapper defined earlier. leadTimeDays leaves the editor as "12" and passes through Number before Base44 receives the integer. approvedOn leaves as "2024-03-26", independent of how the date appeared in the grid.
This workbook contributes one source with 640 rows. The result looks like this, with one of those rows expanded.
{ sources: [ { sourceId: "source_…", sourceName: "harrowden-suppliers.xlsx - Suppliers", rows: [ { row: { supplierRef: "HW-0001", supplierName: "Ambleforth Cartons Ltd", category: "Packaging", incoterm: "DDP", leadTimeDays: "12", approvedOn: "2024-03-26", status: "Pending", contactName: "Anselm Ambleside", }, isNew: true, isChanged: false, isDeleted: false, isValid: true, }, // 639 more rows ], }, ], counts: { new: 640, changed: 0, deleted: 0, invalid: 0 }, learnedSynonyms: { columns: [], values: [] },}The write into a Base44 entity
Base44 gives every entity a bulkCreate method that accepts an array of records. New rows can go straight through it.
Changed rows need one more step. The entity SDK documents create, bulkCreate, update, updateMany, bulkUpdate, delete, and deleteMany, but no upsert. bulkUpdate needs the id Base44 generated, while the file carries supplier_ref. The handler therefore looks up the stored suppliers by reference, builds a map from supplier_ref to id, and attaches those ids before the update. Deletions need no map because deleteMany accepts a query.
Import, edit and delete rows through an API follows the same routing against an endpoint you own.
import { base44 } from "@/api/base44Client";
const BATCH = 500;
const batches = (items, size) => { const out = [];
for (let i = 0; i < items.length; i += size) { out.push(items.slice(i, i + size)); }
return out;};
export async function writeSuppliers({ inserts, updates, deletes }) { for (const chunk of batches(inserts, BATCH)) { await base44.entities.Supplier.bulkCreate(chunk); }
if (updates.length > 0) { const idOf = new Map();
for (const chunk of batches( updates.map((record) => record.supplier_ref), BATCH, )) { const stored = await base44.entities.Supplier.filter({ supplier_ref: { $in: chunk }, });
for (const record of stored) { idOf.set(record.supplier_ref, record.id); } }
const edits = updates .filter((record) => idOf.has(record.supplier_ref)) .map((record) => ({ ...record, id: idOf.get(record.supplier_ref), }));
for (const chunk of batches(edits, BATCH)) { await base44.entities.Supplier.bulkUpdate(chunk); } }
for (const chunk of batches(deletes, BATCH)) { await base44.entities.Supplier.deleteMany({ supplier_ref: { $in: chunk }, }); }}Base44 caps bulkUpdate at 500 records per request, so the handler slices updates into batches of 500. The same batch size is used for creates and deletes here to keep the write path uniform. filter returns at most 5,000 records per request, so a larger lookup has to page through the matching suppliers.
The Supplier entity is empty on the first import. The editor also opens without stored rows, so all 640 vendors arrive with isNew: true. The handler routes them to bulkCreate, first 500 and then 140.
Send the same file again and Base44 gets another 640 records. The editor starts empty again, so the same vendors are still new from its point of view. They arrive with isNew: true, and the handler sends them through bulkCreate again. Base44 has no declared unique field on supplier_ref, and this handler performs no reconciliation for new rows, so nothing collapses the repeat.
That behavior is deliberate here. isNew, isChanged, isDeleted, and isValid describe what happened inside the editor. They do not decide what a row means in your database.
If a second import should update the existing suppliers, your app can choose that policy. Load the stored suppliers into the editor through loadData and matching on supplierRef can turn file rows into changes. Reconcile supplier_ref against Base44 before writing and the handler can choose create or update there instead. Updog Importer supplies the row state and leaves that decision to your application.
Failed writes follow the same boundary. Let the error escape from onComplete. A rejected promise keeps the grid, mappings, and buyer edits in place, while a resolved promise tells the importer that submission finished. Base44 requests that already succeeded are not rolled back, so the handler still owns any retry or partial-write policy. Close the modal only after the write path completes.
The Base44 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. .base44.app is on that list, so the URL Base44 gives the app can use the importer without adding a domain in the Updog console. The prompt can leave the placeholder API key in place while the app runs there.
A custom domain changes that. If the procurement 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.
Base44 treats the custom domain separately. Connecting one requires a paid plan, starting with Starter.
The app now has an import path
Updog Importer installs from the Base44 AI chat as an npm package. Its columns mirror the Supplier entity, onComplete returns the edited rows and their state, and your handler routes those rows into Base44 through the entity SDK.
The importer handles the file-facing work: parsing CSV and Excel, matching columns and values, validating cells, and letting the buyer correct the data before submit. Your application still decides what those rows mean when they reach Supplier, including how to treat 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 pieces inside the importer and leaves the Base44 code focused on the entity and the write path.