S3

createS3PresignedAdapter uploads the whole file in a single PUT request to a presigned URL. Your backend generates that URL — Drop Upload never needs your AWS credentials.

import { createS3PresignedAdapter } from "../components/DropUpload"

const adapter = createS3PresignedAdapter({
  getUploadUrl: async (file) => {
    const res = await fetch("/api/s3/presign", {
      method: "POST",
      body: JSON.stringify({ filename: file.name, contentType: file.type }),
    })
    return res.json() // { url: "https://your-bucket.s3.amazonaws.com/..." }
  },
})

<DropUpload uploadAdapter={adapter} />

Your /api/s3/presign endpoint should return a presigned PUT URL (using the AWS SDK's getSignedUrl for PutObjectCommand), and optionally any extra headers to send with the request (e.g. x-amz-server-side-encryption).

The presign endpoint

// app/api/s3/presign/route.ts
import { randomUUID } from "node:crypto"
import path from "node:path"

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { NextResponse } from "next/server"

const ALLOWED = ["image/png", "image/jpeg", "application/pdf"]
const s3 = new S3Client({ region: process.env.AWS_REGION })

export async function POST(request: Request) {
  const { filename, contentType } = await request.json()

  if (!ALLOWED.includes(contentType)) {
    return NextResponse.json({ error: "File rejected" }, { status: 415 })
  }

  // The key is decided here, not sent by the client: the signature *is* the
  // permission, so whatever it authorises can be done by anyone holding the
  // URL. Same for the content type — it's part of what gets signed.
  const key = `uploads/${randomUUID()}${path.extname(filename).toLowerCase()}`

  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      ContentType: contentType,
    }),
    { expiresIn: 60 },
  )

  return NextResponse.json({ url })
}

Sixty seconds is enough: the URL is fetched immediately before the PUT starts, and the deadline applies to the start of the upload, not to how long it takes.

The bucket needs a CORS rule allowing PUT from your origin, with Content-Type in AllowedHeaders. Without it the browser blocks the upload at the preflight, before a single byte leaves — a presigned URL that works fine from curl fails from the page.

For files larger than a few dozen MB, prefer S3 Multipart so a single dropped connection doesn't mean restarting the whole upload.