Import CSV & Excel Files in Next.js

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. Create a new page component:

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

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

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