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

Upload files in TanStack Start with server routes and streaming

A TanStack Start server route that sends uploads straight from the browser to S3 under a size-capped POST policy, or streams them through your server.

One route at /api/files, mounted with files-sdk/tanstack-start, handles both upload styles. With upload(file), the route signs an S3 POST form and S3 itself enforces the size cap when the browser sends the file. With upload(key, file), the file goes to the route, and the gateway pipes the request body into an S3 multipart upload a few parts at a time. Default to the first, and use the second only when the bytes must pass through your server.

The catch is server functions. TanStack Start parses a multipart/form-data server function call with request.formData() before your validator runs, so a file sent that way is read in full before your code can check its size. Send file bytes to a server route, not a server function.

Before you start

  • An AWS account with an S3 bucket (this guide calls it uploads) and credentials whose policy allows s3:PutObject, s3:GetObject, s3:DeleteObject, and s3:AbortMultipartUpload on the bucket’s objects, plus s3:ListBucket on the bucket.
  • A TanStack Start app with an auth library that can resolve the signed-in user from request headers.
  • A long-running Node.js server deployment. The streamed path keeps a connection open for the whole upload, which serverless hosts limit (see Limits and tradeoffs).
  • Written against files-sdk 3.0, @tanstack/react-start 1.168, @tanstack/react-router 1.170, and React 19.3.
npm install files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage
pnpm add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage
yarn add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage
bun add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage
nub add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage
aube add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage

@aws-sdk/s3-presigned-post builds the POST policy for the direct path. @aws-sdk/lib-storage uploads a stream of unknown length as multipart, which the streamed path needs.

Choose an upload path

Both paths go through the same route and the same authorize hook. They differ in where the bytes travel and what enforces the limits.

upload(file) (keyless) upload(key, file) (keyed)
Requests Presign to /api/files, POST to S3, then complete One PUT /api/files?op=upload&key=…
Bytes through your server No Yes, streamed
Who picks the key The server: <prefix><uuid>.<ext> The client, inside its keyPrefix
maxUploadSize enforced by The gateway at presign (the declared size), then S3 (the policy’s content-length-range) The gateway, which counts bytes and aborts
Content-Type Bound by the policy to the type the browser claimed Whatever header the browser sends
Bucket CORS rule Required Not needed
Server memory per upload None A few 5 MiB parts at a time

Pick the keyed path when the bytes must pass through code you run: a plugin that reads the body, such as contentType() sniffing or encryption(), a bucket the browser can’t reach, or a bucket where you can’t add a CORS rule. Otherwise the keyless path costs your server nothing per byte.

Add the credentials

AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
# Signs the presign → complete token. Generate with: openssl rand -hex 32
FILES_API_SECRET=a-long-random-string

The S3 adapter uses the AWS credential chain, so on infrastructure with an IAM role you can leave the two AWS_* keys out. Set FILES_API_SECRET to the same value on every instance. Without it, each process signs upload tokens with its own random secret, and a complete request that lands on a different instance fails with upload token signature.

Mount the gateway as a server route

A route file with a server.handlers object and no component is an API route in TanStack Start. createRouteHandler from files-sdk/tanstack-start returns that object: GET serves downloads, POST the JSON operations, and PUT the upload bytes.

import { createFileRoute } from "@tanstack/react-router";
import { FilesError, createFiles } from "files-sdk";
import { createFilesRouter, type FilesOperation } from "files-sdk/api";
import { s3 } from "files-sdk/s3";
import { createRouteHandler } from "files-sdk/tanstack-start";

import { getSession } from "../../lib/auth";

const files = createFiles({
  adapter: s3({ bucket: "uploads", region: "us-east-1" }),
});

// The verbs this app's UI calls. Everything else is refused.
const ALLOWED = new Set<FilesOperation>([
  "upload",
  "list",
  "head",
  "download",
  "delete",
]);

const router = createFilesRouter({
  files,
  maxUploadSize: 100 * 1024 * 1024, // 100 MiB, on both upload paths
  authorize: async ({ operation, key, req }) => {
    const session = await getSession(req.headers);
    if (!session) {
      throw new FilesError("Unauthorized", "Sign in to manage files");
    }
    if (!ALLOWED.has(operation)) {
      throw new FilesError("ReadOnly", `${operation} is not allowed`);
    }
    // A keyed upload streams through this server. Keep it to one folder.
    if (operation === "upload" && key && !key.startsWith("videos/")) {
      throw new FilesError("ReadOnly", "Streamed uploads go under videos/");
    }
    return { keyPrefix: `users/${session.user.id}/`, maxExpiresIn: 300 };
  },
});

export const Route = createFileRoute("/api/files")({
  server: { handlers: createRouteHandler(router) },
});

getSession stands in for your auth library: Better Auth’s auth.api.getSession({ headers }), Clerk’s request helpers, or your own cookie check. This guide assumes it takes request headers and returns { user: { id: string } } or null.

