Back to all postsA continuous brick-orange felt shape with three rounded square lobes on a cream felt backing

Add CSV and Excel Import to a Replit App

Replit turns a prompt into a working app and publishes it from the same project. The Project Editor runs the app behind a development URL, and Publishing puts the result on a replit.app address.

An import in that project ends at a function you wrote. The browser hands it the corrected rows, and the body of that function is the only place the roll is stored.

Once that app has users, their records already sit somewhere else. A school office keeps its roll in a spreadsheet exported from whichever system it ran before, and those rows belong on your students page.

Updog Importer adds that path. The example runs on a students page backed by an empty roll.

The Replit Agent can build that import screen, or it can install one.

Replit already builds an app out of a spreadsheet

Open replit.com/import, pick Spreadsheet, and hand over an .xlsx file, a .csv file or a public Google Sheets URL. Agent reads the structure and builds an app with a database seeded from that data, along with a screen for viewing, searching and managing the records.

That serves the person building the app.

A customer import runs the other way round. The school office opens the finished students page with a file you have never seen, reads it, corrects it, and sends you what they accept.

Importing a spreadsheet on replit.com/import happens while you build the product. The import screen below ships inside it.

The project already has a students page

One prompt to Agent produced the starting app.

Build a small school roll app with one page, on Vite, React and TypeScript.
A student carries these seven fields.
student_ref text, the key
first_name text
last_name text
date_of_birth a date written YYYY-MM-DD
year_group text
house text
guardian_email text
Put the roll behind one module, src/lib/roll.ts, holding an array in memory and
exporting two functions.
loadStudents() returns every student
saveStudents(students) stores each one under its student_ref, replacing a
student already held
Render /students as a page that reads the roll through loadStudents and shows
those seven fields in a table, with the roll count above it and an empty state
while the roll holds nothing. Redirect / to /students.
Have the dev server listen on 0.0.0.0 and on the port Replit gives it.
Keep the styling plain, and give the app one primary color, #2c5f5d. No
authentication, no database, no seed data, no extra pages.

Agent scaffolded a Vite, React and TypeScript page, wrote the roll module behind those two functions, and bound the dev server to 0.0.0.0 and the port Replit gives it. The page rendered an empty roll.

saveStudents holds the roll in memory here, so a reload empties it. Filling that body with a Replit database, or with anything else the project can reach, changes the module and leaves the page and the import screen as they are.

Those seven field names carry through the whole example. The importer's column ids use the same snake_case names as the roll module, so a row the editor returns already has the shape saveStudents expects.

Everything below builds on that project and on one file the project has never seen.

The file brings its own schema

The office's file follows a schema your app does not control. Its headers spell the old system's names. Its dates may run in another order, and its values may sit outside the options your fields allow.

The screen has to Because an export
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 year group
settle the date order per column writes 04/11/2015 and means the fourth of November
keep leading zeros holds 00417, and a plain reader makes it 417
match headers onto your fields says DOB where your app says date_of_birth
match values onto your options says Yr 9 where your app says Year 9
check and let the person fix holds a row saveStudents will reject
stay usable at scale is a hundred thousand rows on a laptop
report new and changed rows is the second upload of a roll that landed once already

Every line in that table turns into a rule somebody has to write. The prompt below opens on the field list, then walks the import stage by stage.

