How to import CSV files in SvelteKit (with Svelte 5 runes)

Add a complete CSV and Excel importer to your SvelteKit app with AI column mapping, in-browser parsing, and a typed form action handling validated rows. Works with Svelte 5 runes and Svelte 4.

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.

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).
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