How to import CSV files in Nuxt 3 (with server routes)

Add a complete CSV and Excel importer to your Nuxt 3 app — Universal SSR ready — with AI column mapping, in-browser parsing, and a Nitro server route handling validated rows.

Nuxt apps grow into bulk-import use cases the moment they ship to a real customer: contact lists, product catalogs, support ticket migrations, billing reconciliations. The naive path is to wire up papaparse plus a custom mapping component and call it done — three weeks later you’re still fixing edge cases for European number formats and Excel files with merged cells.

This guide shows the five-minute alternative: how to add a complete CSV and Excel importer to a Nuxt 3 (or Nuxt 4) app, with AI column matching, in-browser validation, and a clean Nitro server route handling the validated rows.

What is Rowslint

Rowslint is an embedded CSV and Excel import widget. From a Nuxt page, you call launchRowslint(), your users get a polished import modal (file picker → smart column mapping → validation → confirmation), and you receive validated, typed rows in the onImport callback — ready to POST to a Nitro server route.

The importer parses files entirely in the browser. Nuxt doesn’t need an upload endpoint; your users’ data never sits on Rowslint’s servers.

Step 1: Create a Rowslint account and template

Sign up for the free tier. In the dashboard:

  1. Create a template (e.g. customers_v1).
  2. Add the columns you want users to import. Declare types and validators.
  3. Save and copy the template key + your organization API key.

Template setup guide →

Step 2: Install the package and configure runtime config

npm install @rowslint/importer-js

Add the API key to .env:

NUXT_PUBLIC_ROWSLINT_API_KEY=org_pk_live_xxxxxxxxxxxxx

Expose it in nuxt.config.ts:

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      rowslintApiKey: '', // overridden by NUXT_PUBLIC_ROWSLINT_API_KEY
    },
  },
});

Anything under runtimeConfig.public is sent to the client, which is what we need since the importer launches in the browser.

Step 3: Build a useImporter() composable

Composables keep this clean. Create composables/useImporter.ts:

import { launchRowslint } from '@rowslint/importer-js';

type ImportResult<T> =
  | { status: 'success'; data: T[] }
  | { status: 'error'; error: unknown }
  | { status: 'cancelled' };

export function useImporter<T = unknown>(templateKey: string) {
  const config = useRuntimeConfig();

  function launchImport(onImport: (result: ImportResult<T>) => void) {
    if (!import.meta.client) return;
    launchRowslint({
      apiKey: config.public.rowslintApiKey,
      config: { templateKey },
      onImport: onImport as (r: ImportResult<unknown>) => void,
    });
  }

  return { launchImport };
}

The import.meta.client guard makes the composable safe to import on the server — it just won’t do anything until the client hydrates.

Step 4: Use the composable in a page

pages/customers.vue:

<script setup lang="ts">
type Customer = {
  email: string;
  name: string;
  plan: 'free' | 'pro' | 'enterprise';
  created_at: string;
};

const { launchImport } = useImporter<Customer>('customers_v1');

const handleClick = () => {
  launchImport(async (result) => {
    if (result.status !== 'success') return;
    await $fetch('/api/customers/bulk', {
      method: 'POST',
      body: { rows: result.data },
    });
    await refreshNuxtData('customers');
  });
};

const { data: customers } = await useFetch<Customer[]>('/api/customers', {
  key: 'customers',
});
</script>

<template>
  <div>
    <header>
      <h1>Customers</h1>
      <ClientOnly>
        <button @click="handleClick">Import customers</button>
      </ClientOnly>
    </header>
    <ul>
      <li v-for="c in customers" :key="c.email">{{ c.name }} — {{ c.email }}</li>
    </ul>
  </div>
</template>

<ClientOnly> ensures the button only renders after hydration. refreshNuxtData('customers') triggers a re-fetch so the list updates with the imported rows.

Step 5: Receive rows in a Nitro server route

server/api/customers/bulk.post.ts:

import { z } from 'zod';
import { db } from '~/server/utils/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 default defineEventHandler(async (event) => {
  const body = await readBody<{ rows: unknown[] }>(event);
  const rows = z.array(CustomerSchema).parse(body.rows);

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

  return { inserted: rows.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;
}