Build a CSV and Excel import screen for the students page. A button on that
page opens it as a modal wizard, the school office walks that wizard with the
export their old system produced, and it leaves them in a spreadsheet where
they correct what came in.
Where the fields come from
- Read the field list from my code, so the same screen serves the students page
today and a staff page later.
- Support text, dates and a fixed list of options, and let one field carry
several rules at once.
- Check required, a value from a list, and uniqueness inside the file.
- Show the office our label for a field, and hand my code back the field name.
- Take student_ref as the key, so a second upload of the same roll finds those
students again.
- Drop columns the file carries beyond that list.
Opening the file
- Take a file by drop or by dialog, and show a card for it.
- Detect the encoding, strip a byte order mark, and read a Windows-1252 export
without turning accented surnames 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
office 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/2015 does not become April.
- Keep 00417 as text, including a reference 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 office 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 office tells two similar columns
apart.
- Remember the pairs the office confirmed, so the next roll of that shape
arrives matched.
Matching the values
- Collect the distinct values of year group and house as the file spells them.
- Match each one to an option we allow, and let the office place the rest.
Correcting before the handover
- Hand the finished file to a spreadsheet the office works in, with my labels
on top and every row in it.
- Mark what the office changed. A new row and an edited cell read differently
at a glance.
- Run every rule the field list carries, and say which cell failed and why.
- Let the office fix a cell in place, with the editor that fits the field. A
date opens a calendar, a list opens its options.
- Sort and filter, so the office reaches the failing rows among the hundreds
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 and which changed.
- Keep every row and every mapping on screen when my write fails.

Prompt that screen into existence when the columns never move, when you made the file yourself, and when the roll arrives once a term. The rules above stop being optional once the files come from people you have never met.

Build the importer yourself and every rule above turns into code on your side, which then has to survive whatever the office exports next term.

Build or buy a CSV importer puts a number on what owning that code costs over a few years.

The other path hands every one of those rules to a package.

The second prompt installs the importer

Replit installs an npm package from npm install in the Shell, from upm add, by detecting a missing dependency when you press Run, or by asking Agent for the package by name. The prompt below asks Agent, and it names the package once.

Add a spreadsheet import screen to the students 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 students" 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-replit-demo", which is all the key
a replit.dev or a replit.app URL needs, variant="uploader",
primaryKey="student_ref" and these seven columns, written as id, title, then
type and rules:
student_ref "Student ref" text, required, unique
first_name "First name" text, required
last_name "Last name" text, required
date_of_birth "Date of birth" date, required
year_group "Year group" select: Year 7, Year 8, Year 9, Year 10, Year 11,
required, closed list
house "House" select: Ashdown, Bramley, Cransley, Denholm, closed list
guardian_email "Guardian email" text, email
The ids are the roll module's own field names on purpose, so no field is
renamed between the editor and saveStudents.
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 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 pass the rows to saveStudents as
they are. Write no mapper.
7. Throw from onComplete when saveStudents throws. 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 students list.
8. Do not build an uploader of your own, do not parse the file yourself, and do
not invent props.

Agent read updog.md, then the documentation site, then the type definitions inside the installed package, and wrote the screen.

A package is still code you did not write. Test what it does to a real export before the office ever sees it.

The importer mirrors the roll module

The seven fields of a student become seven importer columns. Each column points at one field through its id, and title gives the office the name shown in the grid.

import {
DataEditor,
type DataEditorColumn,
type DataEditorResult,
} from "@updog/data-editor";
import "@updog/data-editor/styles.css";
type Student = {
student_ref: string;
first_name: string;
last_name: string;
date_of_birth: string;
year_group: string;
house: string;
guardian_email: string;
};
const columns: DataEditorColumn[] = [
{
id: "student_ref",
title: "Student ref",
validators: [{ type: "required" }, { type: "unique" }],
},
{
id: "first_name",
title: "First name",
validators: [{ type: "required" }],
},
{
id: "last_name",
title: "Last name",
validators: [{ type: "required" }],
},
{
id: "date_of_birth",
title: "Date of birth",
editor: { type: "date" },
validators: [{ type: "required" }],
},
{
id: "year_group",
title: "Year group",
editor: {
type: "select",
options: ["Year 7", "Year 8", "Year 9", "Year 10", "Year 11"],
enableCustomValue: false,
},
validators: [{ type: "required" }],
},
{
id: "house",
title: "House",
editor: {
type: "select",
options: ["Ashdown", "Bramley", "Cransley", "Denholm"],
enableCustomValue: false,
},
},
{
id: "guardian_email",
title: "Guardian email",
validators: [{ type: "email" }],
},
];

The validators run in the browser, before any row leaves it. { type: "unique" } flags every row that shares a reference with another row in the grid, which is the duplicate the office can still fix. { type: "required" } sits on five columns, and a row leaving one of them empty comes back isValid: false.

