Gateway
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, Vue, Svelte) call. It is framework-agnostic — handle(req: Request): Promise<Response> — and surfaced by thin adapters like files-sdk/next.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { createFilesRouter } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";
const router = createFilesRouter({
files: createFiles({ adapter: s3({ 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
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 proxied downloads. Default true.
forceDownloadDisposition?: boolean,
maxListLimit?: number, // cap a list page (and a search's walk page). Default 1000.
maxSearchResults?: number, // cap a search page. Default 1000.
maxUploadSize?: number, // reject larger uploads (bound + verified).
// 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 — when the adapter can sign URLs (S3, R2, GCS, …), the gateway
302s to a short-lived signed URL and the bytes flow directly from storage. Your server never touches them, andRangerequests are handled by the provider. - Proxy — when the adapter can’t sign (Vercel Blob in public mode, the filesystem, …), the gateway streams the body through itself with full
Range/206support. The client’s abort signal is wired through, so a disconnect cancels the upstream read.
Force one with downloadMode: "redirect" | "proxy". Proxied downloads send Content-Disposition: attachment by default (so a stored .html/SVG can’t execute inline at your origin) unless authorize returns { disposition: "inline" }. They also send X-Content-Type-Options: nosniff, so the browser won’t sniff stored bytes into a more dangerous type.
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. With onUnsupportedRange: "ignore" a ranged request to a non-range adapter also 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.
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:
- 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.
- upload — the client
PUT/POSTs the bytes directly to storage (or proxies through?op=proxyfor non-presigning adapters), reporting progress. - complete — the server verifies the token and
heads the object — the authoritative size check that rejects an oversized “unbounded” upload — then returns the stored metadata.
An explicit upload(key, body) skips presign and streams straight through the gateway. 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/deletebulk forms),files[](presign) andcompletions[](complete) are capped atmaxBatchSize(default 1000). A larger request gets413withreason: "count"before any storage call. - Concurrency. A bulk op’s client-supplied
concurrencyis clamped tomaxConcurrency(default 16). - Body size. A JSON body over
maxJsonBodySize(default 1 MiB) gets413withreason: "size". It is refused fromContent-Lengthup front, or as soon as a streamed body passes the cap, without buffering the rest. - Search. A
searchpage’slimitis clamped tomaxListLimit. Patterns are also bounded: at mostmaxSearchPatternLengthcharacters (default 256) andmaxSearchWildcardsunbounded 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 gets422before 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 ifsearchis 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, Hono, Express, Fastify, Koa, NestJS, Nitro, SvelteKit, Astro, and TanStack Start. For anything else (Elysia, Bun, Deno, …), call router.handle(request) directly from any handler that gives you a Web Request.