Nitro routes are universal — this exact code runs on Vercel, Netlify, Cloudflare Workers, Node.js, and Bun without changes.

Step 6: Add async validation during mapping

For uniqueness checks, foreign-key validation, or plan limits, Rowslint can hit a Nitro route as users map columns:

server/api/validate/email.post.ts:

export default defineEventHandler(async (event) => {
  const { value } = await readBody<{ value: string }>(event);
  const exists = await db.customer.findUnique({
    where: { email: value },
    select: { id: true },
  });
  return {
    valid: !exists,
    message: exists ? 'A customer with this email already exists' : undefined,
  };
});

Configure the validator in the Rowslint template (dashboard → field → async validator) pointing at this route. Users see inline errors and fix data in the importer instead of bouncing in and out of the file.

Why Nuxt teams pick Rowslint

Nuxt’s Universal SSR is a strength, but it makes client-only widgets tricky. Three pain points come up consistently when teams build CSV import in Nuxt:

  • Hydration mismatches when CSV libraries assume window. Rowslint launches imperatively from a click handler, so hydration is never affected.
  • xlsx bundle bloat. The xlsx library is 1.6 MB gzipped — pulling it in tanks Lighthouse scores. Rowslint’s parser is lazy-loaded only when the user clicks Import.
  • Drift between client and server validation. Validating with Zod on the client and Joi on the server means a six-month bug eventually surfaces. Rowslint declares validators once in the template and re-validates server-side with the same schema.

Production-ready checklist

  • <ClientOnly> wrapping the import button for SSR safety
  • runtimeConfig.public for the API key (not hard-coded)
  • Zod re-validation in the Nitro route
  • Batched inserts (500–1000 rows per batch)
  • refreshNuxtData or useFetch revalidation after import
  • Async validators for server-side uniqueness checks
  • Error handling for the Nitro route’s edge cases

Conclusion

CSV and Excel import in Nuxt 3 doesn’t need to be a custom build. With Rowslint, you get a complete import flow — column mapping, validation, Excel support, async server checks — wired into a Nuxt page and a Nitro server route in five minutes.

Start with the free tier and add CSV import to your Nuxt app this afternoon. See the Vue.js guide for non-Nuxt setups, and the full JavaScript SDK reference for the API.

Frequently asked questions

How do I add a CSV importer to a Nuxt 3 app?
Install `@rowslint/importer-js`, wrap the importer call in a `<ClientOnly>` component (the importer is browser-only), and use a Nitro server route to receive the validated rows. The full integration takes around five minutes and works with both Nuxt 3 and Nuxt 4.
Why does Rowslint need ClientOnly in Nuxt?
Rowslint launches a modal in the user's browser and uses browser-only APIs (File API, Web Workers). Nuxt's Universal SSR would try to render or import these on the server, which would fail. Wrapping the launch button in `<ClientOnly>` (or using `process.client`) makes sure the importer only loads in the browser.
How do I store the API key in Nuxt 3?
Add `NUXT_PUBLIC_ROWSLINT_API_KEY` to your `.env` file and expose it via `runtimeConfig.public` in `nuxt.config.ts`. Access it with `useRuntimeConfig().public.rowslintApiKey`. The organization key is safe to expose because it only authorizes pre-configured templates.
How do I receive imported rows in a Nitro server route?
Create a route at `server/api/customers/bulk.post.ts` that reads the JSON body with `readBody`, re-validates with Zod, and persists to your database. Nitro routes run on Node.js or any supported edge runtime, so this works on Vercel, Netlify, Cloudflare Pages, and self-hosted Node.
Can Rowslint be used in a Nuxt module?
Yes. You can wrap Rowslint in a Nuxt composable like `useImporter()` for reuse across pages. The composable handles the API key from runtime config and exposes a `launchImport()` function with type-safe template keys.
Does Rowslint work with Nuxt UI, Tailwind, and shadcn-vue?
Yes. Rowslint's modal inherits CSS variables from your app and accepts theme overrides. It coexists with any UI library — Nuxt UI, PrimeVue, Quasar, Vuetify — without conflicts.
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