The two option lists stay closed. enableCustomValue: false stops the editor creating an option for a value nobody recognizes, so a year group the file made up reaches the office on the value step with nothing beside it, and a value left unplaced there never reaches the grid.

The page mounts the importer

The editor draws on canvas and needs the browser DOM. Nothing in the project above renders on the server, so the component mounts with no directive of its own.

export function StudentImporter({ onImported }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Import students
</button>
<DataEditor<Student>
apiKey="updog-replit-demo"
variant="uploader"
open={open}
onClose={() => {
setOpen(false);
}}
columns={columns}
primaryKey="student_ref"
onComplete={handleComplete}
/>
</>
);
}

variant="uploader" opens the file wizard first, and the prop defaults to "editor", which would open an empty grid instead. primaryKey names the column that identifies a student inside the editor, and the editor requires it.

The editor takes the app's primary color from a single variable.

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

Match the importer to your product reaches the fonts, the grid lines, the shadows and a dark theme.

The file uses the school's own words

The export carries 384 students out of the office's old system.

thornsett-academy.csv
ABCDEFG
1Student no.ForenameSurnameDOBYearSchool houseContact email
2TA-0001BilalFenwick2013-06-28Yr 9Bramley House[email protected]
3TA-0002MaeveLanyon2014-10-20Yr 7Cransley House[email protected]
4TA-0003NoorBrightwell2011-03-24Yr 11Denholm House[email protected]
5TA-0004ElifBrightwell2011-04-17Yr 11Ashdown House[email protected]
90 rows not shown
96TA-0095WillaGallacher2010-09-15Yr 11Ashdown House[email protected]
100 rows not shown
197TA-0196WillaGallacher2014-08-17Yr 8Denholm House[email protected]
114 rows not shown
312TA-0311HamzaIjaz2015-03-22Yr 7Bramley House[email protected]
72 rows not shown
385TA-0384RosalindZabala2014-12-15Yr 7Denholm House[email protected]
1Student no.,Forename,Surname,DOB,Year,School house,Contact email2TA-0001,Bilal,Fenwick,2013-06-28,Yr 9,Bramley House,[email protected]3TA-0002,Maeve,Lanyon,2014-10-20,Yr 7,Cransley House,[email protected]4TA-0003,Noor,Brightwell,2011-03-24,Yr 11,Denholm House,[email protected]5TA-0004,Elif,Brightwell,2011-04-17,Yr 11,Ashdown House,[email protected]90 rows not shown96TA-0095,Willa,Gallacher,2010-09-15,Yr 11,Ashdown House,[email protected]100 rows not shown197TA-0196,Willa,Gallacher,2014-08-17,Yr 8,Denholm House,[email protected]114 rows not shown312TA-0311,Hamza,Ijaz,2015-03-22,Yr 7,Bramley House,[email protected]72 rows not shown385TA-0384,Rosalind,Zabala,2014-12-15,Yr 7,Denholm House,[email protected]

Every one of the seven headers is written differently from the column it belongs to, and the matcher resolves all seven.

Student no. → Student ref 70 half the words shared
Forename → First name 90 synonym
Surname → Last name 90 synonym
DOB → Date of birth 90 synonym
Year → Year group 80 one string inside the other
School house → House 80 one string inside the other
Contact email → Guardian email 70 half the words shared

Forename, Surname and DOB reach their columns through the importer's built-in synonym table, and a synonym match scores 90. Year and School house score 80, where the shorter of the two normalized names runs to four characters or more and sits inside the longer. Student no. and Contact email score 70, where the two names share half their words.

The wizard reports 7/7 matched. Year group and House carry closed lists, and the value step settles those as 5/5 and 4/4, with nothing left to place by hand.

Building a column mapping screen picks up the case this file avoids, a header that scores under the threshold everywhere.

The grid then holds all 384 rows, every cell filled and no rule broken. Sorting, filtering, cell edits, undo and a paste out of Excel all work there before anybody submits. The confirmation dialog counts what is about to leave, and on this file it read 384 new rows will be created.

Submit returns the rows and their state

