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.