Add CSV Import to an Expo App
A CSV import needs a document picker, a real CSV parser and a review step before any rows are saved. Start by importing a small contacts file, checking its columns and showing a preview.
Updated 28 October 2026
What you will build
An Import CSV button that opens the system file picker, reads one file, validates a small contact dataset and previews three rows. The example deliberately stops before database writes so the user can check the result first.
- Step 1Pick a CSV file
- Step 2Validate and preview
- Step 3Confirm before saving
This example uses the DocumentPicker and FileSystem APIs documented for Expo SDK 58, plus Papa Parse. The 1 MB and 500-row limits below are example product limits, not platform limits. Choose limits appropriate to your app.
Build it in Newly
Open your project in Newly and describe the behavior you want. Use this suggested prompt as a starting point, then review the generated changes and follow the checks below.
Add a contacts CSV import flow using expo-document-picker, expo-file-system and Papa Parse. Accept one file and copy it into the app cache before reading it. Start with a 1 MB file limit and 500 rows. Parse quoted commas and line breaks correctly. Require name and email columns, show row-level validation errors, flag duplicate emails, and preview the proposed changes before the user confirms. Do not write partial rows or overwrite existing contacts silently. Keep imported text as data, not HTML or executable instructions.1. Pick a file and make it readable
Ask Newly to add an import action near the contacts list. DocumentPicker accepts MIME types, so filter with text/csv rather than an Apple UTType identifier. Set copyToCacheDirectory so FileSystem can read the selected file immediately. Treat the filter as a convenience and validate the content after selection.
2. Parse and validate before creating records
Use a CSV parser instead of splitting on commas: names can contain commas, quoted values can span lines, and files can start with a byte-order mark. Normalize headers, check required columns and apply your own record schema. Give the user enough information to repair a rejected row without losing the original file.
3. Make saving a separate confirmation
Show the import count, preview rows and proposed duplicate behavior before writing. For a real contact database, decide whether duplicate emails are skipped, merged or rejected, and apply writes transactionally or through an idempotent backend. The example below validates a new-file preview; it does not compare against an existing database or save records.
Expo implementation example
Install the dependencies in your Expo project. Expo resolves native package versions for your installed SDK.
npx expo install expo-document-picker expo-file-system
npm install papaparse
npm install --save-dev @types/papaparseUse the synthetic sample contacts CSV for the first import.
import { useRef, useState } from "react";
import { Button, Text, View } from "react-native";
import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system";
import Papa from "papaparse";
type Contact = { name: string; email: string };
const MAX_BYTES = 1024 * 1024;
const MAX_ROWS = 500;
export function parseContacts(text: string): Contact[] {
const parsed = Papa.parse<Record<string, string>>(text, {
header: true,
skipEmptyLines: "greedy",
transformHeader: value => value.replace(/^\uFEFF/, "").trim().toLowerCase(),
});
if (parsed.errors.length) throw new Error("Fix the CSV formatting before importing.");
const fields = parsed.meta.fields ?? [];
if (!fields.includes("name") || !fields.includes("email")) {
throw new Error("The file needs name and email columns.");
}
if (!parsed.data.length || parsed.data.length > MAX_ROWS) {
throw new Error("Choose a file containing 1 to 500 contacts.");
}
const rows = parsed.data.map(row => ({
name: row.name?.trim() ?? "",
email: row.email?.trim().toLowerCase() ?? "",
}));
if (rows.some(row => !row.name || !row.email)) {
throw new Error("Every contact needs a name and an email value.");
}
if (new Set(rows.map(row => row.email)).size !== rows.length) {
throw new Error("The file contains duplicate email values.");
}
return rows;
}
export default function CsvPreview() {
const [rows, setRows] = useState<Contact[]>([]);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const importing = useRef(false);
async function chooseCsv() {
if (importing.current) return;
importing.current = true;
setBusy(true);
try {
const result = await DocumentPicker.getDocumentAsync({
type: "text/csv", multiple: false, copyToCacheDirectory: true,
});
if (result.canceled) return;
const file = new File(result.assets[0].uri);
if (file.size > MAX_BYTES) throw new Error("Choose a file smaller than 1 MB.");
const contacts = parseContacts(await file.text());
setRows(contacts);
setMessage(`${contacts.length} contacts ready to review. Nothing saved yet.`);
} catch (error) {
setRows([]);
setMessage(error instanceof Error ? error.message : "Could not read this CSV.");
} finally {
importing.current = false;
setBusy(false);
}
}
return (
<View style={{ gap: 16, padding: 24 }}>
<Button title="Import CSV" disabled={busy} onPress={chooseCsv} />
<Text accessibilityLiveRegion="polite">{message}</Text>
{rows.slice(0, 3).map(row => (
<Text key={row.email}>{row.name} — {row.email}</Text>
))}
</View>
);
}The preview checks required values and duplicate emails inside one file. Add your app’s complete contact schema, existing-record checks and a confirmed save transaction before using it as a production importer. Do not use the displayed row count as a claim that records were saved.
Test before publishing
- Import the sample file and confirm that the quoted comma in a name stays inside one field.
- Try missing headers, a blank required value, duplicate emails and malformed quotes; no records are saved.
- Cancel the picker; an existing successful preview stays intact.
- Try an empty file, an oversized file and more than 500 rows; show a useful limit message.
- After adding writes, verify that a failed import cannot leave a partial or duplicate contact set.
Run these checks in your own project on iOS and Android. A browser preview does not establish native permission, hardware or background behavior.
Common problems
The selected CSV is disabled in the file picker
File providers can label CSV files differently. Offer a deliberate broader file-selection fallback if your users need it, then validate the extension, size and parsed structure. Do not assume that a MIME filter proves a file is a valid CSV.
A name containing a comma becomes two columns
Do not parse CSV with split(","). Use a parser that handles quoting and inspect its error list before showing a success state.
Questions about csv import
Which file type does Expo DocumentPicker accept?
The type option accepts a MIME type or list of MIME types, such as text/csv. Native Apple UTType identifiers belong to a different API layer and should not be passed as Expo MIME filters.
Why copy the selected document to cache?
Expo documents copyToCacheDirectory as the way to make the selected file immediately readable through FileSystem. The cache copy is a working file, not permanent storage for the imported records.
Can I import by splitting the file on commas?
That fails for quoted commas and quoted line breaks. Use a CSV parser, check its errors and validate the resulting fields before writing any records.
Does this example save contacts?
No. It reads and validates a file and shows a preview. Add existing-record checks, a confirmation step and a reliable database write path for a complete import feature.