Import CSV Files in SvelteKit with Rowslint

Add a CSV and Excel importer to SvelteKit with AI column mapping, in-browser parsing, and a typed form action for validated rows.

SvelteKit’s pitch is “less code, faster apps” — and CSV import is one of the places where that pitch breaks down. Roll your own with papaparse, and you spend a sprint building a column-mapping UI; reach for svelte-csv-import, and you ship something basic that doesn’t handle Excel files or async validation. Either way, you’ve spent more time on file uploads than on the product.

This guide shows the five-minute alternative: a complete CSV and Excel importer for SvelteKit, with AI column matching, in-browser parsing, and clean integration with form actions or +server.ts endpoints. Works with Svelte 5 runes and Svelte 4.

What is Rowslint

Rowslint is an embedded CSV and Excel import widget. From a SvelteKit page, you call launchRowslint(), your users get a polished modal (file picker → smart column mapping → validation → confirmation), and you receive validated, typed rows in onImport.

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

If you want to evaluate the product before writing SvelteKit code, start with the CSV import widget, Excel import widget, and embedded CSV importer pages.

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.
  3. Configure types and validators.
  4. Save and copy the template key + your organization API key.

Template setup guide →

Step 2: Install the package

npm install @rowslint/importer-js

Add the API key to .env:

PUBLIC_ROWSLINT_API_KEY=org_pk_live_xxxxxxxxxxxxx

SvelteKit auto-exposes anything prefixed PUBLIC_ via $env/static/public. Importing it server-side or client-side both work.

Step 3: Launch the importer from a +page.svelte

For Svelte 5 (runes):

<script lang="ts">
  import { launchRowslint } from '@rowslint/importer-js';
  import { PUBLIC_ROWSLINT_API_KEY } from '$env/static/public';
  import { invalidateAll } from '$app/navigation';

  type Customer = {
    email: string;
    name: string;
    plan: 'free' | 'pro' | 'enterprise';
    created_at: string;
  };

  let isImporting = $state(false);
  let lastImported = $state<number | null>(null);

  function handleImport() {
    launchRowslint({
      apiKey: PUBLIC_ROWSLINT_API_KEY,
      config: { templateKey: 'customers_v1' },
      onImport: async (result) => {
        if (result.status !== 'success') return;
        isImporting = true;
        try {
          const res = await fetch('/api/customers/bulk', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ rows: result.data as Customer[] }),
          });
          const { inserted } = await res.json();
          lastImported = inserted;
          await invalidateAll();
        } finally {
          isImporting = false;
        }
      },
    });
  }
</script>

<button onclick={handleImport} disabled={isImporting}>
  {isImporting ? 'Importing…' : 'Import customers'}
</button>

