Dead-simple, type-safe file uploads for TypeScript applications.
Define upload routes once on the server. Get a typed client on the frontend.
await upload.avatar(file);
await upload.gallery(files);Uplift is for people who want file uploads to feel like a TypeScript contract instead of a pile of multipart parsing, storage SDK calls, auth checks, and duplicated client code.
- Typed from server to client: route names, file multiplicity, and return values are inferred.
- Fluent server API:
image().max("2mb").auth(...).key(...).done(...). - Single endpoint: the client posts to one upload endpoint with a route query.
- Framework adapters: Next.js, Hono, Express, Fastify, Elysia, SvelteKit, Remix, TanStack Start, and Nuxt entrypoints.
- Storage adapters: S3, R2, Bunny, Cloudinary, local, memory, and UploadThing-compatible storage.
- No schema lock-in: JSON validation accepts any
.parse()schema. - Small core: framework and storage adapters live in separate packages, so users install only what they use.
- Package hardening: docs snippets, example app, bundle size, and install smoke checks run before publish.
Install the core plus the adapters you use:
pnpm add @uplift-io/uplift @uplift-io/next @uplift-io/s3npm install @uplift-io/uplift @uplift-io/next @uplift-io/s3yarn add @uplift-io/uplift @uplift-io/next @uplift-io/s3Other adapters are available as separate packages: @uplift-io/hono, @uplift-io/express, @uplift-io/fastify, @uplift-io/elysia, @uplift-io/sveltekit, @uplift-io/remix, @uplift-io/tanstack-start, @uplift-io/nuxt, @uplift-io/local, @uplift-io/memory, @uplift-io/r2, @uplift-io/bunny, @uplift-io/cloudinary, and @uplift-io/uploadthing.
Media capability packages are optional: add @uplift-io/image for image transforms and variants, and @uplift-io/video for synchronous video transforms and derived artifacts.
Async transforms are available for background media work. Use .transform(...) when work should finish during the upload request, and .transformAsync(...) when the request should accept the Original Upload, create a Transform Job, and let a worker produce the final result later.
// 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_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!
}),
routes: {
avatar: image()
.max("2mb")
.auth(async ({ req }) => {
return { id: req.headers.get("x-user-id")! };
})
.headers(({ user }) => ({
"Cache-Control": "public, max-age=31536000",
"Content-Disposition": `inline; filename="${user.id}.png"`
}))
.key(({ user }) => `avatars/${user.id}.png`)
.done(async ({ file, user }) => {
console.log("avatar uploaded", user.id, file.url);
}),
resume: pdf().max("5mb"),
contacts: csv().columns(["email", "name"], { delimiter: "," }),
gallery: image()
.max("8mb")
.multiple(10)
.auth(async ({ req }) => {
return { id: req.headers.get("x-user-id")! };
})
.key(({ user, file }) => `gallery/${user.id}/${file.name}`)
.done(async ({ files }) => {
console.log(files.map((file) => file.url));
})
}
});
export type Uploads = typeof uploads;Routes are single-file by default. Calling .multiple() changes:
- the server
done()context from{ file }to{ files } - the client input from
FiletoFile[] | FileList - the client result from
UploadedFiletoUploadedFile[]
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 { Hono } from "hono";
import { createHonoHandler } from "@uplift-io/hono";
import { uploads } from "./uploads";
const app = new Hono();
app.route("/upload", createHonoHandler(uploads));Express:
import express from "express";
import { createExpressHandler } from "@uplift-io/express";
import { uploads } from "./uploads";
const app = 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");const avatar = await upload.avatar(file);
avatar.url;
avatar.key;
const gallery = await upload.gallery(fileList);
gallery[0].url;The client posts to the configured endpoint with ?route=<routeName>, so one endpoint can host every route.
Route methods also expose operation controls:
upload.avatar.abort();
await upload.avatar.retry();
const check = await upload.avatar.preflight(file);
if (check.ok) {
await check.upload();
}Async transform routes return an AsyncTransformHandle with id, route, status, and done(). The correctness baseline is polling through the upload endpoint; realtime transports can layer on top without changing terminal behavior. Job ids are bearer tokens for status reads, so treat them as sensitive.
import { asyncTransforms, type RedisLike } from "@uplift-io/redis";
import { uplift, video } from "@uplift-io/uplift";
import { createUploadClient } from "@uplift-io/uplift/client";
import { runNextTransformJob } from "@uplift-io/uplift/server";
import { thumbnail, transcode, trim } from "@uplift-io/video";
declare const redis: RedisLike;
export const uploads = uplift({
storage,
asyncTransforms: asyncTransforms(redis, {
queueName: "uplift:async-transforms",
keepOriginal: "failed",
timeout: "10m"
}),
routes: {
clip: video()
.transformAsync(trim({ start: "00:00:01" }), transcode({ format: "mp4" }), { timeout: "10m" })
.outputs(thumbnail("poster", { at: "25%" }))
.listeners({
queued: ({ id }) => console.log("queued", id),
completed: ({ result }) => result.outputs?.poster,
failed: ({ error }) => console.error(error.message)
})
}
});
const upload = createUploadClient<typeof uploads>("/api/upload");
const transform = await upload.clip(file);
const completed = await transform.done({ timeoutMs: 60_000 });
completed.output("poster");
await runNextTransformJob(uploads);@uplift-io/redis accepts a RedisLike object instead of depending on a specific Redis client package. queueName is required because it is the Redis namespace shared by the web app, status reads, and workers for one compatible Upload Contract; do not reuse a queue name across incompatible route definitions or environments. Redis claims are leased and recoverable, with claimVisibilityTimeoutMs defaulting to five minutes. Workers validate the queued route contract before running so removed routes or changed transform/output semantics fail clearly instead of silently processing with the wrong code.
Async Transform Jobs are single-file routes. Durable queues require storage adapters that can read Original Upload bytes; Local, S3, and R2 adapters support this. Do not combine .transform(...) and .transformAsync(...) on the same route. keepOriginal controls Original Upload retention after terminal states: false deletes after success or failure, "failed" keeps failed originals only, and true keeps originals after every terminal state. It defaults to "failed". Route listeners are best-effort diagnostics; .done(...) remains the strict completion hook and can fail the Transform Job.
abort() only cancels the active attempt for that route method. retry() reuses the most recent failed or aborted input while the client instance is alive. preflight() sends file facts only, then check.upload() uses the same upload machinery as a direct route call.
Server handlers expose the standard HTTP surface:
HEADreturns a bodyless health response.GETreturns the public Route Manifest.POSThandles upload attempts and preflight checks.
Generate OpenAPI from the public manifest with @uplift-io/openapi:
import { createRouteManifest } from "@uplift-io/uplift/server";
import { createOpenApiDocument } from "@uplift-io/openapi";
export const openapi = createOpenApiDocument(createRouteManifest(uploads), {
path: "/api/upload"
});import { useUploads } from "@uplift-io/uplift/react";
import type { Uploads } from "./uploads";
export function AvatarUploader() {
const upload = useUploads<Uploads>("/api/upload");
return (
<input
type="file"
accept="image/*"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void upload.avatar(file);
}}
/>
);
}Each route method exposes state:
upload.avatar.progress;
upload.avatar.isUploading;
upload.avatar.error;
upload.avatar.data;In browsers, Uplift uses XHR for real upload progress. In non-browser or custom-fetch environments, progress is lifecycle-based.
import {
any,
audio,
csv,
custom,
image,
json,
pdf,
text,
video
} from "@uplift-io/uplift";Shared methods:
.max("2mb")
.min("10kb")
.multiple(10)
.auth(async ({ req }) => user)
.overrideAuth()
.key(({ user, file }) => `uploads/${file.name}`)
.meta(({ user, file }) => ({ owner: user.id, name: file.name }))
.validate(({ file }) => true)
.headers({ "Cache-Control": "public, max-age=31536000" })
.done(async ({ file, user, meta }) => {})JSON schema validation accepts any object with a .parse() method:
json().schema(zodSchema);headers() is shared by every route builder and means object-storage headers. It accepts a static object or a function that can read req, file, user, and meta.
CSV file structure uses columns():
csv().columns(["email", "name"], { delimiter: "," });
csv().delimiter(";").columns(["email", "name"]);If both columns(..., { delimiter }) and delimiter() are used, the last delimiter call wins. Migrate old CSV column checks from csv().headers([...]) to csv().columns([...]).
There is no hard dependency on Zod, Axios, an ORM, or a database client.
Core Uplift owns the typed pipeline, but media packages own media dependencies and domain APIs. .transform() changes the primary uploaded file before key generation and storage. .outputs() creates named artifacts after primary transforms have finished.
import { image, video } from "@uplift-io/uplift";
import { resize, convert, variant } from "@uplift-io/image";
import { trim, transcode, thumbnail, poster } from "@uplift-io/video";
const routes = {
avatar: image()
.transform(resize({ width: 512, height: 512, fit: "cover" }), convert("webp"))
.outputs(
variant("thumb", resize({ width: 96, height: 96 }), convert("webp")),
variant("preview", resize({ width: 320 }), convert("webp"))
),
clip: video()
.transform(trim({ start: "00:00:01", end: "00:00:10" }), transcode({ format: "mp4", codec: "h264" }))
.outputs(thumbnail("thumb", { at: "25%" }), poster("poster", { at: "00:00:02" }))
};The frontend upload method shape is unchanged:
const avatar = await upload.avatar(file);
avatar.output("thumb").url;
const clip = await upload.clip(videoFile);
clip.output("poster").url;Output keys use the v1 convention <primary-key>/outputs/<name>.<extension>. Outputs derive from the transformed primary file, and any transform, output, done(), or onUploadComplete failure fails the upload request. Storage adapters can implement delete(key) to let core roll back already-written objects during failed requests.
@uplift-io/image uses Sharp internally. @uplift-io/video shells out to ffmpeg/ffprobe during the upload request; production hosts should install those binaries or set UPLIFT_FFMPEG_PATH and UPLIFT_FFPROBE_PATH, and should keep request-time budgets appropriate for the selected work.
import { bunny } from "@uplift-io/bunny";
import { cloudinary } from "@uplift-io/cloudinary";
import { local } from "@uplift-io/local";
import { r2 } from "@uplift-io/r2";
import { s3 } from "@uplift-io/s3";
import { uploadthing } from "@uplift-io/uploadthing";S3 and R2 use @aws-sdk/client-s3 and support safe object headers plus rollback deletion. Bunny supports safe object headers and deletes through Bunny Storage. Local and memory storage implement cleanup. Custom adapters can omit delete, but rollback cannot remove files for adapters that do not expose it.
Cloudinary unsigned uploads still work with cloudName and uploadPreset; rollback cleanup is enabled only when server-side apiKey and apiSecret are configured. Cloudinary does not provide generic object-storage header parity, so route headers are not mapped there.
The UploadThing adapter accepts a server-side uploader compatible with UTApi.uploadFiles() and an optional deleter compatible with UTApi.deleteFiles(), keeping UploadThing as an optional integration rather than a core dependency.
import { uploadthing } from "@uplift-io/uploadthing";
import { UTApi } from "uploadthing/server";
const utapi = new UTApi();
const storage = uploadthing({
uploader: (file) => utapi.uploadFiles(file),
deleter: (key) => utapi.deleteFiles(key)
});- Next local example: local storage by default, with S3 and R2 configuration paths.
- Typed docs snippets: source samples checked by TypeScript.
Run the dogfood checks:
pnpm docs:check
pnpm examples:check
pnpm smoke:packBundle size is generated from the built package, not a remote badge service:
pnpm build
pnpm bundle:sizeSee docs/BUNDLE_SIZE.md.
@uplift-io/rich is legacy. Prefer @uplift-io/image and @uplift-io/video for media behavior. Existing inspection-heavy routes remain opt-in:
import { audio, pdf, video } from "@uplift-io/rich";
pdf().pages({ max: 10 }).encrypted(false);
video().duration({ max: "2m" });
audio().duration({ max: "5m" });Rich inspection methods currently fail closed until an inspector is wired for the deployment. Video duration checks are designed around ffprobe; hosts using that legacy feature must provide ffprobe and should verify availability in deployment.
- Docs site: itzfeminisce.github.io/uplift
- PRD: docs/PRD.md
- Media transforms PRD: docs/PRD_MEDIA_TRANSFORMS_AND_OUTPUTS.md
- Storage headers and rollback: docs/STORAGE_HEADERS_AND_ROLLBACK.md
- Issue breakdown: docs/ISSUE_BREAKDOWN.md
- 1.2.0 checklist: docs/releases/1.2.0-checklist.md
- Changelog: CHANGELOG.md
Uplift is not trying to replace every upload tool.
- Choose Uplift when the upload contract should be typed from server route to client call, and storage should stay swappable.
- Choose UploadThing when you want managed upload infrastructure and first-party UI helpers. Uplift can use it as a storage boundary.
- Choose Uppy or FilePond when the browser upload widget is the product surface.
- Choose direct SDKs when you need bespoke storage behavior and do not need route inference.
Uplift is at 1.0.0. The package split is the stable install model: install @uplift-io/uplift for core APIs, then add only the framework and storage adapters your app uses.
Issues and PRs are welcome. Good contributions usually include:
- a small failing test that captures the behavior
- the minimal implementation to make it pass
- docs updates when the public API changes
Run the project locally:
pnpm install
pnpm checkMIT