What the route does with each request:

  • authorize runs first, every time. Throw a FilesError: Unauthorized becomes a 401 and ReadOnly a 403. A plain Error becomes a 500. Authorization covers the rest of the returned constraint.
  • key tells the two paths apart. A presign request authorizes as upload with no key, because the server mints it. A keyed PUT authorizes as upload with the client’s key, before the prefix is added. The hook above lets users stream only into videos/.
  • maxUploadSize applies to both paths. On the keyless path, presign refuses a file that declares a larger size with 422, and the limit goes into the S3 policy for the rest. On the keyed path the gateway counts bytes as they stream.
  • TanStack Start doesn’t read the body for you. Its server-route dispatch passes the Request to your handler untouched, and the gateway hands request.body to the adapter as a stream.

Let the browser POST to S3

With maxUploadSize set, the S3 adapter answers a presign with a presigned POST: a form URL plus signed fields. The policy inside those fields fixes the key, requires the Content-Type the browser claimed, and allows anything from 0 bytes to maxUploadSize. S3 checks each condition when the form arrives.

The browser sends that form cross-origin, so the bucket needs a CORS rule. In the S3 console, open the bucket’s Permissions tab and edit Cross-origin resource sharing (CORS):

[
  {
    "AllowedOrigins": ["http://localhost:3000", "https://app.example.com"],
    "AllowedMethods": ["POST"],
    "MaxAgeSeconds": 3600
  }
]

POST is the only method the browser uses against S3 here. The upload is sent with XMLHttpRequest so it can report progress, and an XMLHttpRequest with upload listeners always sends a preflight OPTIONS first. That preflight is what this rule answers. Downloads are top-level navigations to a signed URL, which need no CORS rule.

Build the upload page

import { createFileRoute } from "@tanstack/react-router";
import { useFiles, useList } from "files-sdk/react";
import type { ChangeEvent } from "react";

export const Route = createFileRoute("/files")({
  component: FilesPage,
});

function FilesPage() {
  const files = useFiles();
  const list = useList({ limit: 100 });

  async function onSelect(event: ChangeEvent<HTMLInputElement>) {
    const selected = [...(event.target.files ?? [])];
    event.target.value = "";
    // Keyless: presign, then the browser POSTs straight to S3.
    await Promise.allSettled(selected.map((file) => files.upload(file)));
    list.refetch();
  }

  return (
    <main>
      <label>
        Upload files
        <input multiple onChange={onSelect} type="file" />
      </label>

      <ul>
        {files.uploads.map((upload, index) => (
          <li key={`${upload.name}-${index}`}>
            {upload.name}: {upload.status}
            {upload.status === "uploading" &&
              ` ${Math.round(upload.progress * 100)}%`}
            {upload.error && ` (${upload.error.message})`}
          </li>
        ))}
      </ul>

      {list.error && <p role="alert">{list.error.message}</p>}
      <ul>
        {list.data?.items.map((item) => (
          <li key={item.key}>
            <a
              href={`/api/files?op=download&key=${encodeURIComponent(item.key)}`}
            >
              {item.key}
            </a>{" "}
            ({item.size} bytes)
          </li>
        ))}
      </ul>
    </main>
  );
}

useFiles talks to /api/files by default. Each upload(file) runs presign, the POST to S3, and complete, and keeps one entry in files.uploads that moves from "uploading" to "success", "error", or "aborted". The listed keys are relative to the user’s prefix, and the download link gets a 302 to a presigned S3 GET that lives at most 300 seconds. React documents the rest of the hook.

Stream an upload through your server

Give upload a key and it skips presign. The browser sends one PUT to /api/files with the file as the body. Add a handler like this to FilesPage and attach it to a second file input:

async function onSelectVideo(event: ChangeEvent<HTMLInputElement>) {
  const selected = [...(event.target.files ?? [])];
  event.target.value = "";
  // Keyed: one PUT to /api/files, streamed through the server to S3.
  await Promise.allSettled(
    selected.map((file) =>
      files.upload(`videos/${crypto.randomUUID()}.mp4`, file)
    )
  );
  list.refetch();
}

Here is what the server does with that PUT, from the gateway and adapter source:

  1. The gateway checks authorize and the request’s origin. If Content-Length is larger than maxUploadSize, it answers 422 with upload exceeds maxUploadSize before reading the body.
  2. It pipes request.body through a byte counter and passes the stream to files.upload(). The counter errors the stream once the total passes maxUploadSize.
  3. The S3 adapter sees a stream with no known length and hands it to @aws-sdk/lib-storage. lib-storage cuts the stream into 5 MiB parts and keeps up to four part uploads running, so server memory holds a few parts at a time rather than the whole file.
  4. If the stream errors, because the limit tripped or the connection dropped, the adapter aborts the multipart upload (leavePartsOnError: false), so no partial object is left behind. On success, it reads the stored size back with a HeadObject.

Against a local MinIO server with an 8 MiB limit, a 6 MiB streamed upload landed as a two-part multipart object. An 11 MiB stream sent without Content-Length returned 422 upload exceeds maxUploadSize, and neither the object nor an unfinished multipart upload remained in the bucket.

The upload holds a server connection for as long as it takes the browser to send the file, and every byte crosses your server’s network twice: in from the browser, out to S3.

