How to import CSV files in Next.js (App Router & Pages Router)

Add a drop-in CSV and Excel importer to your Next.js app — App Router or Pages Router — with AI column mapping, in-browser validation, and a server-action handler that writes clean rows to your database.

Next.js apps tend to grow into bulk-import territory faster than other React stacks. You ship an admin dashboard, then a customer onboarding flow, then a contact import for your sales team — and suddenly you’ve got three half-finished CSV uploaders, each parsing files differently and validating them inconsistently.

This guide shows how to add one drop-in CSV and Excel importer to your Next.js app — App Router or Pages Router — with AI column mapping, in-browser validation, and a clean Server Action handler that writes the rows to your database.

What is Rowslint

Rowslint is an embedded CSV and Excel import widget for web apps. You call launchRowslint() from a Client Component, your users get a complete import flow (file picker → smart column mapping → validation → confirmation), and clean, typed rows arrive in your onImport callback ready to forward to a Server Action.

The importer parses files entirely in the browser — Next.js doesn’t need an upload endpoint, and your users’ data never sits on Rowslint’s servers.

Step 1: Create a Rowslint account and template

Sign up for the free tier (100 imports/month, no credit card). In the dashboard:

  1. Create a template — name it after the resource you’re importing (customers_v1, orders_v1).
  2. Add columns matching your database schema (email, name, plan, etc.).
  3. Set types and validators for each column.
  4. Save the template and copy the template key. Grab your organization API key from settings.

Template setup guide →

Step 2: Install the package

npm install @rowslint/importer-js

Add the API key to .env.local:

NEXT_PUBLIC_ROWSLINT_API_KEY=org_pk_live_xxxxxxxxxxxxx

The NEXT_PUBLIC_ prefix exposes the variable to the browser, which is required since the importer launches client-side. The organization key is safe to expose — it only authorizes templates you’ve already created.

Step 3: Build the import button as a Client Component

In the App Router, anything interactive needs 'use client'. Create app/customers/_components/import-button.tsx:

'use client';

import { launchRowslint } from '@rowslint/importer-js';
import { useTransition } from 'react';
import { importCustomers } from '../actions';

export function ImportCustomersButton() {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    launchRowslint({
      apiKey: process.env.NEXT_PUBLIC_ROWSLINT_API_KEY!,
      config: { templateKey: 'customers_v1' },
      onImport: (result) => {
        if (result.status !== 'success') return;
        startTransition(() => importCustomers(result.data));
      },
    });
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? 'Importing…' : 'Import customers'}
    </button>
  );
}

Two things to notice:

  • 'use client' only wraps the button. The rest of the page can stay as a Server Component, fetched and rendered on the server.
  • useTransition keeps the UI responsive while the Server Action runs. Users see “Importing…” and can keep clicking around.

Step 4: Receive rows in a Server Action

Create app/customers/actions.ts:

'use server';

import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { db } from '@/lib/db';

const CustomerSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  plan: z.enum(['free', 'pro', 'enterprise']),
  created_at: z.coerce.date(),
});

export async function importCustomers(rows: unknown[]) {
  const validated = z.array(CustomerSchema).parse(rows);

  const chunks = chunk(validated, 500);
  for (const batch of chunks) {
    await db.customer.createMany({ data: batch, skipDuplicates: true });
  }

  revalidatePath('/customers');
  return { inserted: validated.length };
}

function chunk<T>(arr: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

Three things this Server Action gets right:

  1. Re-validates on the server. Even though Rowslint already validated client-side, never trust a client. Run Zod again on the server boundary.
  2. Batches inserts. Inserting 50,000 rows in a single statement will lock your table or blow your connection pool. Chunks of 500 keep things smooth.
  3. Revalidates the cache. revalidatePath('/customers') makes sure the customers list shows the new rows on the next render — no manual cache busting.

Step 5: Add async validation against your database

Most production imports need server-side checks during mapping — uniqueness, foreign keys, plan limits. Rowslint runs async validators per column, calling any URL you specify:

// In your Rowslint template config (set in the dashboard)
{
  field: 'email',
  type: 'string',
  format: 'email',
  asyncValidator: {
    url: 'https://app.example.com/api/validate-email',
    method: 'POST',
  },
}

Then create a Route Handler at app/api/validate-email/route.ts:

import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(req: Request) {
  const { value } = await req.json();
  const exists = await db.customer.findUnique({
    where: { email: value },
    select: { id: true },
  });

  return NextResponse.json({
    valid: !exists,
    message: exists ? 'A customer with this email already exists' : undefined,
  });
}

Now users see "[email protected] already exists" inline as they map columns. They fix it in the importer and never re-upload a file.

Pages Router: same idea, slightly different wiring

If you’re still on the Pages Router, the only difference is that you submit imported rows via fetch to an API route instead of a Server Action:

// pages/customers.tsx
import { launchRowslint } from '@rowslint/importer-js';

export default function CustomersPage() {
  const handleImport = () => {
    launchRowslint({
      apiKey: process.env.NEXT_PUBLIC_ROWSLINT_API_KEY!,
      config: { templateKey: 'customers_v1' },
      onImport: async (result) => {
        if (result.status !== 'success') return;
        await fetch('/api/customers/bulk', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ rows: result.data }),
        });
      },
    });
  };

  return <button onClick={handleImport}>Import customers</button>;
}

