---
title: Gateway
description: createFilesRouter exposes the whole Files API over one HTTP endpoint. Mount it in Next (or any Web-Request runtime) and the browser bindings talk to it.
---

`createFilesRouter` turns a `Files` instance into an HTTP handler that the browser bindings ([React](/docs/ui/client/react), [Vue](/docs/ui/client/vue), [Svelte](/docs/ui/client/svelte)) call. It is framework-agnostic — `handle(req: Request): Promise<Response>` — and surfaced by thin adapters like [`files-sdk/next`](/docs/ui/server/next).

```ts title="app/api/files/route.ts" lineNumbers
import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { createFilesRouter } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";

const router = createFilesRouter({
  files: createFiles({ adapter: r2({ bucket: "uploads" }) }),
  authorize: async ({ req }) => {
    /* … */
  },
});

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

`GET` serves downloads, `POST` the JSON verbs, and `PUT` the upload byte path. The handler is Web-native (`Request`/`Response`, `crypto.subtle`, `ReadableStream`), so the same code runs on Node **and** the Edge runtime.

Passing a `Files` instance (not a raw adapter) is deliberate: its `prefix`, `readonly`, `plugins`, `hooks`, and `receipts` all compose for free. Pass `files.readonly()` for a read-only deployment so writes are refused at the SDK layer too.

## Options

```ts lineNumbers
createFilesRouter({
  // A Files instance, or a per-request factory for multi-tenant apps.
  files: Files | ((req: Request) => Files | Promise<Files>),

  // Per-operation gate. Deny-by-default when omitted. See /ui/server/authorization.
  authorize?: (ctx) => void | Constraint | Promise<void | Constraint>,

  // Declarative allow-list — operations permitted without a hook.
  operations?: FilesOperation[],

  // CSRF/origin allow-list for state-changing actions. Defaults to same-origin.
  allowedOrigins?: string[] | ((origin: string) => boolean),

  // Expiry for url()/download/upload URLs when the client doesn't ask for one,
  // seconds. Default 300. Not a ceiling — cap it with authorize's maxExpiresIn.
  defaultExpiresIn?: number,

  // Force Content-Disposition: attachment on downloads (proxied, or bound
  // into the redirect URL). Default true.
  forceDownloadDisposition?: boolean,

  maxListLimit?: number,       // cap a list page; also the page size a search walks with. Default 1000.
  maxSearchResults?: number,   // cap a search page. Default 1000.
  maxSearchScan?: number,      // keys one search reads before answering truncated: true. Default 10000.
  maxUploadSize?: number,      // reject larger uploads (checked at presign, bound, verified on complete).

  // Runs once per verified upload; its result is the client's upload `data`.
  // Throw to reject (the object is deleted). See /ui/server/upload-lifecycle.
  onUploadComplete?: (ctx) => TData | Promise<TData>,
  onRejected?: "delete" | "keep", // rejected objects. Default "delete".
  completions?: CompletionStore,  // make keyless completions single-use.
  completeGracePeriod?: number,   // seconds `complete` still accepts an expired upload token. Default 3600.

  // Gets every failure whose detail the client doesn't see: anything thrown
  // that isn't a FilesError or RouterError (a generic 500), and a storage
  // Provider/Unauthorized error (sent with a fixed message). Default console.error.
  onError?: (error: unknown, req: Request) => void,

  // Limits on client-driven load (see "Request limits" below).
  maxBatchSize?: number,           // keys[]/files[]/completions[] per request. Default 1000.
  maxConcurrency?: number,         // ceiling on a bulk op's `concurrency`. Default 16.
  maxJsonBodySize?: number,        // JSON request body, bytes. Default 1 MiB.
  maxSearchPatternLength?: number, // search pattern, characters. Default 256.
  maxSearchWildcards?: number,     // unbounded wildcards per search pattern. Default 4.

  downloadMode?: "auto" | "redirect" | "proxy",      // default "auto"
  onUnsupportedRange?: "reject" | "ignore",          // default "reject" (416)

  // HMAC secret for the upload round-trip. Falls back to FILES_API_SECRET,
  // then a per-process random (logs a warning — set a stable secret in prod).
  secret?: string,
});
```

## How downloads flow

`downloadMode: "auto"` (the default) picks the cheapest correct path per adapter:

- **Redirect to a signed URL** — when the adapter can sign (S3, R2, GCS, …) and bind the forced `Content-Disposition` into the signature, the gateway `302`s to a short-lived signed URL and the bytes flow **directly from storage**. Your server never touches them, and `Range` requests are handled by the provider. When [`authorize`](/docs/ui/server/authorization) returns `{ disposition: "inline" }`, nothing needs binding, so an adapter that can't set a disposition is redirected too, with a URL that carries none.
- **Redirect to the public link** — when no disposition must be bound (`forceDownloadDisposition: false`, or an inline policy) and `authorize` sets no `maxExpiresIn`, an adapter with a permanent public link (a `publicBaseUrl` CDN, a public bucket or blob) is redirected to that link.
- **Proxy** — otherwise: the adapter can't sign (Vercel Blob in public mode, the filesystem, …), or it signs but can't bind the forced `attachment` (Vercel Blob and UploadThing in private mode, Box, PocketBase, …). The gateway streams the body through itself, setting the disposition itself and answering `Range` requests with `206` slices when the adapter can read ranges (see below for adapters that can't). The client's abort signal is wired through, so a disconnect cancels the upstream read.

The `url` operation follows the same rules: it signs whenever the adapter can (with your `defaultExpiresIn` unless the client asks for one), and on an adapter that only has permanent links, a client-requested `expiresIn` or an `authorize` `maxExpiresIn` is answered with a `422` instead of a link that outlives it.

Force one with `downloadMode: "redirect" | "proxy"`. Downloads send `Content-Disposition: attachment` by default (so a stored `.html`/SVG can't execute inline at your origin) unless [`authorize`](/docs/ui/server/authorization) returns `{ disposition: "inline" }`. The proxy path sets the header itself, and the redirect path binds it into the signed URL. Proxied downloads also send `X-Content-Type-Options: nosniff`, so the browser won't sniff stored bytes into a more dangerous type, and `Cache-Control: private, no-store`, so a shared cache never keeps an authorized response. Except for raster images, audio, video, and PDF, they also carry `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`: a stored HTML or SVG file opened inline from your origin gets an opaque origin, loads nothing, and runs no script. A stored content type that isn't exactly one media type (`image/png, text/html`, which a browser reads as its last entry) is sandboxed too, and the gateway refuses such a type from a client with `422` (`reason: "type"`) on the keyed upload `PUT`, `presign`, and `signed-upload-url`.

The proxy's default `attachment` names the file after the key's last segment (`Content-Disposition: attachment; filename="report.pdf"`, plus an RFC 6266 `filename*` for a non-ASCII name), so a download opened straight from its `?op=download` URL saves under its own name. A disposition `authorize` returns is sent exactly as given. Proxied downloads also answer `HEAD` with the headers a `GET` would send (`Content-Length`, `ETag`, `Last-Modified`, the disposition) and no body, without reading the object. On a full `200` response, the base64 JSON `X-Files-Meta` header carries the object's `size` alongside its key and metadata, so the browser client still knows the size when compression middleware drops `Content-Length`.

Some adapters can sign a URL but can't bind a `Content-Disposition` into it (Vercel Blob in private mode, Box, Dropbox, PocketBase, …), and their `url()` refuses the option rather than drop it. With `downloadMode: "auto"`, the gateway proxies those downloads instead, so the attachment header still applies. More generally, whenever `url()` refuses with `Unsupported` on the signed or the public-link path, `auto` falls back to the proxy. With `downloadMode: "redirect"` there is no fallback, and the refusal surfaces as an error. When `authorize` returns `{ disposition: "inline" }`, inline rendering is already your policy, so the gateway redirects to a URL minted without a disposition.

On the proxy path, `Accept-Ranges` reflects the adapter: `bytes` when it can serve ranges, `none` when it can't. A `Range` request that carries an `If-Range` validator (the `ETag` or date from an earlier response) only gets a `206` slice while the object is unchanged. If the object changed, the gateway sends the whole new object with a `200`, so a resumed download never splices bytes from two versions. Proxied responses carry `ETag` as a quoted entity-tag (even when the adapter reports it bare, as the S3 family does) and `Last-Modified` when the adapter knows it, and `If-Range` matches either the quoted or the bare form.

A ranged request to a non-range adapter gets `416` with the default `onUnsupportedRange: "reject"`, with one exception. A `416` carries a JSON error body (`Validation`, `reason: "range"`), which the browser client turns into an `Invalid` error. `bytes=0-` (the open-ended range every `<video>` and `<audio>` element starts with) is satisfied by the whole body, so it gets the full object with a `200`. With `onUnsupportedRange: "ignore"` any ranged request to a non-range adapter gets the whole object with a `200`, and the browser client rejects that instead of treating it as the slice.

The JSON `url` operation applies the same safe default before calling `files.url()`: a browser request for `responseContentDisposition: "inline"` is rewritten to `attachment`, while an existing `attachment; filename="..."` value keeps its filename. Return `{ disposition: "inline" }` from `authorize` only for routes where inline rendering is deliberate server policy.

On an adapter that can't bind a disposition into its URLs, the gateway can't guarantee `attachment`, so the `url` operation fails with a `422` `Unsupported` error instead of handing out a URL that may render inline. The message says how to proceed: return `{ disposition: "inline" }` from `authorize` for routes where inline URLs are acceptable (the URL is then minted without a disposition), or use `download`, which proxies the bytes and sets `Content-Disposition` itself.

## How uploads flow

A keyless `upload(file)` runs a three-step protocol so bytes never round-trip through your server when they don't have to:

1. **presign** — the server mints a key (under the authorized prefix), signs an HMAC token binding the key and size/type constraints, and returns either a real presigned URL or a proxy target. The token holds the upload to the `size` the client declared (the size `authorize` approved), capped by `maxUploadSize`, and it is bound to the endpoint that minted it: the request's origin, path, and query. A file whose declared `size` is over `maxUploadSize` is refused up front with `422` and `reason: "size"`, before anything is signed. A batch is signed at most `maxConcurrency` targets at a time. A proxy target's token isn't capped by the adapter's `signedUpload.maxExpiresIn`, which only limits the provider's own presigned URLs.
2. **upload** — the client `PUT`/`POST`s the bytes directly to storage (or proxies through `?op=proxy` for non-presigning adapters), reporting progress. Like a presigned URL, the proxy `PUT` is authorized by its token alone (see [Authorization](/docs/ui/server/authorization)).
3. **complete** — the server verifies the token, checks that its key lies under the calling request's authorized prefix, and `head`s the object, then returns the stored metadata. A token minted for another caller's prefix gets an `Unauthorized` entry with no stored metadata. (The token is signed, not encrypted: its payload names the key it was minted for, so treat a token as readable by whoever holds it.) The `head` is the authoritative size check: an object larger than the token allows (the declared size, capped by `maxUploadSize`) gets an error entry and is deleted. The server then runs [`onUploadComplete`](/docs/ui/server/upload-lifecycle) and returns the stored metadata with whatever the hook returned.

An explicit `upload(key, body)` skips presign and streams straight through the gateway, then runs `onUploadComplete` the same way. The stream reaches the adapter with no known length, so the S3-family adapters on their AWS SDK client need the optional `@aws-sdk/lib-storage` package for this path.

## Request limits

Every JSON request is untrusted input that turns into storage calls, so the gateway bounds how much work one request can ask for:

- **Batch size.** `keys[]` (`head`/`exists`/`delete` bulk forms), `files[]` (`presign`) and `completions[]` (`complete`) are capped at `maxBatchSize` (default 1000). A larger request gets `413` with `reason: "count"` before any storage call.
- **Concurrency.** A bulk op's client-supplied `concurrency` is clamped to `maxConcurrency` (default 16).
- **Body size.** A JSON body over `maxJsonBodySize` (default 1 MiB) gets `413` with `reason: "size"`. It is refused from `Content-Length` up front, or as soon as a streamed body passes the cap, without buffering the rest.
- **Search.** A `search` walks the bucket in pages of `maxListLimit` keys whatever `limit` the client sends (`maxResults` caps the matches), and one request reads at most `maxSearchScan` keys (default 10,000), matching or not, before it stops and answers `truncated: true`, so a pattern that matches nothing can't walk the whole bucket. Patterns are also bounded: at most `maxSearchPatternLength` characters (default 256) and `maxSearchWildcards` unbounded wildcards or quantifiers (default 4). `*`, `**`, `+`, and `{n,}` each count, and an unanchored regex counts one more. Nested repetition such as `(a+)+` is always refused. A pattern over either limit gets `422` before any key is matched. Globs and regexes both run on a backtracking engine, where each wildcard multiplies the worst-case work per key, so this bounds what one request can cost in CPU. Raise the limit if your patterns need more, or lower it if `search` is open to untrusted users.

## Other frameworks

The gateway core accepts any `{ handle(req: Request): Promise<Response> }` consumer, so a binding is a few lines. Ready-made adapters ship for [Next.js](/docs/ui/server/next), [Hono](/docs/ui/server/hono), [Express](/docs/ui/server/express), [Fastify](/docs/ui/server/fastify), [Koa](/docs/ui/server/koa), [NestJS](/docs/ui/server/nestjs), [Nitro](/docs/ui/server/nitro), [SvelteKit](/docs/ui/server/sveltekit), [Astro](/docs/ui/server/astro), and [TanStack Start](/docs/ui/server/tanstack-start). For anything else (Elysia, Bun, Deno, …), call `router.handle(request)` directly from any handler that gives you a Web `Request`.
