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-nodecaps request bodies at 512 KB.- adapter-node assumes
httpswhen 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 ahandlehook that setsevent.locals.user. The type below assumes{ id: string } | null. - Written against files-sdk 3.0,
@sveltejs/kit3.0,@sveltejs/adapter-node6.0, Svelte 5.57, Vite 8.3, and@google-cloud/storage8.4. - The SvelteKit behavior below was observed on a production build (
node buildandvite preview), with a local MinIO server standing in for GCS. The GCSPOSTpolicy 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/storagepnpm add files-sdk @google-cloud/storageyarn add files-sdk @google-cloud/storagebun add files-sdk @google-cloud/storagenub add files-sdk @google-cloud/storageaube add files-sdk @google-cloud/storagedeclare 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.
createRouteHandlerreturns theGET,POST, andPUTexports SvelteKit expects.GETserves downloads,POSTthe JSON operations (presign, complete, delete, head), andPUTthe upload bytes. getRequestEvent()bridges tolocals. The handlers passevent.requestto the gateway, andauthorizereceives that sameRequest, not the SvelteKit event.getRequestEvent()from$app/serverreturns the event for the current request,localsincluded. A logged run confirmed thatgetRequestEvent().requestis the exact objectauthorizereceives. It relies onAsyncLocalStorage, becauseauthorizeruns after the gateway has awaited the request body. adapter-node has it. On other adapters, check that their runtime providesnode:async_hooks.authorizegates every operation.Unauthorizedbecomes a401, andReadOnlya403. An unauthenticatedPOSTanswered401withSign in to manage files. Alistcall answered403withlist is not allowed, because the page never needs the gateway to list.maxUploadSizegoes into the policy. Forupload(file), the gateway asks the adapter for aPOSTpolicy instead of aPUTURL. A policy generated through the gateway for a PDF carried["content-length-range", 0, maxUploadSize], an exact match onContent-Type: application/pdf, and the minted keyusers/42/<uuid>.pdf. GCS checks each condition when the form arrives (policy document conditions).FILES_API_SECRETsigns 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, orDELETEfrom another origin when itsContent-Typeistext/plain,multipart/form-data, orapplication/x-www-form-urlencoded, or, new in SvelteKit 3, when it has noContent-Typeat all. It compares theOriginheader topaths.originif 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 asheadanddownloaddon’t check it. It comparesOrigintoallowedOriginsif 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-Typethe 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: 300keeps 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()tiesauthorizeto SvelteKit. The same router mounted in another framework would throw there. If it has to be portable, read the session fromreqinstead.
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.