Everything else — the validation, batching, async validators — works identically.

Why Next.js teams pick Rowslint

Next.js teams are usually shipping fast: B2B SaaS dashboards, internal tools, AI products. Three pain points come up consistently when you build CSV import in-house:

  • Hydration mismatches. Most CSV libraries assume a browser. Importing them in a Next.js app without dynamic(() => …, { ssr: false }) gates causes hydration errors. Rowslint is launched imperatively from an event handler, so SSR is never an issue.
  • Bundle size. xlsx is 1.6 MB gzipped. Pulling it into your app/ bundle slows every page. Rowslint loads its parser lazily — your first paint stays fast.
  • Type drift. Client-side Zod schemas tend to drift from server-side validators. Rowslint lets you declare validation once in the template and re-validate on the server with the same rules.

Production-ready checklist

  • Server Action validates with Zod before any DB write
  • Idempotent insertsskipDuplicates: true or ON CONFLICT DO NOTHING
  • Batched inserts in chunks of 500–1000 rows
  • revalidatePath or revalidateTag after import
  • Async validators wired for uniqueness checks
  • Error logged to Sentry / Axiom / your observability stack
  • Rate limit the bulk endpoint with unstable_after or middleware

Conclusion

Adding CSV and Excel import to a Next.js app doesn’t need to be a sprint. With Rowslint, you get a complete import experience — mapping, validation, Excel support, async server checks — wired into your App Router or Pages Router in five minutes.

Start with the free tier and ship CSV import this afternoon. See the Next.js examples for App Router and Pages Router patterns.

Frequently asked questions

How do I add a CSV importer to a Next.js App Router project?
Install `@rowslint/importer-js`, create a Client Component with the `'use client'` directive, and call `launchRowslint()` on a button click. Pass the imported rows to a Server Action or Route Handler to persist them. The full integration takes under five minutes and works with the App Router's streaming and partial pre-rendering.
Can I use Rowslint with Next.js Server Components?
The importer UI itself is interactive and must be a Client Component (`'use client'`). The data flow afterwards is fully Server Component compatible — pass the validated rows to a Server Action or POST them to a Route Handler, then revalidate the cache. The result is fast, type-safe, and SEO-friendly.
Where should I store the Rowslint API key in Next.js?
Use a `NEXT_PUBLIC_ROWSLINT_API_KEY` environment variable in `.env.local`. The organization API key is safe to expose because it only grants access to launch templates you've already configured. Sensitive credentials (database, Stripe, etc.) should stay in non-public env vars.
How do I bulk insert imported rows in a Next.js Server Action?
Receive the rows in your Server Action, validate them again if your schema requires server-side checks, then batch-insert with your ORM. Most ORMs (Prisma, Drizzle, Kysely) support `createMany` or `insertMany`. Always use chunks of 500–1000 rows for large imports.
Does Rowslint work with Next.js streaming and Edge runtime?
Yes. The Rowslint importer runs on the client, so streaming SSR and Edge runtime have no effect on it. Your Route Handlers or Server Actions that receive the imported rows can run on Node.js or Edge — Rowslint doesn't impose a runtime.
Can I customize the Rowslint UI to match my Next.js theme?
Yes. Rowslint inherits CSS variables from your app and accepts a theme config object for colors, radii, and copy strings. It will pick up your shadcn/ui or Tailwind tokens automatically when scoped to your wrapper element.
ship it this sprint

Your users have a spreadsheet.
Let's give them somewhere to put it.

14-day free trial · No credit card required · Cancel anytime