Uploading large files to S3 from SvelteKit with presigned URLs

Aug 16, 2026 SvelteKit

The file upload tutorial posts the bytes through a SvelteKit form action. That works for a 200 KB avatar. A 20 MB photo hits a limit before the action runs.

adapter-node rejects bodies larger than 512 KB in production unless you raise BODY_SIZE_LIMIT. Vercel Functions stop at 4.5 MB and you cannot raise that. Serverless disks also disappear between requests, which is why the earlier uploads/ folder only works on your laptop.

A presigned URL is a normal Amazon S3 URL with a signature in the query string. Your server creates it with your AWS keys. The browser then PUTs the file to that URL. S3 checks the signature and the expiry, plus any headers you locked in when you signed. The file never enters your SvelteKit process, so body limits and function timeouts do not apply.

This guide uses the session from HttpOnly cookie auth. You need a Node or serverless adapter. adapter-static cannot sign URLs at request time.

You will create these files:

src/lib/server/s3.ts
src/routes/upload/
├── +page.svelte
├── +page.server.ts
└── presign/
    └── +server.ts

Why SvelteKit never sees the file

A form action runs inside your server. The whole multipart body has to arrive there before your code reads formData.get('file'). That is one HTTP request to your origin, so you pay for the whole body and it counts against the size limit.

With a presigned PUT there are two requests:

  1. A small JSON POST to /upload/presign. Your server checks the session, picks an object key, and returns a URL.
  2. A PUT from the browser to S3, with the file as the body.

Your function only sees a few hundred bytes of JSON. The megabytes go to S3.

A single S3 PUT accepts up to 5 GB. Bigger than that needs multipart upload, which is a different API. This post uses one PUT.

Step 1: a private bucket the browser can PUT into

Create an S3 bucket. Leave Block Public Access on. A presigned URL is how a browser writes to a private bucket. The bucket does not need to be public.

The browser is a different origin from *.s3.*.amazonaws.com, so the bucket needs CORS. In the S3 console, CORS is JSON:

[
  {
    "AllowedHeaders": ["Content-Type", "Content-Length"],
    "AllowedMethods": ["PUT"],
    "AllowedOrigins": ["http://localhost:5173"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Add your production origin to AllowedOrigins when you deploy. A * origin would also work for this PUT (the request does not send cookies to S3), but listing the sites you actually run is easier to reason about later.

Create an IAM user or role that can only touch the prefix you will write to:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject"],
      "Resource": "arn:aws:s3:::your-bucket-name/uploads/*"
    }
  ]
}

PutObject is the upload. GetObject is for the short-lived preview URL later.

Step 2: credentials stay on the server

Install the modular AWS SDK for JavaScript v3 pieces you need:

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

@aws-sdk/s3-request-presigner is the helper that turns a PutObjectCommand into a URL.

Put these in .env. None of them start with PUBLIC_:

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-east-1
S3_BUCKET=your-bucket-name

Create src/lib/server/s3.ts. Code under $lib/server cannot be imported from a .svelte file, so the keys cannot leak into the client bundle. Read them through $env/static/private:

import { S3Client } from '@aws-sdk/client-s3';
import {
  AWS_ACCESS_KEY_ID,
  AWS_SECRET_ACCESS_KEY,
  AWS_REGION,
  S3_BUCKET as BUCKET
} from '$env/static/private';

export const S3_BUCKET = BUCKET;

export const s3 = new S3Client({
  region: AWS_REGION,
  credentials: {
    accessKeyId: AWS_ACCESS_KEY_ID,
    secretAccessKey: AWS_SECRET_ACCESS_KEY
  }
});

$env/static/private fails the build (and vite dev) if a name is missing, so add the values before you start the app.

On AWS or Vercel you can drop the credentials block and let the SDK use the host’s IAM role. On a laptop, pass the keys yourself.

Cloudflare R2 speaks the same API. You would add endpoint and set region to auto. The rest of this post is unchanged.

Step 3: sign a PUT url

Create src/routes/upload/presign/+server.ts:

import { error, json } from '@sveltejs/kit';
import { extname } from 'node:path';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3, S3_BUCKET } from '$lib/server/s3';
import type { RequestHandler } from './$types';

const MAX_BYTES = 100 * 1024 * 1024;
const EXPIRES_IN = 60;
const ALLOWED: Record<string, string> = {
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp'
};

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) {
    error(401, 'Sign in');
  }

  const body = await request.json();
  const contentType = typeof body.contentType === 'string' ? body.contentType : '';
  const size = typeof body.size === 'number' ? body.size : 0;
  const name = typeof body.name === 'string' ? body.name : '';

  if (size <= 0 || size > MAX_BYTES) {
    error(400, 'File must be 100 MB or smaller');
  }

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

  const key = `uploads/${locals.user.id}/${crypto.randomUUID()}${ext}`;

  const command = new PutObjectCommand({
    Bucket: S3_BUCKET,
    Key: key,
    ContentType: type,
    ContentLength: size
  });

  const [url, previewUrl] = await Promise.all([
    getSignedUrl(s3, command, { expiresIn: EXPIRES_IN }),
    getSignedUrl(s3, new GetObjectCommand({ Bucket: S3_BUCKET, Key: key }), {
      expiresIn: 300
    })
  ]);

  return json({ url, key, contentType: type, previewUrl });
};

