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:
- Create a template (e.g.
customers_v1). - Add the columns you want users to import. Declare types and validators.
- Save and copy the template key + your organization API key.
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. xlsxbundle bloat. Thexlsxlibrary 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.publicfor the API key (not hard-coded) - Zod re-validation in the Nitro route
- Batched inserts (500–1000 rows per batch)
-
refreshNuxtDataoruseFetchrevalidation 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.