{#if lastImported}
  <p>Imported {lastImported} customers.</p>
{/if}

For Svelte 4, swap $state for let and onclick for on:click — everything else is identical.

Step 4: Receive rows in a +server.ts endpoint

src/routes/api/customers/bulk/+server.ts:

import { json, error } from '@sveltejs/kit';
import { z } from 'zod';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';

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 const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) throw error(401, 'Unauthorized');

  const { rows } = await request.json();
  const parsed = z.array(CustomerSchema).safeParse(rows);
  if (!parsed.success) throw error(400, 'Invalid payload');

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

  return json({ inserted: parsed.data.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 endpoint gets right:

  1. Auth check via locals.user (set by your handle hook).
  2. Server-side Zod re-validation even though Rowslint validated client-side.
  3. Batched inserts to keep large imports from blowing the connection pool.

Step 5: Use a form action instead of an endpoint (optional)

If you want the import to participate in SvelteKit’s progressive-enhancement flow, use a form action:

// src/routes/customers/+page.server.ts
import { z } from 'zod';
import { fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import type { Actions } from './$types';

const Schema = z.object({ rows: z.string() });

export const actions: Actions = {
  import: async ({ request, locals }) => {
    if (!locals.user) return fail(401);
    const data = Object.fromEntries(await request.formData());
    const { rows: rowsJson } = Schema.parse(data);
    const rows = JSON.parse(rowsJson);
    // ...validate + insert as above
    return { inserted: rows.length };
  },
};
<script>
  import { enhance } from '$app/forms';
  import { launchRowslint } from '@rowslint/importer-js';
  import { PUBLIC_ROWSLINT_API_KEY } from '$env/static/public';

  let formEl: HTMLFormElement;
  let rowsInput = $state('');

  function handleImport() {
    launchRowslint({
      apiKey: PUBLIC_ROWSLINT_API_KEY,
      config: { templateKey: 'customers_v1' },
      onImport: (result) => {
        if (result.status === 'success') {
          rowsInput = JSON.stringify(result.data);
          formEl.requestSubmit();
        }
      },
    });
  }
</script>

<button onclick={handleImport}>Import customers</button>
<form method="POST" action="?/import" use:enhance bind:this={formEl}>
  <input type="hidden" name="rows" bind:value={rowsInput} />
</form>

Step 6: Add async validation during column mapping

For uniqueness or business-rule checks, configure async validators in your Rowslint template pointing at a +server.ts endpoint:

src/routes/api/validate/email/+server.ts:

export const POST: RequestHandler = async ({ request }) => {
  const { value } = await request.json();
  const exists = await db.customer.findUnique({
    where: { email: value },
    select: { id: true },
  });
  return json({
    valid: !exists,
    message: exists ? 'A customer with this email already exists' : undefined,
  });
};

Users see inline validation errors as they map columns. No re-uploads.

Why SvelteKit teams pick Rowslint

  • No SSR conflicts. Rowslint launches imperatively from a click handler, so there’s nothing to render server-side. No browser checks, no dynamic imports needed.
  • Tiny bundle impact. The Rowslint SDK is tree-shakable; the parser loads on click. Your first-paint Lighthouse score stays clean.
  • Works with all adapters. Vercel, Cloudflare Pages, Node, Bun — same code, no special config.

Compared to svelte-csv, PapaParse, and rolling your own

FeatureRowslintsvelte-csvPapaParseRoll your own
Drop-in mapping UIpartial
AI column matching
Excel (XLSX) support
Async validation
Setup time< 5 min~2 days~1 week2+ weeks

Production-ready checklist

  • PUBLIC_ROWSLINT_API_KEY in .env, accessed via $env/static/public
  • +server.ts endpoint with Zod re-validation
  • Auth check via locals.user
  • Batched inserts in 500-row chunks
  • invalidateAll() after import to refresh load functions
  • Async validators wired for server-side uniqueness checks

Conclusion

CSV and Excel import in SvelteKit doesn’t need to be a side quest. With Rowslint, you get a complete import flow — column mapping, validation, Excel support, async server checks — wired into a +page.svelte and +server.ts endpoint in five minutes.

Start with the free tier and ship CSV import in your SvelteKit app today. See the JavaScript SDK reference for the full API.

Frequently asked questions

How do I add a CSV importer to a SvelteKit app?
Install `@rowslint/importer-js`, call `launchRowslint()` from a click handler in any `+page.svelte` file, and forward validated rows to a SvelteKit form action or `+server.ts` endpoint. Setup takes five minutes and works with both Svelte 4 and Svelte 5 (runes).
Does Rowslint work with Svelte 5 runes?
Yes. Rowslint's API is reactivity-agnostic — the importer launches imperatively from an event handler. Use `$state` and `$derived` runes around the import results in your component, and Svelte 5 will track them as expected.
Where should I store the Rowslint API key in SvelteKit?
Add `PUBLIC_ROWSLINT_API_KEY` to your `.env` file. SvelteKit's `$env/static/public` exposes any variable prefixed with `PUBLIC_` to the browser. The organization key is safe to expose since it only authorizes pre-configured templates.
How do I receive imported rows in a SvelteKit form action?
Send the validated rows to a form action with `use:enhance`-friendly fetch, or POST them to a `+server.ts` API endpoint. Validate with Zod inside the action, batch-insert in chunks of 500–1000, and `invalidateAll()` to refresh load functions.
Can Rowslint validate rows during column mapping in SvelteKit?
Yes. Configure async validators in your Rowslint template that hit any URL — including a SvelteKit `+server.ts` endpoint. Users see inline errors during column mapping for uniqueness, foreign keys, and custom rules.
Does Rowslint work with Vercel, Cloudflare, and Node adapters?
Yes. Rowslint runs in the user's browser, so the SvelteKit adapter has no impact on it. Your `+server.ts` endpoints handling the validated rows will work on `adapter-vercel`, `adapter-cloudflare`, `adapter-node`, and `adapter-static` (with a separate API).
next smallest step

Ship an import flow
your next customer can actually finish.

Start with one real spreadsheet, one schema, and one import surface in your app. If the flow feels clearer than what you have today, make it the default.

14-day free trial · No credit card required · Browser-side validation by default