Tanstack Start

Route handler

Broker browser uploads while your API key stays on the server.

createRouteHandler builds a small upload broker you mount on your server. The browser SDK talks to it for the create/complete handshake, so your secret API key never reaches the client. The file bytes still go straight to the bucket — the handler only signs and authorizes.

// app/api/s3delivery/route.ts  (Next.js App Router)
import { S3Delivery, createRouteHandler } from "s3delivery";

export const POST = createRouteHandler(new S3Delivery(), {
  constraints: { maxFileSize: 10 * 1024 * 1024, allowedFileTypes: ["image/*"] },
});

Signature

createRouteHandler(
  client: S3Delivery,
  options?: {
    constraints?: { maxFileSize?: number; allowedFileTypes?: string[] };
    beforeUpload?: (ctx: { request: Request; input: CreateUploadInput })
      => void | Partial<CreateUploadInput> | Promise<void | Partial<CreateUploadInput>>;
    onUploadComplete?: (ctx: { request: Request; file: S3DeliveryFile })
      => void | Promise<void>;
  },
): (request: Request) => Promise<Response>;

It returns a framework-agnostic (Request) => Promise<Response>. Mount it on any runtime that speaks the Web Request/Response API — Next.js App Router, Hono, Remix, SvelteKit, Bun, Deno, or Cloudflare Workers. See Framework integrations.

The handler accepts POST only; other methods get a 405. The browser sends two kinds of body — { action: "create", … } and { action: "complete", … } — which the SDK manages for you.

constraints

Per-route limits enforced server-side, before any upload URL is minted. This is the authoritative check — a client can mirror the same values for instant feedback, but cannot skip these.

createRouteHandler(s3, {
  constraints: {
    maxFileSize: 10 * 1024 * 1024, // bytes
    allowedFileTypes: ["image/*", "application/pdf"],
  },
});
FieldEffect
maxFileSizeRejects an upload whose size exceeds this, with FILE_TOO_LARGE (413).
allowedFileTypesRejects a content type not in the list, with TYPE_NOT_ALLOWED (415). Supports exact types ("application/pdf") and wildcard subtypes ("image/*"); matching is case-insensitive.

maxFiles is client-only

The handler signs one file at a time, so a maxFiles count is meaningless here and is ignored. Set maxFiles in the browser upload constraints instead.

beforeUpload

Runs before an upload URL is minted — your place to authenticate the request and constrain the upload. Unlike a webhook, it runs synchronously inside your own server.

  • Throw to reject the upload. The thrown error becomes the response.
  • Return a partial CreateUploadInput to override fields — for example, force visibility, or clamp the filename.
  • Return nothing to accept the input as-is.
createRouteHandler(s3, {
  beforeUpload: async ({ request, input }) => {
    const user = await getUser(request);
    if (!user) throw new Error("Unauthorized");

    // Force every upload from this route to be private, namespaced by user.
    return { visibility: "private", filename: `${user.id}/${input.filename}` };
  },
});

The ctx.input is { filename, contentType?, size, visibility? }. Any override you return is merged over it, and constraints are checked against the merged result.

onUploadComplete

Runs inline when an upload is confirmed complete. Because it is a normal request to your own server, there is no public callback URL or dev tunnel to set up — a common pain point with webhook-based upload SDKs.

createRouteHandler(s3, {
  onUploadComplete: async ({ request, file }) => {
    const user = await getUser(request);
    await db.files.insert({
      id: file.id,
      ownerId: user.id,
      url: file.url,
      name: file.name,
      size: file.size,
    });
  },
});

The file is the fully-confirmed S3DeliveryFile (status ready), so this is the right place to persist it to your database, kick off processing, or send a notification.

How errors surface

The handler catches S3DeliveryError and returns it as JSON with the matching HTTP status:

{ "error": { "code": "FILE_TOO_LARGE", "message": "…" } }

The browser SDK re-throws these as an S3DeliveryError with the same code, so you can branch on error.code on the client. Unknown errors return a generic 500 with code INTERNAL.

Adding auth headers from the browser

If your route handler authenticates the request (recommended), the browser needs to send credentials. Pass headers to the uploader:

import { useUploadFiles } from "s3delivery/react";

const { upload } = useUploadFiles({
  headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
});

headers accepts a static object or a function returning one (sync or async), so you can refresh a token per upload. See Browser uploads.

On this page