SvelteKit File Upload Form with Server-Side Validation

File Uploads in SvelteKit: The Complete Guide

File upload forms add complexity that text-only forms don’t have: multipart encoding, binary data handling, file size limits, MIME type validation, and storage decisions. Here’s how to build one properly in SvelteKit with Superforms and Zod.

The Schema: Client vs Server Validation

File validation with Zod works differently on client and server. On the client, you validate the File object’s properties. On the server, you parse the multipart form data first.

Client-Side Schema

import { z } from 'zod';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];

export const uploadSchema = z.object({
  title: z.string().min(2, 'Title is required'),
  description: z.string().max(500).optional(),
  file: z.instanceof(File)
    .refine(f => f.size <= MAX_FILE_SIZE, 'File must be under 5MB')
    .refine(f => ACCEPTED_TYPES.includes(f.type), 'Only JPEG, PNG, WebP, and PDF files are accepted')
});

export type UploadSchema = typeof uploadSchema;

Why z.instanceof(File)?

Zod’s z.instanceof(File) checks that the value is a browser File object. This only works on the client — on the server, form data arrives as a multipart stream, not a File object. The server needs different handling.

Server Action: Handling Multipart Data

import { superValidate, message, withFiles } from 'sveltekit-superforms/server';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { uploadSchema } from './schema';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  const form = await superValidate(zod(uploadSchema));
  return { form };
};

export const actions: Actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(uploadSchema));

    if (!form.valid) {
      return fail(400, withFiles({ form }));
    }

    const file = form.data.file;

    // Server-side file validation (defense in depth)
    if (file.size > 5 * 1024 * 1024) {
      return message(form, 'File too large', { status: 400 });
    }

    // Save the file
    const buffer = Buffer.from(await file.arrayBuffer());
    const filename = crypto.randomUUID() + '.' + file.name.split('.').pop();

    // Option 1: Local filesystem
    await writeFile('uploads/' + filename, buffer);

    // Option 2: S3-compatible storage
    // await s3.putObject({ Bucket: 'uploads', Key: filename, Body: buffer });

    // Option 3: Cloudflare R2
    // await env.R2_BUCKET.put(filename, buffer);

    return message(form, 'File uploaded successfully!');
  }
};

Key Details

  • withFiles({ form }) — Superforms helper that preserves file data across failed validation responses. Without it, the file input resets on validation errors and users have to re-select their file.
  • Defense in depth — the server re-checks file size even though the client already validated it. Client validation is bypassable; server validation is the security boundary.
  • Random filenames — never use the original filename for storage. It can contain path traversal attacks (../../etc/passwd) or collide with existing files.

The Form Component

<script lang="ts">
  import { superForm } from 'sveltekit-superforms';
  import { zod } from 'sveltekit-superforms/adapters';
  import { uploadSchema } from './schema';
  import type { PageData } from './$types';

  let { data } = $props();

  const { form, errors, enhance, submitting } = superForm(data.form, {
    validators: zod(uploadSchema)
  });

  let fileInput: HTMLInputElement;
  let selectedFile = $state('No file chosen');

  function handleFileChange(e: Event) {
    const input = e.target as HTMLInputElement;
    const file = input.files?.[0];
    if (file) {
      selectedFile = file.name + ' (' + (file.size / 1024 / 1024).toFixed(1) + ' MB)';
      $form.file = file;
    }
  }
</script>

<form method="POST" use:enhance enctype="multipart/form-data" class="space-y-4">
  <div>
    <label for="title">Title</label>
    <input id="title" name="title" bind:value={$form.title} />
    {#if $errors.title}<p class="error">{$errors.title}</p>{/if}
  </div>

  <div>
    <label for="file">File</label>
    <input id="file" name="file" type="file"
      accept=".jpg,.jpeg,.png,.webp,.pdf"
      bind:this={fileInput}
      onchange={handleFileChange} />
    <p class="text-sm text-gray-500">{selectedFile}</p>
    {#if $errors.file}<p class="error">{$errors.file}</p>{/if}
  </div>

  <button type="submit" disabled={$submitting}>
    {$submitting ? 'Uploading...' : 'Upload File'}
  </button>
</form>

Important: the form tag must have enctype="multipart/form-data". Without it, the browser sends the file as a filename string instead of the actual binary data.

Storage Options for SvelteKit

OptionBest ForSetup
Local filesystemDevelopment, self-hostedNode.js fs module
Cloudflare R2Cloudflare Pages deploymentR2 binding in wrangler.toml
AWS S3AWS infrastructure@aws-sdk/client-s3
Supabase StorageSupabase projectssupabase-js client
UploadthingQuick setup, managed serviceuploadthing SDK

For Cloudflare Pages (which SvelteForms itself uses), R2 is the natural choice — it’s S3-compatible, has no egress fees, and binds directly to your Pages project.

Security Checklist

  • Always validate file size and type on the server, not just the client
  • Generate random filenames — never trust user-provided filenames
  • Set Content-Disposition headers when serving files to prevent XSS
  • Consider virus scanning for user-uploaded files in production
  • Set upload size limits at the server/CDN level (Cloudflare default: 100MB)
  • Store files outside the web root to prevent direct URL access

Generate File Upload Forms

SvelteForms includes a File Upload field type that generates the client-side schema with z.instanceof(File) and appropriate validation. Load it in the form builder, configure accepted types and max size visually, and export the code.

For more validation patterns, see Zod schema patterns and client + server validation.

Build forms faster

Try the form builder →