Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 057 / 100

File uploads in SvelteKit: practical tips and trade-offs

Upload files with form actions.

Expona Admin7 min read

File uploads in SvelteKit: practical tips and trade-offs

Most web applications need to handle file uploads—whether it's profile pictures, CSV imports, or document submissions. File uploads in SvelteKit: practical tips and trade-offs covers how to build a robust upload flow using form actions, validating input safely, and deciding between local storage and cloud solutions. We'll walk through the decisions and code patterns that teams use in production.

Why form actions matter for uploads

SvelteKit's form actions give us a server-side foundation for handling uploads securely. When you POST file data through a form action, you're working with the request on the server before the file lands anywhere—perfect for validation and access control. Client-side upload logic is useful for UX (progress bars, quick validation), but the real work happens in the action.

Form actions also integrate with SvelteKit's form state system, so you get automatic error handling and loading states without extra wiring. That's a huge win compared to managing fetch requests manually.

Setting up multipart/form-data

The browser won't send files as JSON. You need to tell it to use multipart/form-data encoding. In SvelteKit, this is straightforward:

typescript
<!-- +page.svelte -->
<form method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/*" required />
  <input type="text" name="username" required />
  <button type="submit">Upload</button>
</form>

The enctype="multipart/form-data" attribute is essential. Without it, the browser sends form data as URL-encoded, and the file won't come through. The accept attribute helps UX by filtering the file picker, but don't rely on it for security—always validate on the server.

On the server side, SvelteKit's RequestEvent gives you access to request.formData(), which returns a FormData object containing both regular fields and file entries:

typescript
// +page.server.ts
import { fail } from '@sveltejs/kit';

export const actions = {
  default: async (event) => {
    const formData = await event.request.formData();
    const file = formData.get('avatar'); // returns File object
    const username = formData.get('username'); // returns string

    // file is a File object (extends Blob)
    console.log(file.name, file.size, file.type);

    return { success: true };
  }
};

Validating file size and type

A file is just a stream of bytes until you decide what to do with it. Validation must happen before you write to disk or send to cloud storage. Most breaches involving uploads come from skipped validation, not the upload mechanism itself.

Common validation checks:

  • File type: Check the MIME type, though remember it's not foolproof. A user can rename malware.exe to image.jpg.
  • File size: Reject anything over your limit to prevent storage bloat and DoS.
  • File content: For images, consider parsing headers to confirm the type matches the extension.
  • Virus scanning: For production systems handling untrusted input, integrate a service like ClamAV or VirusTotal.

Here's a practical validation helper:

typescript
// lib/upload.ts
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB

export function validateUpload(file) {
  if (!file || !(file instanceof File)) {
    return { error: 'No file provided' };
  }

  if (!ALLOWED_TYPES.includes(file.type)) {
    return { error: `File type ${file.type} not allowed` };
  }

  if (file.size > MAX_SIZE) {
    return { error: `File exceeds ${MAX_SIZE / 1024 / 1024}MB limit` };
  }

  if (file.size === 0) {
    return { error: 'File is empty' };
  }

  return { ok: true };
}

Use this in your action:

typescript
export const actions = {
  default: async (event) => {
    const formData = await event.request.formData();
    const file = formData.get('avatar');

    const validation = validateUpload(file);
    if (!validation.ok) {
      return fail(400, { error: validation.error });
    }

    // Safe to proceed
    return { success: true };
  }
};

Storing files: local disk vs. object storage

After validation, you have two main paths: store locally or push to cloud storage.

Local disk storage

Storing on your server's disk is simple to start with, but it doesn't scale. Once you deploy to multiple servers or need to handle large traffic, you're stuck—files live on one machine. Use local storage for development and small hobby projects only.

typescript
import { writeFile } from 'fs/promises';
import { randomUUID } from 'crypto';
import path from 'path';

export const actions = {
  default: async (event) => {
    const formData = await event.request.formData();
    const file = formData.get('avatar');

    const validation = validateUpload(file);
    if (!validation.ok) {
      return fail(400, { error: validation.error });
    }

    const bytes = await file.arrayBuffer();
    const filename = `${randomUUID()}-${file.name}`;
    const filepath = path.join('uploads', filename);

    await writeFile(filepath, Buffer.from(bytes));

    return { success: true, filename };
  }
};

S3 and object storage

For production, push files to S3, Google Cloud Storage, or similar. These services handle redundancy, CDN integration, and scaling automatically. You pay per gigabyte, but operational burden drops dramatically.

Most teams use the AWS SDK or a wrapper like @aws-sdk/client-s3:

typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';

const s3 = new S3Client({ region: 'us-east-1' });

export const actions = {
  default: async (event) => {
    const formData = await event.request.formData();
    const file = formData.get('avatar');

    const validation = validateUpload(file);
    if (!validation.ok) {
      return fail(400, { error: validation.error });
    }

    const key = `avatars/${randomUUID()}-${file.name}`;
    const bytes = await file.arrayBuffer();

    try {
      await s3.send(
        new PutObjectCommand({
          Bucket: 'my-app-uploads',
          Key: key,
          Body: Buffer.from(bytes),
          ContentType: file.type,
          Metadata: {
            'original-name': file.name
          }
        })
      );

      return { success: true, key };
    } catch (err) {
      return fail(500, { error: 'Upload failed' });
    }
  }
};

Benefits of S3:

  • Automatic replication and durability (11 nines).
  • Built-in CDN via CloudFront.
  • Bucket policies for access control and CORS.
  • Presigned URLs for temporary access without exposing credentials.

One pattern we often use: generate a presigned URL on the backend and let the browser upload directly to S3. This keeps your server out of the data path:

typescript
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand } from '@aws-sdk/client-s3';

export const actions = {
  getUploadUrl: async (event) => {
    const key = `avatars/${randomUUID()}`;
    const url = await getSignedUrl(
      s3,
      new PutObjectCommand({
        Bucket: 'my-app-uploads',
        Key: key,
        ContentType: 'image/jpeg'
      }),
      { expiresIn: 900 } // 15 minutes
    );

    return { url, key };
  }
};

Then on the client, POST to that URL directly. Your server never handles the bytes, reducing bandwidth and latency.

Common mistakes

Skipping MIME type validation. Users can upload anything if you only check the extension. Always validate on the server.

No file size limit. A malicious client can send a 1TB file. Enforce limits in validation, and set timeouts on upload endpoints.

Trusting client-side validation alone. The accept attribute and JavaScript checks are UX sugar only. Do real validation in the action.

Storing files with predictable names. If you save files as avatars/john.jpg, anyone can guess URLs. Use UUIDs or hashed names, and control access with bucket policies or signed URLs.

Forgetting to handle cleanup. If a user uploads a 500MB file and then abandons the form, that data stays in S3. Consider setting lifecycle policies to delete old uploads or tie them to database records with cleanup jobs.

Key takeaways

  • Use enctype="multipart/form-data" on forms and validate files on the server before writing anywhere.
  • Check MIME type, file size, and consider file content inspection for untrusted uploads.
  • For production, use S3 or object storage rather than local disk to avoid scaling headaches.
  • Presigned URLs let browsers upload directly to S3, reducing server bandwidth and complexity.
  • Store files with unpredictable names (UUIDs) and control access via bucket policies or signed URLs, not guessable paths.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles