Uploading files in a SvelteKit web app

Aug 14, 2026 SvelteKit

A file upload in SvelteKit is a form POST. The browser sends the bytes, a form action on the server reads them as a File, and you decide what to do with them. That only works because SvelteKit is a full-stack framework: the same project renders the page and receives the request.

It needs a server at runtime, so you want adapter-node, adapter-vercel, or anything else that runs server code. With adapter-static there is no server to catch the POST. For a static frontend talking to a separate API, the picker still lives in Svelte, but the bytes go to that API, the same way SPA auth leaves the real check on the backend.

We will end up with these files:

src/routes/upload/
├── +page.svelte
├── +page.server.ts
└── [name]/
    └── +server.ts
uploads/                  # created at runtime, keep it out of git

Step 1: the form

Create src/routes/upload/+page.svelte. Put method="POST" and enctype="multipart/form-data" on the form. Without the second one, a non-JavaScript submit sends the filename as a string and drops the file bytes. SvelteKit 2 will also refuse an enhanced submit from a file form that forgot enctype, so the bug shows up in development instead of in production.

<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData, PageData } from './$types';

  let { data, form }: { data: PageData; form: ActionData } = $props();
</script>

<h1>Upload an image</h1>

<form method="POST" enctype="multipart/form-data" use:enhance>
  <label>
    Image
    <input
      type="file"
      name="file"
      accept="image/jpeg,image/png,image/webp"
      required
    />
  </label>
  <button type="submit">Upload</button>
</form>

{#if form?.message}
  <p>{form.message}</p>
{/if}

{#if form?.success}
  <p>Saved.</p>
{/if}

<ul>
  {#each data.files as name (name)}
    <li>
      <a href="/upload/{name}">
        <img src="/upload/{name}" alt="" width="160" />
      </a>
    </li>
  {/each}
</ul>

The server reads the file under the name="file" key. accept is a hint for the file picker, not a security check. use:enhance turns the submit into a fetch so the page does not fully reload. The form still works if JavaScript is off, which is why enctype has to be correct on the element itself.

data.files comes from the load function in the next step. Until that file exists, leave the list in the template. SvelteKit will type it once +page.server.ts is in place.

Step 2: read the file in a form action

Create src/routes/upload/+page.server.ts. The action pulls a FormData object off the request, the same way a login action reads an email field. The value here is a File, not a string.

import { fail } from '@sveltejs/kit';
import { mkdir, readdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import type { Actions, PageServerLoad } from './$types';

const UPLOAD_DIR = 'uploads';
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED: Record<string, string> = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp'
};

export const load: PageServerLoad = async () => {
  await mkdir(UPLOAD_DIR, { recursive: true });
  const names = (await readdir(UPLOAD_DIR)).filter((name) => ALLOWED[extname(name)]);
  return { files: names };
};

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const file = data.get('file');

    if (!(file instanceof File) || file.size === 0) {
      return fail(400, { message: 'Choose a file' });
    }

    if (file.size > MAX_BYTES) {
      return fail(400, { message: 'File must be 2 MB or smaller' });
    }

    const ext = extname(file.name).toLowerCase();
    const type = ALLOWED[ext];
    if (!type || file.type !== type) {
      return fail(400, { message: 'Only JPEG, PNG or WebP images' });
    }

    const name = `${crypto.randomUUID()}${ext}`;
    await mkdir(UPLOAD_DIR, { recursive: true });
    await writeFile(join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer()));

    return { success: true };
  }
};

file instanceof File is the right type check. formData.get returns File | string | null, and an empty file input still gives you a File whose size is 0.

The saved name is a new id plus the extension. Never write file.name onto disk. A value like ../../.env is a path, not a filename.

file.type and the extension both have to match the allow list. The browser can lie about the type, and a renamed .exe can look like a .png in the picker, so this is a first filter rather than a guarantee. Checking the file’s magic bytes is the follow-up if you need to be sure.

fail(400, { message }) sends the error back as form on the page. In SvelteKit 3 an enhanced submit reports that as HTTP 400. In SvelteKit 2 it still reports 200 and you read form either way.

