Import CSV & Excel Files in React

A step-by-step guide to building a production-ready CSV importer that handles real-world data

Whether you're migrating from a legacy system, onboarding enterprise customers with existing data, modernizing an internal tool, or building a SaaS product data import is unavoidable. And it's always harder than it looks.

Here's what every import flow deals with:

  • Files arrive with inconsistent column names ("Email" vs "email" vs "E-mail Address")
  • Data is messy (extra spaces, inconsistent capitalization, malformed values)
  • You need validation before import (or deal with broken data in production)
  • Edge cases multiply (Excel vs CSV, different date formats, special characters, encoding issues)

Building this from scratch takes weeks of edge case handling. Let's do it in 5 minutes instead.

What we're building

By the end of this tutorial, you'll have:

  • ✅ File upload for CSV and Excel
  • ✅ Smart column mapping (with AI assistance)
  • ✅ Automatic data cleanup
  • ✅ Real-time validation with user feedback
  • ✅ Integration with your existing API

See it in action: Live demo on StackBlitz →

Step 1: Install importOK

1npm install @importok/javascript @importok/react

Step 2: Create your first importer

Let's say you're building a CRM and need to import contacts. Add the import wizard to your component:

1import ImportokWizard from '@importok/react';
2 
3export default function ImportContacts() {
4 const fields = {
5 first_name: { label: 'First Name' },
6 last_name: { label: 'Last Name' },
7 email: { label: 'Email' },
8 phone: { label: 'Phone' },
9 country: { label: 'Country' }
10 };
11 
12 return (
13 <div className="container mx-auto p-8">
14 <h1 className="text-2xl font-bold mb-4">Import Contacts</h1>
15 <ImportokWizard
16 title="Upload your contact list"
17 fields={fields}
18 />
19 </div>
20 );
21}

What just happened?

  • You defined your data model with fields
  • The wizard automatically handles file upload, parsing, and column mapping
  • Users can map "First Name" → "first_name" even if their CSV says "Given Name"

Column mapping demo

Step 3: Handle messy data

Real-world CSV files are never clean. Here's what you typically see:

1First Name,Last Name,Email
2 John , smith ,[email protected]
3mary,JONES ,[email protected]

Notice the problems? Extra spaces, inconsistent capitalization, uppercase emails. Let's fix this automatically with transformers:

1const fields = {
2 first_name: {
3 label: 'First Name',
4 transformers: 'trim|capitalize'
5 },
6 last_name: {
7 label: 'Last Name',
8 transformers: 'trim|capitalize'
9 },
10 email: {
11 label: 'Email',
12 transformers: 'trim|lowercase' // Emails should be lowercase
13 },
14 phone: {
15 label: 'Phone',
16 transformers: 'trim'
17 },
18 country: {
19 label: 'Country',
20 transformers: 'trim'
21 }
22};

Now that messy data becomes:

1John,Smith,[email protected]
2Mary,Jones,[email protected]

How it works: Transformers run immediately after file upload, before users see the data. Users can still edit if needed, but most cleanup happens automatically.

See all built-in transformers →

Step 4: Add validation rules

Cleanup helps, but you still need to catch invalid data. Let's add validators:

1const fields = {
2 first_name: {
3 label: 'First Name',
4 transformers: 'trim|capitalize'
5 },
6 last_name: {
7 label: 'Last Name',
8 transformers: 'trim|capitalize',
9 validators: 'required' // Can't be empty
10 },
11 email: {
12 label: 'Email',
13 transformers: 'trim|lowercase',
14 validators: 'email|required' // Must be valid email format
15 },
16 phone: {
17 label: 'Phone',
18 transformers: 'trim',
19 validators: 'phone' // Optional but must be valid if provided
20 },
21 country: {
22 label: 'Country',
23 transformers: 'trim',
24 validators: 'in:countries' // Must be a valid country
25 }
26};

The user experience:

  • Invalid rows are highlighted in red
  • Users can filter by error type
  • They can fix errors inline without re-uploading
  • Only valid data reaches your API

Validation demo

See all built-in validators →

Step 5: Send data to your API

Now let's actually import the data. You have two options:

Option A: Batch import

Send all records at once. Best for most use cases:

1export default function ImportContacts() {
2 const fields = { /* ... */ };
3 
4 const handleBatchImport = async (records, meta) => {
5 try {
6 const response = await fetch('/api/contacts/import', {
7 method: 'POST',
8 headers: { 'Content-Type': 'application/json' },
9 body: JSON.stringify({
10 contacts: records,
11 total: meta.totalRecords
12 })
13 });
14 
15 if (!response.ok) {
16 throw new Error('Import failed');
17 }
18 
19 // Show success message
20 return response;
21 } catch (error) {
22 console.error('Import error:', error);
23 throw error;
24 }
25 };
26 
27 return (
28 <ImportokWizard
29 title="Upload your contact list"
30 fields={fields}
31 onImportReady={handleBatchImport}
32 />
33 );
34}

Option B: Individual records

Send records one at a time. Better for long-running imports or when you need to show progress:

1const handleRecordImport = async (record, meta) => {
2 const response = await fetch('/api/contacts', {
3 method: 'POST',
4 headers: { 'Content-Type': 'application/json' },
5 body: JSON.stringify(record)
6 });
7 
8 return response;
9};
10 
11return (
12 <ImportokWizard
13 title="Upload your contact list"
14 fields={fields}
15 onRecordReady={handleRecordImport}
16 />
17);

