End-to-end inference
Route names, multiplicity, and return shapes flow from your server definition to the client — zero manual type duplication.
Define upload routes once on the server — constraints, auth, storage keys, transforms, hooks. Get a fully inferred typed client on the frontend.
// server — define once export const uploads = uplift({ storage: s3({ bucket, region }), routes: { avatar: image() .max("2mb") .auth(async ({ req }) => getUser(req)) .key(({ user }) => `avatars/${user.id}.png`) .done(async ({ file, user }) => saveToDb(file.url)), gallery: image().max("8mb").multiple(10), resume: pdf().max("5mb"), } }); export type Uploads = typeof uploads; // client — fully inferred from server type const upload = createUploadClient<Uploads>("/api/upload"); const avatar = await upload.avatar(file); // → UploadedFile const gallery = await upload.gallery(files); // → UploadedFile[]
Route names, multiplicity, and return shapes flow from your server definition to the client — zero manual type duplication.
S3, R2, Bunny, Cloudinary, local, memory, and UploadThing-compatible. Safe storage headers and rollback cleanup stay behind the same route contract.
First-class handlers for Next.js App Router, Hono, Express, Fastify, Elysia, SvelteKit, Remix, TanStack Start, and Nuxt. One endpoint, every route.
Return any user shape from .auth() and it flows fully typed into .key(), .meta(), and .done().
Image transforms via Sharp, video via ffmpeg. Both live in optional domain packages — the core stays lean.
XHR-backed progress in browsers. The React hook exposes .progress, .isUploading, .error, and .data per route.
Define routes on the server, mount a framework handler, create a typed client. That's the full API surface for the common case.
import { csv, image, pdf, uplift } from "@uplift-io/uplift"; import { s3 } from "@uplift-io/s3"; export const uploads = uplift({ storage: s3({ bucket: process.env.S3_BUCKET!, region: "us-east-1", accessKeyId: process.env.S3_KEY!, secretAccessKey: process.env.S3_SECRET!, }), routes: { avatar: image() .max("2mb") .auth(async ({ req }) => ({ id: req.headers.get("x-user-id")! })) .headers({ "Cache-Control": "public, max-age=31536000" }) .key(({ user }) => `avatars/${user.id}.png`) .done(async ({ file, user }) => { console.log("uploaded", user.id, file.url); }), resume: pdf().max("5mb"), contacts: csv().columns(["email", "name"]), gallery: image().max("8mb").multiple(10), } }); export type Uploads = typeof uploads;
// Next.js App Router — app/api/upload/route.ts import { createNextHandler } from "@uplift-io/next"; import { uploads } from "@/uploads"; export const { HEAD, GET, POST } = createNextHandler(uploads); // Hono import { createHonoHandler } from "@uplift-io/hono"; app.route("/upload", createHonoHandler(uploads)); // Express import { createExpressHandler } from "@uplift-io/express"; app.use("/upload", createExpressHandler(uploads));
import { createUploadClient } from "@uplift-io/uplift/client"; import type { Uploads } from "./uploads"; export const upload = createUploadClient<Uploads>("/api/upload"); // Single file → UploadedFile const avatar = await upload.avatar(file); avatar.url; // string avatar.key; // string // Multiple → UploadedFile[] const gallery = await upload.gallery(fileList); gallery[0].url;
Drop the hook anywhere. Each route method carries its own progress state — no shared progress bar to coordinate.
XHR-backed in browsers for real byte progress. Lifecycle-based in non-browser environments.
upload.avatar.progress
upload.avatar.isUploading
upload.avatar.data
upload.avatar.error
import { useUploads } from "@uplift-io/uplift/react"; import type { Uploads } from "./uploads"; export function AvatarUploader() { const upload = useUploads<Uploads>("/api/upload"); return ( <div> <input type="file" accept="image/*" onChange={(e) => { const f = e.currentTarget.files?.[0]; if (f) void upload.avatar(f); }} /> {upload.avatar.isUploading && ( <progress value={upload.avatar.progress} max={100} /> )} {upload.avatar.data?.url && ( <img src={upload.avatar.data.url} alt="avatar" /> )} </div> ); }
Primary transforms run before key generation and storage. Derived outputs run after the transformed primary file, use convention-based keys, and come back through typed uploaded.output("name") access.
Image and video processing live in optional domain packages. Use .transform(...) for request-time work or .transformAsync(...) for background Transform Jobs with polling, typed outputs, listeners, and Original Upload cleanup.
import { image, video } from "@uplift-io/uplift"; import { resize, convert, variant } from "@uplift-io/image"; import { trim, transcode, thumbnail } from "@uplift-io/video"; routes: { avatar: image() .transform(resize({ width: 512 }), convert("webp")) .outputs(variant("thumb", resize({ width: 96 }))), clip: video() .transformAsync(trim({ start: "00:00:01" }), transcode({ format: "mp4" }), { timeout: "10m" }) .outputs(thumbnail("poster", { at: "25%" })) .listeners({ queued: ({ id }) => console.log(id) }) } // client — typed output access const avatar = await upload.avatar(file); avatar.output("thumb").url; // string const transform = await upload.clip(videoFile); const clip = await transform.done(); // polling fallback clip.output("poster").url; // string
Every builder shares the same chain. .multiple(n) changes both the server context and the client input type automatically — no separate overload to maintain.
json() accepts any .parse() compatible schema. Works with any validation library.
image() .max("2mb") // max file size .min("10kb") // min file size .multiple(10) // up to 10 files .auth(async ({ req }) => user) .key(({ user, file }) => `uploads/${file.name}`) .meta(({ user, file }) => ({ owner: user.id })) .validate(({ file }) => true) .headers({ "Cache-Control": "public, max-age=31536000" }) .done(async ({ file, user, meta }) => {}) // image/video: add transforms and outputs .transform(resize({ width: 512 }), convert("webp")) .outputs(variant("thumb", resize({ width: 96 }))) // json: schema accepts any .parse() compatible json().schema(zodSchema); // csv: file structure uses columns(), not headers() csv().columns(["email", "name"], { delimiter: "," });
Core routes don't care where the file lands. S3 and R2 use @aws-sdk/client-s3. Bunny and Cloudinary use fetch. UploadThing stays optional.
Failed requests attempt best-effort rollback through adapter delete(key). Cloudinary cleanup needs signed server credentials; unsigned uploads still work without them.
Click a provider to see its config.
Core weighs 8.5kb gzip. Storage adapters, framework handlers, and media processors are all separate installs — include only what you use.
| Package | Purpose |
|---|---|
| Core | |
| @uplift-io/uplift | Core builders, server runtime, typed client, React hook |
| Framework Handlers | |
| @uplift-io/next | Next.js App Router handler (HEAD + GET + POST export) |
| @uplift-io/hono | Hono route handler |
| @uplift-io/express | Express middleware handler |
| @uplift-io/fastify | Fastify handler/plugin helpers |
| @uplift-io/elysia | Elysia handler helpers |
| @uplift-io/sveltekit | SvelteKit endpoint handlers |
| @uplift-io/remix | Remix loader/action helpers |
| @uplift-io/tanstack-start | TanStack Start route handlers |
| @uplift-io/nuxt | Nuxt/Nitro-style handler |
| @uplift-io/openapi | OpenAPI generation from Route Manifest |
| Storage Adapters | |
| @uplift-io/s3 | AWS S3 via @aws-sdk/client-s3 |
| @uplift-io/r2 | Cloudflare R2 via S3-compatible API |
| @uplift-io/bunny | Bunny Storage/CDN via fetch |
| @uplift-io/cloudinary | Cloudinary unsigned upload via fetch |
| @uplift-io/local | Local filesystem (dev/testing) |
| @uplift-io/memory | In-memory (CI/tests, zero config) |
| @uplift-io/uploadthing | UploadThing-compatible storage boundary |
| Media Processing | |
| @uplift-io/image | Sharp-backed transforms and variant outputs |
| @uplift-io/video | ffmpeg-backed video transforms, thumbnails, posters |
Uplift is for teams who want the upload contract itself to be typed, framework-light, and storage-swappable.
It can even use UploadThing as a storage boundary rather than competing with it.
Typed upload contracts server-to-client. Storage-swappable. No lock-in to a provider, framework, or schema library. Simple primitives, not a widget product.
Managed upload infrastructure with first-party UI helpers. Great when you want the full hosted stack.
Rich browser upload widgets. Best when drag-and-drop UI experience is the primary product surface.
Lowest-level escape hatch. Full control, but you wire auth, key generation, and client types yourself.