The server picks the key. If the client could choose Key, anyone who can upload could overwrite uploads/someone-else/photo.jpg.

ContentType and ContentLength go into PutObjectCommand, so they become part of the signature. The browser must send those exact headers. Change the type, or send a 2 GB body after asking for 1 MB, and the PUT fails.

expiresIn: 60 means the PUT has to start within a minute. S3 checks expiry when the request begins. Give yourself enough time to start the transfer. A long expiry makes a leaked URL useful for longer.

file.type is still a claim from the browser. This is the same first filter as the form upload. Checking magic bytes is a later pass, after the object lands.

Layouts do not wrap +server.ts. The locals.user check has to live in this handler, the same way serving files behind auth checks the session on GET.

Step 4: the page that talks to S3

A form action cannot do this job. The bytes have to go from the browser to S3, not to your origin.

Replace src/routes/upload/+page.svelte:

<script lang="ts">
  let file = $state<File | null>(null);
  let message = $state('');
  let preview = $state('');
  let busy = $state(false);

  async function upload() {
    if (!file) {
      message = 'Choose a file';
      return;
    }

    busy = true;
    message = '';
    preview = '';

    try {
      const sign = await fetch('/upload/presign', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          contentType: file.type,
          size: file.size,
          name: file.name
        })
      });

      if (!sign.ok) {
        const err = await sign.json().catch(() => ({}));
        message = err.message ?? 'Could not start upload';
        return;
      }

      const { url, contentType, previewUrl } = await sign.json();

      const put = await fetch(url, {
        method: 'PUT',
        headers: { 'content-type': contentType },
        body: file
      });

      if (!put.ok) {
        message = 'S3 rejected the upload';
        return;
      }

      preview = previewUrl;
      message = 'Saved.';
    } finally {
      busy = false;
    }
  }
</script>

<h1>Upload an image</h1>

<label>
  Image
  <input
    type="file"
    accept="image/jpeg,image/png,image/webp"
    onchange={(e) => {
      file = e.currentTarget.files?.[0] ?? null;
    }}
  />
</label>

<button type="button" onclick={upload} disabled={busy}>Upload</button>

{#if message}
  <p>{message}</p>
{/if}

{#if preview}
  <p>
    <img src={preview} alt="" width="160" />
  </p>
{/if}

If you still have the form action from the first tutorial, delete it and leave src/routes/upload/+page.server.ts as a login gate:

import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    redirect(303, '/login?redirectTo=/upload');
  }
};

fetch(url, { method: 'PUT', body: file }) sends the File as the body. The browser sets Content-Length from file.size. You set Content-Type to the value the server signed. If either disagrees with the URL, S3 returns 403.

This page has no use:enhance. The Fetch API also does not expose upload progress. If you need a percent, listen to XMLHttpRequest.upload.onprogress on the PUT, not on the presign request.

Step 5: look at the file without opening the bucket

A private object has no public URL. The previewUrl in the presign response is a signed GetObject that lasts five minutes. Use it as src. Do not put https://your-bucket.s3.amazonaws.com/uploads/... in the page. That only works if the object is public.

For a product, store key and ownerId in the table from serving uploaded files behind auth. The download path stays a SvelteKit GET that checks the session, then either streams from S3 or redirects to a fresh signed URL. Do not persist the signed URL. It expires, and anyone who has a copy can use it until then.

Try it

  1. Sign in, open /upload, pick a JPEG larger than 5 MB.
  2. Watch the network tab: a small POST to /upload/presign, then a PUT to amazonaws.com.
  3. The image should appear under the button.
  4. Open the PUT URL in a new tab after a minute. It should fail.

If the PUT fails in the browser with a CORS error and never hits S3, the bucket CORS origin does not match your page origin, including the port. http://localhost:5173 and http://127.0.0.1:5173 are different origins.

If you get SignatureDoesNotMatch, the Content-Type header on the PUT does not match what you signed, or the clock on the machine that signed is wrong.

Things that trip people up

Sending the file through the form action anyway. Vercel returns 413 once the body crosses 4.5 MB. The presign request is JSON. The file goes to S3.

Putting the AWS secret in a PUBLIC_ variable, or reading it from $env/static/public. That module is compiled into the client. Use $env/static/private and a $lib/server module.

Letting the client choose the object key. The signed URL can only write that one key, so pick it on the server.

Signing without ContentType. A client can then upload text/html to a .jpg key.

Signing without ContentLength. A client can then PUT a much larger object than the UI allowed.

Making the bucket public so the img tag works. Use a signed GET, or a SvelteKit GET that checks the session.

Forgetting CORS. That shows up as a browser error, not as an S3 403 in your server logs.

Reusing the PUT URL as a download link. It is a PUT. A GET to that URL is a different operation and will not match the signature.

What to add next

Record the key next to ownerId the way the auth upload post records a local filename. Serve downloads through your app, or mint a fresh signed GET when someone is allowed to see the file.

A 100 MB video still fits in one PUT. A multi-gigabyte file needs parallel parts and retries. That is S3 multipart upload, with one presigned URL per part.

Cloudflare R2 is the usual S3-compatible stand-in if you want to stay off AWS egress.

Check the bytes you stored. file.type is still just what the browser claimed.