Submit returns the corrected rows grouped by source. Every row arrives inside a wrapper carrying isNew, isChanged, isDeleted and isValid, and the four move independently. Your handler decides what those states mean for the roll.

const handleComplete = async (result: DataEditorResult<Student>) => {
const students = result.sources
.flatMap((source) => source.rows)
.filter((entry) => entry.isValid)
.map((entry) => entry.row);
await saveStudents(students);
setOpen(false);
await onImported();
};

No mapper sits between the row and the call. The keys of entry.row are the roll module's own field names, so students reaches saveStudents as it is.

The values arrive canonical. date_of_birth leaves as ISO 2013-06-28 independent of how the date appeared in the grid. year_group and house carry the option the value step chose, so a file that wrote Yr 9 hands back Year 9.

onComplete receives this object, with one of the 384 rows kept.

{
sources: [
{
sourceId: "source_…",
sourceName: "thornsett-academy.csv",
rows: [
{
row: {
student_ref: "TA-0001",
first_name: "Bilal",
last_name: "Fenwick",
date_of_birth: "2013-06-28",
year_group: "Year 9",
house: "Bramley",
guardian_email: "[email protected]",
},
isNew: true,
isChanged: false,
isDeleted: false,
isValid: true,
},
// 383 more rows
],
},
],
counts: { new: 384, changed: 0, deleted: 0, invalid: 0 },
learnedSynonyms: { columns: [], values: [] },
}

The object names no table and no statement. Reading those flags into a write is the handler's job, and this one passes every valid row on.

Import, edit and delete rows through a REST API routes those flags against a backend that answers over HTTP, and it covers the deletion path this page leaves out.

The write goes to a function you own

saveStudents is the seam between the importer and wherever the roll ends up. It takes the rows and answers with nothing, and Updog Importer never learns what sits behind it.

// src/lib/roll.ts
const roll = new Map<string, Student>();
export const saveStudents = async (students: Student[]) => {
for (const student of students) {
roll.set(student.student_ref, student);
}
};
export const loadStudents = async () => {
return [...roll.values()];
};

A Map over student_ref is the whole store here, and it lasts as long as the tab. Every row the grid held reached the roll page through it. Point that body at a Replit database, at a REST call, or at a file, and every section above stays as it is.

Editor state and storage state answer different questions. This run flags all 384 rows isNew: true, because nothing sat in the grid when it opened. isNew means new to the editor, and saveStudents still decides what to do with a student_ref it already holds.

Let a failed write reject onComplete. A rejection leaves every row on screen, and it takes back nothing saveStudents already stored. Resolving tells Updog Importer the submission finished, so wait for a successful write before closing the modal and reloading the roll.

The Replit URL runs for free

The editor asks its license endpoint when it starts. That request carries the API key, and the browser puts the page's origin on it. Nothing out of the file travels with it.

Updog holds a list of development and preview hosts, and a page served from one of them needs no paid production domain. Both Replit hosts sit on that list, .replit.dev for the development URL and .replit.app for the published app. While the app lives on either one, the placeholder key in the prompt is enough.

Replit writes the development URL as UUID.servername.replit.dev, it stays public to the web by default, and it can change each time you reopen the app. The editor opened on that host and on the published replit.app one, both on the placeholder key.

A custom domain changes that on both sides. Replit sells custom domains on its Autoscale, Reserved VM and Static deployments, and a production hostname needs a slot at console.updog.tech. New accounts get 14 days free with no credit card. A production domain then costs $19 a month, and no charge follows the rows or the imports.

The roll page now has an import path

Updog Importer installs into a Replit project as an npm package. Its columns spell the roll module's own field names, the office settles the file before submit, and onComplete hands the corrected rows and their state to a handler you own.

Parsing CSV and Excel, matching columns and values, checking cells and letting somebody correct them all stay inside the importer. Your project still owns saveStudents, the storage behind it, and what a repeat upload means there.

The first prompt above spells out, rule by rule, what building this flow yourself would ask for. Install the package and those rules stay in the package, while the Replit project keeps the students page and the write path behind it.