Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Upload files in SvelteKit to Google Cloud Storage with progress

A SvelteKit endpoint signs browser uploads straight to a private GCS bucket, a load function lists each user's files, and adapter-node's CSRF, origin, and body-size checks stay out of the way.

One +server.ts endpoint mounts the Files SDK gateway with files-sdk/sveltekit. A signed-in user picks files, and useFiles from files-sdk/svelte asks the endpoint to presign each one. The gateway mints a key under that user’s prefix and signs a Google Cloud Storage POST policy whose content-length-range caps the size. The browser then posts the file straight to GCS and reports progress. A load function lists the user’s files for server rendering, and downloads redirect to signed URLs that expire after five minutes.

Three checks sit in front of the gateway and can refuse an upload before your endpoint code runs:

  • SvelteKit’s CSRF check rejects cross-origin form-like requests.
  • @sveltejs/adapter-node caps request bodies at 512 KB.
  • adapter-node assumes https when it builds the request URL.

Uploads that go straight to GCS never meet the first two, because their bytes never reach SvelteKit. This guide shows where each check applies and how to set it.

Before you start

  • A Google Cloud project with a bucket (this guide calls it uploads) that isn’t publicly readable, and a service account for the app to run as.
  • A SvelteKit 3 app on @sveltejs/adapter-node, with a handle hook that sets event.locals.user. The type below assumes { id: string } | null.
  • Written against files-sdk 3.0, @sveltejs/kit 3.0, @sveltejs/adapter-node 6.0, Svelte 5.57, Vite 8.3, and @google-cloud/storage 8.4.
  • The SvelteKit behavior below was observed on a production build (node build and vite preview), with a local MinIO server standing in for GCS. The GCS POST policy was generated through the gateway with a throwaway signing key. Cloud Run and GCS limits come from Google’s documentation.
npm install files-sdk @google-cloud/storage
pnpm add files-sdk @google-cloud/storage
yarn add files-sdk @google-cloud/storage
bun add files-sdk @google-cloud/storage
nub add files-sdk @google-cloud/storage
aube add files-sdk @google-cloud/storage
declare global {
  namespace App {
    interface Locals {
      user: { id: string } | null;
    }
  }
}

export {};

Create the storage instance

import { createFiles } from "files-sdk";
import { gcs } from "files-sdk/gcs";

export const files = createFiles({
  adapter: gcs({ bucket: "uploads" }),
});

/** Every key a user can reach lives under this prefix. */
export const userPrefix = (userId: string) => `users/${userId}/`;

SvelteKit 3 no longer generates the $lib alias. Declare #lib in package.json and import with the .js extension, as the SvelteKit docs describe:

{
  "imports": {
    "#lib": "./src/lib/index.js",
    "#lib/*": "./src/lib/*"
  }
}

With no credentials passed, @google-cloud/storage uses Application Default Credentials: the attached service account on Cloud Run, or your gcloud login locally. Reading and writing objects works with either one. Signing a URL or a POST policy needs a service-account identity. Your user credentials can’t sign, so locally log in as the app’s service account:

gcloud auth application-default login \
  --impersonate-service-account=files-app@your-project.iam.gserviceaccount.com

Your user account needs the Service Account Token Creator role on that service account for the impersonation to work. Deploy on Cloud Run covers the permissions the service account itself needs.

Mount the gateway

import { getRequestEvent } from "$app/server";
import { FilesError } from "files-sdk";
import { createFilesRouter, type FilesOperation } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/sveltekit";

import { files, userPrefix } from "#lib/server/files.js";

// The page lists files in its load function, so the gateway never needs `list`.
const ALLOWED = new Set<FilesOperation>([
  "upload",
  "head",
  "download",
  "delete",
]);

const router = createFilesRouter({
  files,
  maxUploadSize: 100 * 1024 * 1024, // 100 MiB
  authorize: ({ operation, key }) => {
    // `authorize` gets the Web Request. getRequestEvent() reaches the
    // SvelteKit event behind it, with the locals your handle hook set.
    const { locals } = getRequestEvent();
    if (!locals.user) {
      throw new FilesError("Unauthorized", "Sign in to manage files");
    }
    if (!ALLOWED.has(operation)) {
      throw new FilesError("ReadOnly", `${operation} is not allowed`);
    }
    // The one keyed upload this app makes: a fixed avatar key, streamed
    // through this route.
    if (operation === "upload" && key !== undefined && key !== "avatar") {
      throw new FilesError("ReadOnly", "Upload files with upload(file)");
    }
    return { keyPrefix: userPrefix(locals.user.id), maxExpiresIn: 300 };
  },
});