Add uploads/ to .gitignore. Those files are user data, not source.

Step 3: serve the file yourself

Do not save uploads into static/. Everything in static/ is copied into the build and served to anyone who knows the URL, including an HTML file someone renamed to look like an image. Serve them from a +server.ts route instead, so you control the Content-Type and can add an auth check later.

Create src/routes/upload/[name]/+server.ts:

import { error } from '@sveltejs/kit';
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import type { RequestHandler } from './$types';

const ALLOWED: Record<string, string> = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp'
};

export const GET: RequestHandler = async ({ params }) => {
  const name = params.name;
  const type = ALLOWED[extname(name)];

  if (!type || name.includes('..') || name.includes('/') || name.includes('\')) {
    error(404, 'Not found');
  }

  try {
    const bytes = await readFile(join('uploads', name));
    return new Response(bytes, {
      headers: {
        'content-type': type,
        'x-content-type-options': 'nosniff'
      }
    });
  } catch {
    error(404, 'Not found');
  }
};

The content-type comes from the extension you allowed, not from the original upload. nosniff stops the browser from guessing a different type. After an upload, the page’s load runs again, data.files includes the new name, and the <img> tags hit this handler.

Open /upload, pick a small JPEG or PNG, and submit. You should see the image under the form.

How big a file you can accept

Your MAX_BYTES check is not the first limit the request hits. adapter-node rejects bodies larger than 512 KB in production unless you raise BODY_SIZE_LIMIT. Set it on the Node process:

BODY_SIZE_LIMIT=5M

vite dev and vite preview do not apply that limit, which is why a 2 MB image can work on your laptop and then return 413 after you deploy. Keep the env var a bit above your own MAX_BYTES so a file that is too large dies in your action with a useful message, not as a generic 413 from the adapter.

Vercel Functions cap the request body at 4.5 MB, and you cannot raise that. A 10 MB photo has to go around your function, usually with a presigned URL straight to object storage.

Writing to uploads/ only works on a machine with a writable disk that survives the request. On Vercel the filesystem is ephemeral (and mostly read-only), which is fine for learning locally. In production, send the bytes to Amazon S3, Cloudflare R2, or similar, and store the object key in your database.

Who is allowed to upload

Anyone who can load /upload can POST to it. If this is a user avatar or a document, check the session in the action the same way you would on any other write.

With HttpOnly cookie auth, that is locals.user after hooks.server.ts has resolved the cookie:

default: async ({ request, locals }) => {
  if (!locals.user) {
    return fail(401, { message: 'Sign in to upload' });
  }
  // ...the rest of the action
};

Do the same check in the GET handler if the files are not public. The cookie is sent automatically on both the form POST and the <img> request, so you do not pass a token around in JavaScript.

Several files at once

Add multiple to the input and use a plural name:

<input type="file" name="files" accept="image/jpeg,image/png,image/webp" multiple />

On the server, data.getAll('files') returns every selected file. Loop over them, run the same checks on each one, and skip anything that is not a File. Selecting files one after another in the picker replaces the previous selection. That is how <input type="file"> works, not a SvelteKit bug. If you need a growing list, keep chosen File objects in component state and build a FormData yourself on submit.

Things that trip people up

Forgetting enctype="multipart/form-data" is the most common failure. The input looks like it worked, but the action receives a string.

BODY_SIZE_LIMIT defaults to 512 KB on a Node deploy, so a 1 MB image that worked in vite dev never reaches your file.size check in production.

Saving under the original filename is a path-traversal bug waiting to happen, and it overwrites files when two people upload photo.jpg.

Putting uploads in static/ publishes them, including anything that is not really an image.

adapter-static cannot run this action. The built site is files on a CDN.

use:enhance does not give you an upload progress bar, because the Fetch API does not expose upload progress. If you need a percent, post with XMLHttpRequest and listen to upload.onprogress.

What to add next

Before any of this goes near production, check locals.user, store the files in object storage instead of the app disk, and verify the bytes match the type you think they are. The form and the action stay the same; only the writeFile line changes.