Why not a server function with FormData

This is the pattern the server-function docs suggest for forms, and it works for small files:

import { createServerFn } from "@tanstack/react-start";
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";

const files = createFiles({
  adapter: s3({ bucket: "uploads", region: "us-east-1" }),
});

// The whole request body is parsed before this validator runs.
export const uploadFile = createServerFn({ method: "POST" })
  .validator((data: FormData) => data)
  .handler(async ({ data }) => {
    const file = data.get("file");
    if (!(file instanceof File)) {
      throw new Error("Expected a file");
    }
    await files.upload(`uploads/${crypto.randomUUID()}`, file);
  });

In @tanstack/start-server-core 1.169, the version @tanstack/react-start 1.168 installs, the server-function handler checks the request’s Content-Type. For multipart/form-data or application/x-www-form-urlencoded, it calls await request.formData() and only then invokes your function. formData() resolves after the entire body has arrived, so the whole file is held before your validator can look at its size, and a size check in the validator runs too late to save the memory. Passing that File to files.upload() then reads it into a byte array for the S3 request.

TanStack’s open feature request TanStack/router#5704 asks for access to the raw body in server functions. Until something like it ships, keep file bytes out of server functions. Server functions are fine for the small JSON calls around an upload, such as saving the returned key to a database record.

Limits and tradeoffs

  • The policy checks the claimed type, not the bytes. On the keyless path, S3 stores the object with the Content-Type the browser declared and rejects a form that changes it, but a file of HTML labelled image/png passes. The keyed path stores whatever header the browser sends. To decide the type from the bytes, see Enforce file-size and content-type limits on presigned uploads.
  • Keep maxUploadSize set. Without it, the S3 adapter signs a presigned PUT instead of a POST. That PUT binds the Content-Type the browser claimed, so S3 refuses a different one, but it has no size limit, and complete has no limit to check either. (The PUT also needs PUT and the Content-Type header in the CORS rule.)
  • Body-reading plugins turn keyless uploads into proxied ones. contentType(), and validation() with a size or type rule, refuse to sign upload URLs because they can’t inspect bytes they never see, and turn off files.capabilities.signedUpload. The gateway checks that and uses its proxy path instead, so upload(file) then streams through your server too.
  • Complete is the last check. After a direct upload, the complete step heads the object. One larger than maxUploadSize is deleted and reported as an error. With S3’s policy in place that case shouldn’t arise. Against a local MinIO server with a 1000-byte limit, a 2000-byte object written to the minted key from the server side came back from complete as uploaded object is 2000 bytes, exceeds maxSize 1000, and was gone afterwards.
  • Empty files pass. The gateway signs the policy with a minimum of 0 bytes, so a 0-byte file uploads and completes. Check file.size in the browser, or the size complete returns, if empty files aren’t useful to you.
  • Keyed uploads can overwrite. A user can PUT to a key they already wrote. Check key in authorize, or generate unique keys on the client as above.
  • Serverless hosts cap request bodies. On Vercel, function request bodies are limited to 4.5 MB, so the streamed path fails above that. Fix Vercel’s 413 upload error covers the platform side. The keyless path never sends file bytes to the function.
  • Cloudflare Workers can’t run @aws-sdk/client-s3, which needs a DOMParser. s3Fetch() runs there, but it has no POST policy and buffers streamed bodies, so neither path above carries over unchanged.
  • Crashed uploads leave parts. If the server process dies mid-stream, the abort never runs. Add a lifecycle rule that aborts incomplete multipart uploads after a day or two.

Troubleshooting

upload exceeds maxUploadSize. The gateway refused the file. On a keyless upload that happens at presign, from the size the browser declared, before anything is signed. On a keyed upload it happens up front from Content-Length, or partway through the stream. Check file.size in the browser first for a friendlier message.

upload failed (400) on a keyless upload. S3 rejected the form. A body larger than the policy allows gets EntityTooLarge, but the client declares each file’s real size at presign, so an oversized file normally stops there instead. Open the response in the network panel and read S3’s <Code>.

upload failed (403) on a keyless upload. S3 refused the form’s signature or a policy condition. The form expired, the server clock is off, or a field such as Content-Type was changed after signing.

network error during upload and a CORS error in the console. The preflight to S3 didn’t match a rule. Check that the page’s exact origin is in AllowedOrigins and POST is in AllowedMethods.

Multipart, progress, and unknown-length stream uploads on S3 require the optional peer dependency '@aws-sdk/lib-storage'. The keyed path always streams. Install @aws-sdk/lib-storage.

Keyed uploads fail intermittently with undefined is not a function under bun --bun. Bun 1.3 fails reading a request body through an async generator, which is how lib-storage reads streams. files-sdk#96 traced it; Bun 1.4 or running the dev server under Node fixes it.

<img> tags pointing at /api/files?op=download break in development only. The nitro dev server can 404 image requests to a catch-all route before your handler runs (files-sdk#131). Add "url" to the allowed operations, get a signed URL with files.url(key), and use it as the src.

Last updated on

Was this page helpful?