export const { GET, POST, PUT } = createRouteHandler(router);

What the endpoint does with each request:

  • One route, three methods. createRouteHandler returns the GET, POST, and PUT exports SvelteKit expects. GET serves downloads, POST the JSON operations (presign, complete, delete, head), and PUT the upload bytes.
  • getRequestEvent() bridges to locals. The handlers pass event.request to the gateway, and authorize receives that same Request, not the SvelteKit event. getRequestEvent() from $app/server returns the event for the current request, locals included. A logged run confirmed that getRequestEvent().request is the exact object authorize receives. It relies on AsyncLocalStorage, because authorize runs after the gateway has awaited the request body. adapter-node has it. On other adapters, check that their runtime provides node:async_hooks.
  • authorize gates every operation. Unauthorized becomes a 401, and ReadOnly a 403. An unauthenticated POST answered 401 with Sign in to manage files. A list call answered 403 with list is not allowed, because the page never needs the gateway to list.
  • maxUploadSize goes into the policy. For upload(file), the gateway asks the adapter for a POST policy instead of a PUT URL. A policy generated through the gateway for a PDF carried ["content-length-range", 0, maxUploadSize], an exact match on Content-Type: application/pdf, and the minted key users/42/<uuid>.pdf. GCS checks each condition when the form arrives (policy document conditions).
  • FILES_API_SECRET signs the presign → complete token. Set it to the same long random value on every instance. The router reads it from the environment.

Authorization covers the rest of what authorize can return.

Let the browser POST to GCS

The browser posts each form to https://storage.googleapis.com/uploads/, a different origin from your app, so the bucket needs a CORS rule:

[
  {
    "origin": ["http://localhost:5173", "https://app.example.com"],
    "method": ["POST"],
    "responseHeader": ["Content-Type"],
    "maxAgeSeconds": 3600
  }
]
gcloud storage buckets update gs://uploads --cors-file=cors.json

The upload uses XMLHttpRequest so it can report progress, and an XMLHttpRequest with upload listeners always sends a preflight, which this rule answers. On success, GCS answers the form with an empty 204. Downloads are top-level navigations to a signed URL, so they need no rule.

List files in a load function

The page’s list comes from a load function that calls files.list() directly. It runs during server rendering, it already has locals, and it doesn’t go through the gateway at all:

import { redirect } from "@sveltejs/kit";

import { files, userPrefix } from "#lib/server/files.js";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    redirect(303, "/login");
  }
  const prefix = userPrefix(locals.user.id);
  const { items } = await files.list({ prefix, limit: 100 });
  return {
    // Keys relative to the user's prefix: the same keys the gateway takes.
    files: items.map((item) => ({
      key: item.key.slice(prefix.length),
      size: item.size,
    })),
  };
};

useList from files-sdk/svelte would also work, but it fetches /api/files from the browser after the page loads, and during server rendering it would fire a request to a relative URL that can’t resolve. A load function gives you the list in the server-rendered HTML.

Build the upload page

<script lang="ts">
  import { invalidateAll } from "$app/navigation";
  import { useFiles } from "files-sdk/svelte";
  import { onDestroy } from "svelte";

  import type { PageProps } from "./$types";

  let { data }: PageProps = $props();

  const files = useFiles();
  const { uploads, isUploading, progress } = files;
  onDestroy(() => files.abort());

  async function onSelect(event: Event & { currentTarget: HTMLInputElement }) {
    const selected = [...(event.currentTarget.files ?? [])];
    event.currentTarget.value = "";
    // Keyless: the gateway mints each key and signs a GCS POST form for it.
    await Promise.allSettled(selected.map((file) => files.upload(file)));
    // Re-run the load function so the new files show up.
    await invalidateAll();
  }

  async function onAvatar(event: Event & { currentTarget: HTMLInputElement }) {
    const file = event.currentTarget.files?.[0];
    event.currentTarget.value = "";
    if (!file) {
      return;
    }
    // Keyed: one PUT to /api/files, streamed through SvelteKit to GCS.
    await files.upload("avatar", file).catch(() => {});
    await invalidateAll();
  }

  async function remove(key: string) {
    await files.delete(key).catch(() => {});
    await invalidateAll();
  }

  const downloadHref = (key: string) =>
    `/api/files?op=download&key=${encodeURIComponent(key)}`;
</script>

<label>
  Upload files
  <input type="file" multiple onchange={onSelect} />
