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

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, and Range requests 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/206 support. 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:

  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.
  2. upload — the client PUT/POSTs the bytes directly to storage (or proxies through ?op=proxy for non-presigning adapters), reporting progress.
  3. 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/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 page’s limit is clamped to maxListLimit. 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, 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.

Was this page helpful?