Real-world example: CRM contact import

Let's put it all together with a complete example:

1import ImportokWizard from '@importok/react';
2import { useState } from 'react';
3 
4export default function ImportContacts() {
5 const fields = {
6 first_name: {
7 label: 'First Name',
8 transformers: 'trim|capitalize',
9 validators: 'required'
10 },
11 last_name: {
12 label: 'Last Name',
13 transformers: 'trim|capitalize',
14 validators: 'required'
15 },
16 email: {
17 label: 'Email',
18 transformers: 'trim|lowercase',
19 validators: 'email|required'
20 },
21 phone: {
22 label: 'Phone',
23 transformers: 'trim'
24 },
25 company: {
26 label: 'Company',
27 transformers: 'trim'
28 },
29 country: {
30 label: 'Country',
31 transformers: 'trim',
32 validators: 'in:countries'
33 }
34 };
35 
36 const handleImport = async (records, meta) => {
37 
38 try {
39 const response = await fetch('/api/contacts/import', {
40 method: 'POST',
41 headers: { 'Content-Type': 'application/json' },
42 body: JSON.stringify({ contacts: records })
43 });
44 
45 const data = await response.json();
46 
47 return response;
48 } catch (error) {
49 throw error;
50 }
51 };
52 
53 return (
54 <div className="container mx-auto p-8">
55 <h1 className="text-2xl font-bold mb-4">Import Contacts</h1>
56 
57 <ImportokWizard
58 title="Upload your contact list"
59 fields={fields}
60 onImportReady={handleImport}
61 />
62 </div>
63 );
64}

Going further

Custom validation rules

Need business-specific validation? Create custom validators:

1const customValidators = {
2 // Validate phone numbers match your format
3 phone: (value) => {
4 if (!/^\+1-\d{3}-\d{3}-\d{4}$/.test(value)) {
5 return 'Phone must be in format: +1-555-123-4567';
6 }
7 
8 return true;
9 },
10 
11 // Check against your database
12 email_not_exists: async (value) => {
13 const response = await fetch(`/api/contacts?email=${value}`);
14 const { exists } = await response.json();
15 if (exists) {
16 return 'This email already exists in your CRM';
17 }
18 
19 return true;
20 }
21};
22 
23const fields = {
24 email: {
25 label: 'Email',
26 transformers: 'trim|lowercase',
27 validators: 'email|required|email_not_exists'
28 },
29 phone: {
30 label: 'Phone',
31 transformers: 'trim',
32 validators: 'phone'
33 }
34};
35 
36<ImportokWizard
37 fields={fields}
38 validators={customValidators}
39 onImportReady={handleImport}
40/>

Learn more about custom validators →

Custom transformations

Need domain-specific data cleanup? Create custom transformers:

1const customTransformers = {
2 // Standardize company names
3 company: (value) => {
4 return value
5 .replace(/\bInc\b/gi, 'Inc.')
6 .replace(/\bLLC\b/gi, 'LLC')
7 .replace(/\bLtd\b/gi, 'Ltd.');
8 }
9};
10 
11const fields = {
12 company: {
13 label: 'Company',
14 transformers: 'trim|company'
15 }
16};
17 
18<ImportokWizard
19 fields={fields}
20 transformers={customTransformers}
21 onImportReady={handleImport}
22/>

Learn more about custom transformers →

Connect to your database

Use data providers to populate dropdowns from your API:

1const dataProviders = {
2 // Load sales reps from your database
3 sales_reps: {
4 // Get a single sales representative ID
5 get: async function (query) {
6 const response = await fetch('/api/sales-reps/' + query + ');
7 if (!response.ok) {
8 throw new Error(`Response status ${response.status}`);
9 }
10 
11 const data = await response.json();
12 
13 return {
14 value: data.id,
15 label: data.name
16 };
17 },
18 // Find multiple sales representatives matching the provided name
19 find: async function (query) {
20 const response = await fetch('/api/sales-reps?name=' + query + ');
21 if (!response.ok) {
22 throw new Error(`Response status ${response.status}`);
23 }
24 
25 const data = await response.json();
26 return data.map(item => ({
27 value: item.id,
28 label: item.name
29 }));
30 },
31 },
32};
33 
34const fields = {
35 // ... other fields
36 assigned_to: {
37 label: 'Assigned To',
38 validators: 'required|in:sales_reps'
39 },
40};
41 
42<ImportokWizard
43 fields={fields}
44 providers={dataProviders}
45 onImportReady={handleImport}
46/>

Learn more about data providers →

Next steps

You now have a production-ready CSV importer. Here's what to explore next:

Free trial limitations

The trial version is fully functional with these limits:

  • ✅ Full UI and mapping features
  • ✅ Built-in validators and transformers
  • ⚠️ 20 records per import (unlimited on paid plans)
  • ⚠️ Up to 2 custom validators (unlimited on paid plans)
  • ⚠️ Up to 2 custom transformers (unlimited on paid plans)
  • ⚠️ Up to 2 data providers (unlimited on paid plans)

See pricing and upgrade options →

The easiest way to import data

Ready for your app

Get importOK

Free trial available

Start typing to search documentation and articles...

⌘K or Ctrl+K to open search

No results found for ""

Try different keywords or check your spelling.

Use ↑ ↓ arrow keys to navigate and Enter to select