Data import is a common requirement for SaaS applications. Here is how to build a polished CSV import experience.
Step 1: Install Dependencies
pnpm add papaparse
pnpm add -D @types/papaparse
Step 2: CSV Parser Hook
// hooks/useCsvParser.ts
"use client";
import { useState, useCallback } from "react";
import Papa from "papaparse";
interface CsvData {
headers: string[];
rows: Record<string, string>[];
rowCount: number;
}
export function useCsvParser() {
const [data, setData] = useState<CsvData | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const parseFile = useCallback((file: File) => {
setIsLoading(true);
setError(null);
if (!file.name.endsWith(".csv")) {
setError("Please upload a CSV file");
setIsLoading(false);
return;
}
if (file.size > 10 * 1024 * 1024) {
setError("File size must be under 10MB");
setIsLoading(false);
return;
}
Papa.parse(file, {
header: true,
skipEmptyLines: true,
transformHeader: (header) => header.trim(),
complete: (results) => {
const headers = results.meta.fields ?? [];
const rows = results.data as Record<string, string>[];
setData({
headers,
rows,
rowCount: rows.length,
});
setIsLoading(false);
},
error: (err) => {
setError(`Parse error: ${err.message}`);
setIsLoading(false);
},
});
}, []);
const reset = useCallback(() => {
setData(null);
setError(null);
}, []);
return { data, error, isLoading, parseFile, reset };
}
Step 3: File Dropzone
"use client";
import { useState, useCallback } from "react";
import { Upload, FileText } from "lucide-react";
interface DropzoneProps {
onFile: (file: File) => void;
accept?: string;
}
export function Dropzone({ onFile, accept = ".csv" }: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) onFile(file);
},
[onFile]
);
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 transition-colors ${
isDragging
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
: "border-gray-300 dark:border-gray-700"
}`}
>
<Upload className="mb-4 h-10 w-10 text-gray-400" />
<p className="mb-2 text-sm font-medium">
Drag and drop your CSV file here
</p>
<p className="mb-4 text-xs text-gray-400">or</p>
<label className="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Browse Files
<input
type="file"
accept={accept}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFile(file);
}}
className="hidden"
/>
</label>
</div>
);
}
Step 4: Column Mapper
"use client";
interface ColumnMapperProps {
csvHeaders: string[];
targetFields: { key: string; label: string; required: boolean }[];
mapping: Record<string, string>;
onChange: (mapping: Record<string, string>) => void;
}
export function ColumnMapper({
csvHeaders,
targetFields,
mapping,
onChange,
}: ColumnMapperProps) {
function autoMap() {
const newMapping: Record<string, string> = {};
targetFields.forEach((field) => {
const match = csvHeaders.find(
(h) =>
h.toLowerCase() === field.key.toLowerCase() ||
h.toLowerCase() === field.label.toLowerCase() ||
h.toLowerCase().replace(/[_\s]/g, "") ===
field.key.toLowerCase().replace(/[_\s]/g, "")
);
if (match) newMapping[field.key] = match;
});
onChange(newMapping);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Map Columns</h3>
<button
onClick={autoMap}
className="text-xs text-blue-600 hover:underline"
>
Auto-detect
</button>
</div>
<div className="space-y-2">
{targetFields.map((field) => (
<div key={field.key} className="flex items-center gap-3">
<label className="w-40 text-sm">
{field.label}
{field.required && <span className="ml-1 text-red-500">*</span>}
</label>
<select
value={mapping[field.key] ?? ""}
onChange={(e) =>
onChange({ ...mapping, [field.key]: e.target.value })
}
className="flex-1 rounded-lg border px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="">-- Skip --</option>
{csvHeaders.map((header) => (
<option key={header} value={header}>
{header}
</option>
))}
</select>
</div>
))}
</div>
</div>
);
}
Step 5: Data Preview with Validation
"use client";
import { useMemo } from "react";
import { AlertCircle, CheckCircle } from "lucide-react";
interface DataPreviewProps {
rows: Record<string, string>[];
mapping: Record<string, string>;
validators?: Record<string, (value: string) => string | null>;
maxPreview?: number;
}
export function DataPreview({
rows,
mapping,
validators = {},
maxPreview = 10,
}: DataPreviewProps) {
const mappedFields = Object.entries(mapping).filter(([, v]) => v);
const validatedRows = useMemo(() => {
return rows.slice(0, maxPreview).map((row) => {
const errors: Record<string, string> = {};
mappedFields.forEach(([targetKey, csvHeader]) => {
const value = row[csvHeader] ?? "";
const validator = validators[targetKey];
if (validator) {
const err = validator(value);
if (err) errors[targetKey] = err;
}
});
return { row, errors, isValid: Object.keys(errors).length === 0 };
});
}, [rows, mapping, validators, maxPreview, mappedFields]);
const errorCount = validatedRows.filter((r) => !r.isValid).length;
const totalErrors = rows.length > maxPreview
? Math.round((errorCount / maxPreview) * rows.length)
: errorCount;
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
{errorCount === 0 ? (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span>All {rows.length} rows look good</span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-amber-500" />
<span>~{totalErrors} rows have issues (showing first {maxPreview})</span>
</>
)}
</div>
<div className="overflow-x-auto rounded-lg border dark:border-gray-700">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-3 py-2 text-xs font-medium text-gray-500">#</th>
{mappedFields.map(([key]) => (
<th key={key} className="px-3 py-2 text-xs font-medium text-gray-500">
{key}
</th>
))}
<th className="px-3 py-2 text-xs font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
{validatedRows.map(({ row, errors, isValid }, idx) => (
<tr key={idx} className={!isValid ? "bg-red-50 dark:bg-red-950/10" : ""}>
<td className="px-3 py-2 text-xs text-gray-400">{idx + 1}</td>
{mappedFields.map(([targetKey, csvHeader]) => (
<td
key={targetKey}
className={`px-3 py-2 ${errors[targetKey] ? "text-red-600" : ""}`}
title={errors[targetKey] ?? undefined}
>
{row[csvHeader] ?? "—"}
</td>
))}
<td className="px-3 py-2">
{isValid ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Step 6: Putting It Together
"use client";
import { useState } from "react";
import { useCsvParser } from "@/hooks/useCsvParser";
import { Dropzone } from "./Dropzone";
import { ColumnMapper } from "./ColumnMapper";
import { DataPreview } from "./DataPreview";
const TARGET_FIELDS = [
{ key: "name", label: "Full Name", required: true },
{ key: "email", label: "Email", required: true },
{ key: "phone", label: "Phone", required: false },
{ key: "company", label: "Company", required: false },
];
const VALIDATORS: Record<string, (v: string) => string | null> = {
name: (v) => (v.trim().length < 2 ? "Name too short" : null),
email: (v) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "Invalid email"),
};
export function CsvImporter() {
const { data, error, isLoading, parseFile, reset } = useCsvParser();
const [mapping, setMapping] = useState<Record<string, string>>({});
const [step, setStep] = useState<"upload" | "map" | "preview" | "complete">("upload");
if (step === "upload") {
return (
<Dropzone
onFile={(file) => {
parseFile(file);
setStep("map");
}}
/>
);
}
if (step === "map" && data) {
return (
<div className="space-y-6">
<ColumnMapper
csvHeaders={data.headers}
targetFields={TARGET_FIELDS}
mapping={mapping}
onChange={setMapping}
/>
<div className="flex justify-between">
<button onClick={() => { reset(); setStep("upload"); }} className="text-sm text-gray-500">
Back
</button>
<button
onClick={() => setStep("preview")}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white"
>
Preview Import
</button>
</div>
</div>
);
}
if (step === "preview" && data) {
return (
<div className="space-y-6">
<DataPreview rows={data.rows} mapping={mapping} validators={VALIDATORS} />
<div className="flex justify-between">
<button onClick={() => setStep("map")} className="text-sm text-gray-500">
Back
</button>
<button
onClick={() => {
// Submit to API here
setStep("complete");
}}
className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white"
>
Import {data.rowCount} Rows
</button>
</div>
</div>
);
}
return (
<div className="text-center">
<p className="text-lg font-semibold text-green-600">Import Complete</p>
<button onClick={() => { reset(); setStep("upload"); }} className="mt-4 text-sm text-blue-600">
Import Another File
</button>
</div>
);
}
Summary
- Multi-step wizard: upload, map columns, preview, import
- Auto-detect column mappings by name similarity
- Validate data before importing
- Handle large files with PapaParse streaming
Need Data Import Features?
We build data management tools with seamless import and export workflows. Contact us to discuss your project.