Blog
January 11, 2026

Building a CSV upload component in Next.js

12 mins read

Building a CSV upload component in Next.js

CSV uploads are a common requirement for web applications. Whether you're building an admin dashboard, a data import tool, or a bulk user registration feature, you need a way to accept CSV files from users and process their data.

This tutorial walks through building a production-ready CSV upload component in Next.js. You'll learn both client-side and server-side approaches, complete with TypeScript types, error handling, and validation.

Prerequisites

Before starting, make sure you have:

  • Next.js 14+ with App Router
  • Node.js 18+
  • Basic React and TypeScript knowledge

What you'll build

By the end of this tutorial, you'll have a reusable CSV upload component that:

  • Accepts files via drag-and-drop or click-to-upload
  • Parses CSV data with automatic header detection
  • Displays parsed data in a table preview
  • Handles errors and provides user feedback
  • Validates file type and size

Step 1: Install dependencies

First, install the two libraries we'll use:

npm install papaparse react-dropzone
npm install -D @types/papaparse

papaparse (v5.5.3) handles CSV parsing with support for headers, streaming, and automatic delimiter detection. It's the most popular JavaScript CSV parser with millions of weekly downloads and zero dependencies.

react-dropzone (v14.3.8) provides drag-and-drop file uploads with built-in TypeScript support. It requires React 16.8+ and is one of the most popular file upload libraries for React.

Step 2: Create the dropzone component

Create a new file at components/CsvDropzone.tsx:

"use client";

import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";

interface CsvDropzoneProps {
  onFileAccepted: (file: File) => void;
  maxSize?: number;
}

export function CsvDropzone({
  onFileAccepted,
  maxSize = 10 * 1024 * 1024 // 10MB default
}: CsvDropzoneProps) {
  const [error, setError] = useState<string | null>(null);

  const onDrop = useCallback(
    (acceptedFiles: File[], rejectedFiles: any[]) => {
      setError(null);

      if (rejectedFiles.length > 0) {
        const rejection = rejectedFiles[0];
        if (rejection.errors[0]?.code === "file-too-large") {
          setError(`File too large. Maximum size is ${maxSize / 1024 / 1024}MB`);
        } else if (rejection.errors[0]?.code === "file-invalid-type") {
          setError("Please upload a CSV file");
        }
        return;
      }

      if (acceptedFiles.length > 0) {
        onFileAccepted(acceptedFiles[0]);
      }
    },
    [onFileAccepted, maxSize]
  );

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: { "text/csv": [".csv"] },
    maxSize,
    multiple: false,
  });

  return (
    <div>
      <div
        {...getRootProps()}
        style={{
          border: "2px dashed #ccc",
          borderRadius: "8px",
          padding: "40px",
          textAlign: "center",
          cursor: "pointer",
          backgroundColor: isDragActive ? "#f0f9ff" : "#fafafa",
        }}
      >
        <input {...getInputProps()} />
        {isDragActive ? (
          <p>Drop your CSV file here...</p>
        ) : (
          <p>Drag and drop a CSV file here, or click to select</p>
        )}
      </div>
      {error && (
        <p style={{ color: "red", marginTop: "8px" }}>{error}</p>
      )}
    </div>
  );
}

The useDropzone hook handles all the drag-and-drop logic. The accept option restricts uploads to CSV files only, and maxSize sets a file size limit.

Step 3: Add CSV parsing

Now create a component that parses the uploaded CSV and displays the data. Create components/CsvUploader.tsx:

"use client";

import { useState } from "react";
import Papa from "papaparse";
import { CsvDropzone } from "./CsvDropzone";

interface ParsedData {
  data: Record<string, string>[];
  headers: string[];
  errors: Papa.ParseError[];
}

