Amazon S3

Direct-to-bucket uploads with presigned URLs and resumable multipart.

"use client"

import { s3Adapter } from "@uploadcn/core"

import { demoTransport } from "@/examples/_demo"
import { FileUpload } from "@/components/file-upload"

/**
 * 1. The browser asks /api/upload for a signed PUT URL (size + type locked).
 * 2. Bytes go straight to the bucket, never through your server.
 * 3. The route verifies the object and runs `onUploadComplete`.
 * Files ≥ 64 MB automatically switch to resumable multipart.
 */
const adapter = s3Adapter({
  endpoint: "/api/upload",
  transport: demoTransport, // docs only, see examples/_demo.ts
})

export default function S3PresignedExample() {
  return <FileUpload adapter={adapter} title="Upload directly to S3" />
}

How it works

  1. The browser validates the file and asks your route for a signed URL.
  2. The route checks auth, size and type, and signs a PUT whose signature locks the content type and content length: S3 rejects anything else.
  3. The browser uploads directly to the bucket and tracks progress.
  4. The browser calls complete; the route verifies the object with HEAD and runs onUploadComplete (save metadata, enqueue a scan…).

Files at or above multipart.threshold (64 MB by default) use S3 multipart instead: parts are signed one at a time, uploaded in parallel, retried individually, and the upload can pause, resume, and survive reloads.

Client

import { MiB, s3Adapter } from "@uploadcn/core"

const adapter = s3Adapter({
  endpoint: "/api/upload",
  multipart: { threshold: 64 * MiB, partSize: 8 * MiB, concurrency: 4 },
  headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
})

Prop

Type

The result is a StoredObject: { key, url, etag?, data? }.

Server

npx shadcn@latest add @uploadcn/upload-route
app/api/upload/route.ts
import { UploadRouteError, createUploadRoute, s3Storage } from "@uploadcn/server"

import { auth } from "@/lib/auth"
import { db } from "@/lib/db"

export const { POST } = createUploadRoute({
  storage: s3Storage({
    bucket: process.env.S3_BUCKET!,
    region: process.env.S3_REGION!,
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  }),
  maxFileSize: 500 * 1024 * 1024,
  allowedTypes: ["image/*", "application/pdf"],
  async authorize({ key }) {
    const session = await auth()
    if (!session) throw new UploadRouteError("Unauthorized", 401)
    // Multipart actions carry the key: make sure it's the caller's.
    if (key && !key.startsWith(`${session.user.id}/`)) throw new UploadRouteError("Forbidden", 403)
    return session.user
  },
  getKey: ({ auth, file }) => `${auth.id}/${crypto.randomUUID()}/${file.name}`,
  async onUploadComplete({ auth, key, file }) {
    return db.file.create({ data: { userId: auth.id, key, name: file.name, size: file.size } })
  },
})

See the server reference for every option.

Bucket CORS

[
  {
    "AllowedOrigins": ["https://your-app.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["content-type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

ExposeHeaders: ["ETag"] is required for multipart uploads: the browser reads each part's ETag to complete the upload.

S3-compatible services

Pass endpoint to s3Storage for MinIO, Backblaze B2, DigitalOcean Spaces, Wasabi and others. Path-style URLs are used automatically with a custom endpoint.

s3Storage({
  endpoint: "https://s3.us-west-004.backblazeb2.com",
  region: "us-west-004",
  bucket: "uploads",
  accessKeyId: process.env.B2_KEY_ID!,
  secretAccessKey: process.env.B2_APPLICATION_KEY!,
})

Abandoned multipart uploads

Parts of cancelled uploads are aborted automatically. Parts of uploads that are simply abandoned (tab closed, never resumed) are billed until removed, add a bucket lifecycle rule to abort incomplete multipart uploads after a few days.

On this page