</label>
<label>
  Avatar
  <input type="file" accept="image/*" onchange={onAvatar} />
</label>

{#if $isUploading}
  <progress value={$progress.fraction}></progress>
{/if}

<ul>
  {#each $uploads as upload, index (index)}
    <li>
      {upload.name}: {upload.status}
      {#if upload.status === "uploading"}
        {Math.round(upload.progress * 100)}%
      {/if}
      {#if upload.error}
        ({upload.error.message})
      {/if}
    </li>
  {/each}
</ul>

<ul>
  {#each data.files as file (file.key)}
    <li>
      <a href={downloadHref(file.key)}>{file.key}</a>
      ({file.size} bytes)
      <button type="button" onclick={() => remove(file.key)}>Delete</button>
    </li>
  {/each}
</ul>

useFiles returns Svelte stores, so $uploads and $progress work in a Svelte 5 component as they did in Svelte 4. Each upload(file) keeps one entry in uploads that moves from "uploading" to "success", "error", or "aborted". The binding imports no Svelte lifecycle hooks, so onDestroy cancels in-flight calls when the page unmounts. Download links answer with a 302 to a V4 signed GET URL that expires after at most 300 seconds. Svelte documents the rest of the binding.

Direct and proxied uploads

The two upload calls on the page take different paths, and only one of them meets SvelteKit’s limits:

upload(file) upload("avatar", file)
Bytes go Browser → GCS Browser → SvelteKit → GCS
Size enforced by GCS, from the policy’s content-length-range The gateway, which counts bytes and aborts past maxUploadSize
SvelteKit CSRF check Never sees the bytes. The JSON POSTs are not form submissions Applies to the PUT
BODY_SIZE_LIMIT Never sees the bytes Applies to the PUT
Cloud Run’s 32 MiB HTTP/1 request cap Doesn’t apply Applies

Keyed uploads suit a small file at a fixed key that you want overwritten in place, like this avatar. Keep anything large on upload(file).

Set the body size limit

adapter-node reads request bodies with a limit of BODY_SIZE_LIMIT, which defaults to 512 KB (512K, so 524,288 bytes). The limit applies inside the request stream the gateway reads, so a keyed upload over it fails partway through files.upload(). On a node build server with the default, a 1 MiB avatar PUT answered 500:

{
  "error": {
    "code": "Provider",
    "message": "Content-length of 1048576 exceeds limit of 524288 bytes."
  }
}

A chunked body without Content-Length failed the same way, with request body size exceeded BODY_SIZE_LIMIT of 524288. No object was left behind in either case. With BODY_SIZE_LIMIT=10M the same 1 MiB upload stored normally.

Set it to the largest keyed upload you accept:

BODY_SIZE_LIMIT=5M node build

The K, M, and G suffixes are binary multiples. Infinity turns the limit off, but it’s server-wide: it also lifts the cap on every form action and other endpoint in the app. Keyless uploads don’t need a higher limit at all, so size it for keyed uploads only. Check file.size in the browser before a keyed upload, so users get a clear message instead of the 500.

The table above holds because GCS can sign a size-limited POST (files.capabilities.signedUpload.maxSize is true). On R2, B2, Azure, or Supabase it’s false, and setting maxUploadSize sends upload(file) through the gateway’s proxy route as well, so BODY_SIZE_LIMIT then applies to every upload. Build a Next.js file uploader with Cloudflare R2 covers that tradeoff.

Origins, CSRF, and the production build

Two origin checks run on requests to /api/files, and they look at different things:

  • SvelteKit’s CSRF check runs in production builds only. It rejects a POST, PUT, PATCH, or DELETE from another origin when its Content-Type is text/plain, multipart/form-data, or application/x-www-form-urlencoded, or, new in SvelteKit 3, when it has no Content-Type at all. It compares the Origin header to paths.origin if you set one, otherwise to the request URL’s origin.
  • The gateway’s origin check runs on every state-changing operation (presign, complete, delete, and both kinds of upload PUT) whatever the content type. Reads such as head and download don’t check it. It compares Origin to allowedOrigins if you set them, otherwise to the request URL’s origin.

adapter-node 6 builds that request URL as https:// plus the Host header, unless paths.origin is set or PROTOCOL_HEADER/HOST_HEADER name proxy headers to read instead. That’s right for a browser talking to Cloud Run directly. It’s wrong for node build on plain http://localhost:3000: there, a presign from http://localhost:3000 answered 403 with origin not allowed. Test the production build locally with vite preview, which serves over http and builds an http request URL. Presign worked there, and the CSRF check was active, as in production.

If a proxy in front of the app rewrites Host, set paths.origin in the sveltekit() plugin options in vite.config.ts, or have adapter-node read the proxy’s headers:

PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build

paths.origin sets both adapter-node’s request URL and SvelteKit’s CSRF origin, so it fixes both checks. Only set the header variables behind a proxy you trust, since a client could otherwise send its own.

Serving another origin

If a different site calls this endpoint, both checks need its origin. List it in the gateway’s allowedOrigins, and in csrf.trustedOrigins in the sveltekit() plugin options (SvelteKit 3 moved its configuration from svelte.config.js into vite.config.ts). Without the second, cross-origin keyed PUTs of text/plain files, or of files with no type, get SvelteKit’s 403 with the plain-text body Cross-site PUT form submissions are forbidden. The client can’t parse that, so it reports upload failed (403). A cross-origin application/pdf PUT passes SvelteKit and then hits the gateway’s check instead.

Deploy on Cloud Run

The app’s service account needs object access on the bucket, and permission to sign as itself:

SA=files-app@your-project.iam.gserviceaccount.com

# Create, read, list, and delete objects in the bucket
gcloud storage buckets add-iam-policy-binding gs://uploads \
  --member="serviceAccount:$SA" --role="roles/storage.objectUser"

# iam.serviceAccounts.signBlob on itself, for signed URLs and POST policies
gcloud iam service-accounts add-iam-policy-binding "$SA" \
  --member="serviceAccount:$SA" --role="roles/iam.serviceAccountTokenCreator"

gcloud services enable iamcredentials.googleapis.com

There’s no private key on Cloud Run, so @google-cloud/storage signs through the IAM Credentials API with the attached account. That needs the signBlob permission above and the IAM Service Account Credentials API enabled. Google rotates the keys behind signBlob, and warns that a signature with an expiry beyond 12 hours can stop working early. The five-minute expiries here are well inside that.

Deploy with --service-account "$SA", and set FILES_API_SECRET and BODY_SIZE_LIMIT on the service. Cloud Run caps HTTP/1 request bodies at 32 MiB, and adapter-node serves HTTP/1, so a keyed upload above 32 MiB is refused before it reaches your container, whatever BODY_SIZE_LIMIT says. Direct uploads to GCS don’t pass through Cloud Run.

Limits and tradeoffs

  • The policy checks the claimed type, not the bytes. GCS stores the Content-Type the browser declared, and rejects a form that changes it. It doesn’t inspect the file. Enforce file-size and content-type limits on presigned uploads shows how to check the bytes after they land.
  • A signed form isn’t single-use. It accepts posts to its key until it expires, whether or not the client completed. maxExpiresIn: 300 keeps that to five minutes.
  • Closed tabs skip complete. A browser that uploads and never calls complete leaves an object the gateway never verified. GCS events let you reconcile those.
  • The avatar overwrites. A keyed upload replaces whatever was at the key. Here that’s the point, but don’t allow keyed uploads to keys the user doesn’t own.
  • getRequestEvent() ties authorize to SvelteKit. The same router mounted in another framework would throw there. If it has to be portable, read the session from req instead.

Troubleshooting

Cannot sign data without client_email. @google-cloud/storage is running with user credentials, which can’t sign. Locally, log in with --impersonate-service-account. On Cloud Run, check that the service runs as a service account with the token creator binding above.

origin not allowed (403) on upload or delete. The request URL adapter-node built doesn’t match the page’s origin. On node build over plain http locally, use vite preview. Behind a proxy that rewrites Host, set paths.origin or PROTOCOL_HEADER/HOST_HEADER.

upload failed (403) on a keyed upload, with no JSON body. SvelteKit’s CSRF check refused a cross-origin PUT. Add the origin to csrf.trustedOrigins, as well as to allowedOrigins.

Content-length of … exceeds limit of 524288 bytes. A keyed upload hit adapter-node’s default BODY_SIZE_LIMIT. Raise it, or switch to upload(file).

network error during upload and a CORS error in the console. The preflight to storage.googleapis.com didn’t match the bucket’s CORS rule. Check that the page’s exact origin is listed and POST is in method.

upload failed (403) on a keyless upload. GCS refused the form. It expired, the server clock is off, or a field such as Content-Type was changed after signing. A file larger than the policy allows normally stops earlier, at presign with upload exceeds maxUploadSize, because the client declares each file’s size there.

Last updated on

Was this page helpful?