export function CsvUploader() {
  const [parsedData, setParsedData] = useState<ParsedData | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const handleFileAccepted = (file: File) => {
    setIsLoading(true);
    setParsedData(null);

    Papa.parse(file, {
      header: true,
      skipEmptyLines: true,
      complete: (results) => {
        setParsedData({
          data: results.data as Record<string, string>[],
          headers: results.meta.fields || [],
          errors: results.errors,
        });
        setIsLoading(false);
      },
      error: (error) => {
        console.error("Parse error:", error);
        setIsLoading(false);
      },
    });
  };

  return (
    <div>
      <CsvDropzone onFileAccepted={handleFileAccepted} />

      {isLoading && <p>Parsing CSV...</p>}

      {parsedData && (
        <div style={{ marginTop: "20px" }}>
          {parsedData.errors.length > 0 && (
            <div style={{ color: "orange", marginBottom: "10px" }}>
              <p>Warnings during parsing:</p>
              <ul>
                {parsedData.errors.map((err, i) => (
                  <li key={i}>
                    Row {err.row}: {err.message}
                  </li>
                ))}
              </ul>
            </div>
          )}

          <p>
            Parsed {parsedData.data.length} rows with{" "}
            {parsedData.headers.length} columns
          </p>

          <div style={{ overflowX: "auto" }}>
            <table style={{ borderCollapse: "collapse", width: "100%" }}>
              <thead>
                <tr>
                  {parsedData.headers.map((header) => (
                    <th
                      key={header}
                      style={{
                        border: "1px solid #ddd",
                        padding: "8px",
                        backgroundColor: "#f4f4f4",
                      }}
                    >
                      {header}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {parsedData.data.slice(0, 10).map((row, i) => (
                  <tr key={i}>
                    {parsedData.headers.map((header) => (
                      <td
                        key={header}
                        style={{ border: "1px solid #ddd", padding: "8px" }}
                      >
                        {row[header]}
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>

          {parsedData.data.length > 10 && (
            <p style={{ marginTop: "10px", color: "#666" }}>
              Showing first 10 of {parsedData.data.length} rows
            </p>
          )}
        </div>
      )}
    </div>
  );
}

The header: true option tells papaparse to use the first row as column names, returning an array of objects instead of arrays. The skipEmptyLines: true option prevents empty rows from appearing in the output.

Step 4: Server-side processing with Server Actions

For larger files or when you need to store data server-side, use a Server Action. Create app/actions/upload.ts:

"use server";

import fs from "node:fs/promises";
import path from "node:path";
import Papa from "papaparse";

export interface UploadResult {
  success: boolean;
  rowCount?: number;
  error?: string;
}

export async function uploadCsv(formData: FormData): Promise<UploadResult> {
  const file = formData.get("file") as File;

  if (!file) {
    return { success: false, error: "No file provided" };
  }

  // Validate file type
  if (!file.name.endsWith(".csv")) {
    return { success: false, error: "File must be a CSV" };
  }

  // Validate file size (10MB limit)
  if (file.size > 10 * 1024 * 1024) {
    return { success: false, error: "File must be under 10MB" };
  }

  try {
    const text = await file.text();

    const results = Papa.parse(text, {
      header: true,
      skipEmptyLines: true,
    });

    if (results.errors.length > 0) {
      return {
        success: false,
        error: `Parse errors: ${results.errors[0].message}`,
      };
    }

    // Process the data (save to database, etc.)
    // For this example, we'll just return the count
    const data = results.data as Record<string, string>[];

    return {
      success: true,
      rowCount: data.length,
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : "Unknown error",
    };
  }
}

Use this Server Action from a client component:

"use client";

import { useState } from "react";
import { uploadCsv, UploadResult } from "@/app/actions/upload";

export function ServerUploader() {
  const [result, setResult] = useState<UploadResult | null>(null);
  const [isUploading, setIsUploading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setIsUploading(true);

    const formData = new FormData(e.currentTarget);
    const result = await uploadCsv(formData);

    setResult(result);
    setIsUploading(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" name="file" accept=".csv" required />
      <button type="submit" disabled={isUploading}>
        {isUploading ? "Uploading..." : "Upload CSV"}
      </button>

      {result && (
        <div style={{ marginTop: "10px" }}>
          {result.success ? (
            <p style={{ color: "green" }}>
              Uploaded {result.rowCount} rows
            </p>
          ) : (
            <p style={{ color: "red" }}>{result.error}</p>
          )}
        </div>
      )}
    </form>
  );
}

Step 5: Handle large files with streaming

For CSV files with thousands of rows, loading the entire file into memory can cause performance issues. Use papaparse's streaming mode instead:

"use client";

import { useState } from "react";
import Papa from "papaparse";

interface StreamProgress {
  rowsProcessed: number;
  isComplete: boolean;
}

export function StreamingCsvUploader() {
  const [progress, setProgress] = useState<StreamProgress>({
    rowsProcessed: 0,
    isComplete: false,
  });

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    let rowCount = 0;

    Papa.parse(file, {
      header: true,
      skipEmptyLines: true,
      step: (row) => {
        // Process each row individually
        rowCount++;

        // Update progress every 100 rows to avoid too many re-renders
        if (rowCount % 100 === 0) {
          setProgress({ rowsProcessed: rowCount, isComplete: false });
        }

        // Here you would typically:
        // - Validate the row
        // - Send to an API endpoint
        // - Add to a batch for bulk insert
      },
      complete: () => {
        setProgress({ rowsProcessed: rowCount, isComplete: true });
      },
    });
  };

  return (
    <div>
      <input type="file" accept=".csv" onChange={handleFileChange} />

      {progress.rowsProcessed > 0 && (
        <p>
          {progress.isComplete
            ? `Completed: ${progress.rowsProcessed} rows processed`
            : `Processing: ${progress.rowsProcessed} rows...`}
        </p>
      )}
    </div>
  );
}

The step callback processes one row at a time, keeping memory usage constant regardless of file size.

Complete example

Here's a full working component that combines everything:

"use client";

import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import Papa from "papaparse";

interface ParsedCsv {
  data: Record<string, string>[];
  headers: string[];
  errors: Papa.ParseError[];
  fileName: string;
}

interface CsvUploaderProps {
  onDataParsed?: (data: Record<string, string>[]) => void;
  maxSizeMB?: number;
}

export function CsvUploader({
  onDataParsed,
  maxSizeMB = 10,
}: CsvUploaderProps) {
  const [parsed, setParsed] = useState<ParsedCsv | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const maxSize = maxSizeMB * 1024 * 1024;

  const onDrop = useCallback(
    (acceptedFiles: File[], rejectedFiles: any[]) => {
      setError(null);
      setParsed(null);

      if (rejectedFiles.length > 0) {
        const rejection = rejectedFiles[0];
        if (rejection.errors[0]?.code === "file-too-large") {
          setError(`File exceeds ${maxSizeMB}MB limit`);
        } else if (rejection.errors[0]?.code === "file-invalid-type") {
          setError("Only CSV files are accepted");
        } else {
          setError("File rejected");
        }
        return;
      }

      const file = acceptedFiles[0];
      if (!file) return;

      setIsLoading(true);

      Papa.parse(file, {
        header: true,
        skipEmptyLines: true,
        delimiter: "", // Auto-detect delimiter
        complete: (results) => {
          const data = results.data as Record<string, string>[];
          const headers = results.meta.fields || [];

          setParsed({
            data,
            headers,
            errors: results.errors,
            fileName: file.name,
          });

          if (onDataParsed && data.length > 0) {
            onDataParsed(data);
          }

          setIsLoading(false);
        },
        error: (err) => {
          setError(`Failed to parse CSV: ${err.message}`);
          setIsLoading(false);
        },
      });
    },
    [maxSizeMB, maxSize, onDataParsed]
  );

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: { "text/csv": [".csv"] },
    maxSize,
    multiple: false,
  });

  const reset = () => {
    setParsed(null);
    setError(null);
  };

  return (
    <div style={{ fontFamily: "system-ui, sans-serif" }}>
      {!parsed ? (
        <>
          <div
            {...getRootProps()}
            style={{
              border: `2px dashed ${isDragActive ? "#0070f3" : "#ccc"}`,
              borderRadius: "8px",
              padding: "40px 20px",
              textAlign: "center",
              cursor: "pointer",
              backgroundColor: isDragActive ? "#f0f9ff" : "#fafafa",
              transition: "all 0.2s",
            }}
          >
            <input {...getInputProps()} />
            <p style={{ margin: 0, color: "#666" }}>
              {isDragActive
                ? "Drop your CSV file here"
                : "Drag and drop a CSV file, or click to browse"}
            </p>
            <p style={{ margin: "8px 0 0", fontSize: "14px", color: "#999" }}>
              Maximum file size: {maxSizeMB}MB
            </p>
          </div>
          {error && (
            <p style={{ color: "#dc2626", marginTop: "8px" }}>{error}</p>
          )}
          {isLoading && (
            <p style={{ color: "#666", marginTop: "8px" }}>Parsing CSV...</p>
          )}
        </>
      ) : (
        <div>
          <div
            style={{
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
              marginBottom: "16px",
            }}
          >
            <div>
              <strong>{parsed.fileName}</strong>
              <span style={{ marginLeft: "8px", color: "#666" }}>
                {parsed.data.length} rows, {parsed.headers.length} columns
              </span>
            </div>
            <button
              onClick={reset}
              style={{
                padding: "6px 12px",
                border: "1px solid #ccc",
                borderRadius: "4px",
                cursor: "pointer",
              }}
            >
              Upload different file
            </button>
          </div>

          {parsed.errors.length > 0 && (
            <div
              style={{
                padding: "12px",
                backgroundColor: "#fef3cd",
                borderRadius: "4px",
                marginBottom: "16px",
              }}
            >
              <strong>Parse warnings:</strong>
              <ul style={{ margin: "8px 0 0", paddingLeft: "20px" }}>
                {parsed.errors.slice(0, 5).map((err, i) => (
                  <li key={i}>
                    Row {err.row}: {err.message}
                  </li>
                ))}
                {parsed.errors.length > 5 && (
                  <li>...and {parsed.errors.length - 5} more warnings</li>
                )}
              </ul>
            </div>
          )}

          <div style={{ overflowX: "auto" }}>
            <table
              style={{
                borderCollapse: "collapse",
                width: "100%",
                fontSize: "14px",
              }}
            >
              <thead>
                <tr>
                  {parsed.headers.map((header) => (
                    <th
                      key={header}
                      style={{
                        border: "1px solid #e5e5e5",
                        padding: "10px 12px",
                        backgroundColor: "#f9f9f9",
                        textAlign: "left",
                        fontWeight: 600,
                      }}
                    >
                      {header}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {parsed.data.slice(0, 10).map((row, i) => (
                  <tr key={i}>
                    {parsed.headers.map((header) => (
                      <td
                        key={header}
                        style={{
                          border: "1px solid #e5e5e5",
                          padding: "10px 12px",
                        }}
                      >
                        {row[header] || ""}
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>

          {parsed.data.length > 10 && (
            <p style={{ marginTop: "12px", color: "#666", fontSize: "14px" }}>
              Showing first 10 of {parsed.data.length} rows
            </p>
          )}
        </div>
      )}
    </div>
  );
}

Use it in a page:

// app/page.tsx
import { CsvUploader } from "@/components/CsvUploader";

export default function Home() {
  return (
    <main style={{ maxWidth: "800px", margin: "40px auto", padding: "0 20px" }}>
      <h1>CSV Upload Demo</h1>
      <CsvUploader
        onDataParsed={(data) => {
          console.log("Parsed data:", data);
          // Send to API, update state, etc.
        }}
        maxSizeMB={5}
      />
    </main>
  );
}

Common pitfalls

Memory issues with large files

Loading a 100MB CSV file into memory will slow down or crash the browser tab. Use papaparse's streaming mode (shown in Step 5) to process files row by row.

Delimiter detection problems

Excel exports may use semicolons instead of commas as delimiters, especially in European locales. Set delimiter: "" in papaparse config to enable auto-detection.

Encoding issues with special characters

Non-UTF-8 encoded files may display garbled characters. As of this writing, react-papaparse 4.4.0+ handles UTF-8 BOM automatically. For other encodings, you may need to convert the file before parsing.

Double file dialog with label elements

If you wrap the dropzone in a <label> element, clicking opens the file dialog twice. Use the noClick prop when using a label wrapper:

const { getRootProps, getInputProps } = useDropzone({
  noClick: true, // Disable click when using <label>
});

API route handler errors

Using multer or other Node.js file upload middleware with Next.js App Router often causes "API resolved without sending a response" errors. Use the native FormData API instead, as shown in the Server Action example.

When to use a dedicated solution

Building a CSV upload component works well for basic use cases. However, production applications often require features beyond file parsing:

  • Column mapping: Let users match CSV columns to your data schema
  • Data validation: Check required fields, data types, formats
  • Error handling: Show users which rows have problems and how to fix them
  • Preview and confirmation: Let users review data before importing
  • Progress feedback: Show upload and processing progress for large files

Implementing these features from scratch adds significant complexity to your codebase.

ImportCSV provides a pre-built, embeddable CSV import component for React and Next.js that handles column mapping, validation, and error feedback out of the box. Instead of writing hundreds of lines of parsing and validation code, you can add CSV imports with a few lines:

import { CSVImporter } from '@importcsv/react';

<CSVImporter
  onComplete={(data) => {
    // Data is already validated and mapped
    console.log(data);
  }}
/>

If you're building a product where CSV imports are core functionality, a dedicated solution can save weeks of development time.

Wrap-up

CSV imports shouldn't slow you down. ImportCSV aims to expand into your workflow — whether you're building data import flows, handling customer uploads, or processing large datasets.

If that sounds like the kind of tooling you want to use, try ImportCSV .