# Route handler (/docs/route-handler)



`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.

```ts
// 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 [#signature]

```ts
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](/docs/frameworks).

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` [#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.

```ts
createRouteHandler(s3, {
  constraints: {
    maxFileSize: 10 * 1024 * 1024, // bytes
    allowedFileTypes: ["image/*", "application/pdf"],
  },
});
```

| Field              | Effect                                                                                                                                                                                 |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maxFileSize`      | Rejects an upload whose `size` exceeds this, with `FILE_TOO_LARGE` (`413`).                                                                                                            |
| `allowedFileTypes` | Rejects 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. |

<Callout title="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](/docs/browser-uploads#constraints) instead.
</Callout>

## `beforeUpload` [#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.

```ts
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` [#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.

```ts
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`](/docs/server-client#the-s3deliveryfile-shape)
(status `ready`), so this is the right place to persist it to your database, kick
off processing, or send a notification.

## How errors surface [#how-errors-surface]

The handler catches [`S3DeliveryError`](/docs/errors) and returns it as JSON with
the matching HTTP status:

```json
{ "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 [#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:

```tsx
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](/docs/browser-uploads).
