Add a CSV importer to Next.js in 10 minutes
Next.js projects use the same React package as any other React app; the extra work is deciding where the client boundary sits. Install first.
pnpm add dromo-uploader-react
The importer touches the DOM, so it belongs in a Client Component. In the App Router that means a file with the "use client" directive at the top. Everything above it in the tree can stay a Server Component.
// app/(dashboard)/orders/ImportOrders.tsx
"use client";
import { useRouter } from "next/navigation";
import DromoUploader, { type IDeveloperField } from "dromo-uploader-react";
const fields: IDeveloperField[] = [
{ label: "Order ID", key: "order_id", type: "string", validators: [{ validate: "required" }, { validate: "unique" }] },
{ label: "Customer email", key: "customer_email", type: "email", validators: [{ validate: "required" }] },
{ label: "Total", key: "total", type: "number" },
{
label: "Status",
key: "status",
type: "select",
selectOptions: [
{ label: "Pending", value: "pending" },
{ label: "Shipped", value: "shipped" },
{ label: "Refunded", value: "refunded" },
],
},
];
export default function ImportOrders({ workspaceId }: { workspaceId: string }) {
const router = useRouter();
return (
<DromoUploader
licenseKey={process.env.NEXT_PUBLIC_DROMO_KEY!}
fields={fields}
settings={{ importIdentifier: "orders", developmentMode: process.env.NODE_ENV !== "production" }}
user={{ id: workspaceId }}
onResults={async (rows) => {
await fetch("/api/orders/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId, rows }),
});
router.refresh();
}}
>
Import orders
</DromoUploader>
);
}
Receive the rows in a Route Handler. Because validation already ran in the browser, the handler's job is persistence and authorization, not parsing.
// app/api/orders/import/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const session = await auth();
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const { workspaceId, rows } = await req.json();
await db.order.createMany({ data: rows.map((r: any) => ({ ...r, workspaceId })) });
return NextResponse.json({ inserted: rows.length });
}
On the Pages Router, drop the directive and load the component with next/dynamic and ssr: false. Both approaches are covered below.
How it fits the Next.js model
App Router projects draw a line between server and client, and the importer sits firmly on the client side of it. Put the "use client" file as low in the tree as you can: the page and layout stay server-rendered, fetch the tenant's schema settings on the server, and pass plain props into the client component. Functions cannot cross that boundary, so build the fields array inside the client file or pass serializable data and construct it there.
Pages Router projects have no directive, so use dynamic import to keep the module out of the server bundle entirely:
import dynamic from "next/dynamic";
const ImportOrders = dynamic(() => import("@/components/ImportOrders"), { ssr: false });
Results can go to a Route Handler, an API route, or a Server Action. A Server Action is the shortest path if the row count is modest; for very large imports, consider sending the rows to a Route Handler that enqueues a job rather than holding a serverless function open.
Next.js gotchas Dromo handles for you
window is not defined. Any library that reads window or document at import time throws during server rendering. Writing your own importer means auditing every dependency for this. The Dromo package is safe to import in a client file and does nothing until the component mounts.
Hydration mismatches. A file picker that renders differently on the server and the client produces a hydration warning and, occasionally, a broken tree. The trigger button renders identically in both passes, and the modal is created only after mount.
Request body limits. Serverless functions on most hosts cap the request body in the low megabytes, so posting a raw 80 MB spreadsheet to an API route fails before your code runs. Dromo parses and validates in the browser and delivers JSON rows, which you can batch. For files that should never transit your servers at all, Bring Your Own Storage writes results directly from the browser to your S3, GCS, or Azure bucket.
Edge runtime restrictions. Middleware and Edge Route Handlers lack the Node APIs most parsing libraries assume. Since parsing happens client-side, your handler receives JSON and works on either runtime.
Environment variables. A license key read as process.env.DROMO_KEY is undefined in the browser. Prefix it NEXT_PUBLIC_ and treat it as a publishable key, which is how the package expects it.
Dromo vs building it with PapaParse
| Build it yourself | Dromo | |
|---|---|---|
| Column mapping | A dropdown per column and a manual header-to-field table | Suggested matches from the user's headers, remembered on the next import |
| Validation | Bespoke checks scattered across the client and the Route Handler | Declarative validators plus custom hooks that can query your API mid-import |
| Error correction UI | Return an error list and ask the user to re-export from Excel | Spreadsheet-style review step with errors highlighted and fixable inline |
| Large files | Chunked uploads, worker threads, and body-size workarounds | Browser-side processing; export an annotated workbook to finish offline |
| Internationalization | next-intl plus strings for every state of the wizard | Over thirty languages included |
| Ongoing maintenance | Each customer's odd export lands on your sprint | Adjust the schema, usually without a deploy |
Someone parsing a clean CSV that their own system generated should use PapaParse in a client component and be done in an afternoon. The calculus changes once files arrive from outside: the parsing is five percent of the job, and the mapping and correction experience is the other ninety-five. That is what you are buying.
Ship it to your users
There is no separate tier for removing Dromo branding. Every plan includes white labeling, so the colors, typeface, and wording match the rest of your Next.js app from the first deploy.
Localization ships with the widget: more than thirty languages are supported out of the box, and the language can follow the user's locale you already resolve in your layout.
Private Mode keeps the file, the parsed rows, and the corrections inside the browser, with nothing retained by Dromo. Teams selling into regulated buyers turn it on with a settings change and keep the same Client Component and the same onResults handler.

