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 <ImportokWizard20 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"

Step 3: Handle messy data
Real-world CSV files are never clean. Here's what you typically see:
1First Name,Last Name,Email2 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 lowercase13 },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 empty10 },11 email: {12 label: 'Email',13 transformers: 'trim|lowercase',14 validators: 'email|required' // Must be valid email format15 },16 phone: {17 label: 'Phone',18 transformers: 'trim',19 validators: 'phone' // Optional but must be valid if provided20 },21 country: {22 label: 'Country',23 transformers: 'trim',24 validators: 'in:countries' // Must be a valid country25 }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

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.totalRecords12 })13 });14 15 if (!response.ok) {16 throw new Error('Import failed');17 }18 19 // Show success message20 return response;21 } catch (error) {22 console.error('Import error:', error);23 throw error;24 }25 };26 27 return (28 <ImportokWizard29 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 <ImportokWizard13 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 <ImportokWizard62 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 database12 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<ImportokWizard37 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<ImportokWizard19 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.name16 };17 },18 // Find multiple sales representatives matching the provided name19 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.name29 }));30 },31 },32};33 34const fields = {35 // ... other fields36 assigned_to: {37 label: 'Assigned To',38 validators: 'required|in:sales_reps'39 },40};41 42<ImportokWizard43 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:
- Field configuration - Advanced field options
- Component props and events - Customize behavior
- Styling and branding - Match your design system
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)