File uploads as a
typed contract.

Define upload routes once on the server — constraints, auth, storage keys, transforms, hooks. Get a fully inferred typed client on the frontend.

MIT Licensed TypeScript 7+ Framework Adapters
upload.ts
// 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[]

Why Uplift

End-to-end inference

Route names, multiplicity, and return shapes flow from your server definition to the client — zero manual type duplication.

Storage adapters

S3, R2, Bunny, Cloudinary, local, memory, and UploadThing-compatible. Safe storage headers and rollback cleanup stay behind the same route contract.

Framework adapters

First-class handlers for Next.js App Router, Hono, Express, Fastify, Elysia, SvelteKit, Remix, TanStack Start, and Nuxt. One endpoint, every route.

Typed auth flow

Return any user shape from .auth() and it flows fully typed into .key(), .meta(), and .done().

Optional media processing

Image transforms via Sharp, video via ffmpeg. Both live in optional domain packages — the core stays lean.

Real upload progress

XHR-backed progress in browsers. The React hook exposes .progress, .isUploading, .error, and .data per route.

Quickstart

Three steps.

Define routes on the server, mount a framework handler, create a typed client. That's the full API surface for the common case.

  1. Install @uplift-io/uplift and your storage adapter.
  2. Define routes with builders, auth, keys, and hooks.
  3. Mount a handler for your framework. Every handler supports HEAD, GET, and POST.
  4. Create a typed client by passing your Uploads type.
uploads.ts
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 · Hono · Express
// 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));
upload-client.ts
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;
React

useUploads hook.

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.

Progress
upload.avatar.progress upload.avatar.isUploading
Result
upload.avatar.data upload.avatar.error
AvatarUploader.tsx
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>
  );
}
Media

Transformations

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.

media routes
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
File builders

Fluent builder API.

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.

any audio csv custom image json pdf text video
shared builder methods
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: "," });
Storage

Adapters stay thin.

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.

@uplift-io/s3
Package map

Everything is isolated.

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/upliftCore builders, server runtime, typed client, React hook
Framework Handlers
@uplift-io/nextNext.js App Router handler (HEAD + GET + POST export)
@uplift-io/honoHono route handler
@uplift-io/expressExpress middleware handler
@uplift-io/fastifyFastify handler/plugin helpers
@uplift-io/elysiaElysia handler helpers
@uplift-io/sveltekitSvelteKit endpoint handlers
@uplift-io/remixRemix loader/action helpers
@uplift-io/tanstack-startTanStack Start route handlers
@uplift-io/nuxtNuxt/Nitro-style handler
@uplift-io/openapiOpenAPI generation from Route Manifest
Storage Adapters
@uplift-io/s3AWS S3 via @aws-sdk/client-s3
@uplift-io/r2Cloudflare R2 via S3-compatible API
@uplift-io/bunnyBunny Storage/CDN via fetch
@uplift-io/cloudinaryCloudinary unsigned upload via fetch
@uplift-io/localLocal filesystem (dev/testing)
@uplift-io/memoryIn-memory (CI/tests, zero config)
@uplift-io/uploadthingUploadThing-compatible storage boundary
Media Processing
@uplift-io/imageSharp-backed transforms and variant outputs
@uplift-io/videoffmpeg-backed video transforms, thumbnails, posters
Comparison

Not trying to replace everything.

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.

↑ Uplift

Typed upload contracts server-to-client. Storage-swappable. No lock-in to a provider, framework, or schema library. Simple primitives, not a widget product.

UploadThing

Managed upload infrastructure with first-party UI helpers. Great when you want the full hosted stack.

Uppy / FilePond

Rich browser upload widgets. Best when drag-and-drop UI experience is the primary product surface.

Direct SDKs

Lowest-level escape hatch. Full control, but you wire auth, key generation, and client types yourself.