S3 Multipart
createS3MultipartAdapter slices large files into parts (5 MB by default,
the S3 minimum for non-final parts) and uploads them individually, which
means a dropped connection only costs you one part, not the whole file.
This can't be done from the browser alone — S3's multipart API needs your AWS credentials to create the upload and sign each part, so your backend must expose three small endpoints. The adapter calls them for you:
import { createS3MultipartAdapter } from "../components/DropUpload"
const adapter = createS3MultipartAdapter({
initiate: async (file) => {
const res = await fetch("/api/s3/multipart/initiate", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
})
return res.json() // { uploadId, key }
},
getPartUrl: async ({ key, uploadId, partNumber }) => {
const res = await fetch("/api/s3/multipart/part-url", {
method: "POST",
body: JSON.stringify({ key, uploadId, partNumber }),
})
return res.json() // { url }
},
complete: async ({ key, uploadId, parts }) => {
const res = await fetch("/api/s3/multipart/complete", {
method: "POST",
body: JSON.stringify({ key, uploadId, parts }),
})
return res.json() // { url? }
},
abort: async ({ key, uploadId }) => {
await fetch("/api/s3/multipart/abort", {
method: "POST",
body: JSON.stringify({ key, uploadId }),
})
},
})
<DropUpload uploadAdapter={adapter} />Backend contract
| Endpoint | Request | Response | AWS SDK call it wraps |
|---|---|---|---|
initiate | { filename, contentType } | { uploadId, key } | CreateMultipartUploadCommand |
getPartUrl | { key, uploadId, partNumber } | { url } | getSignedUrl for UploadPartCommand |
complete | { key, uploadId, parts: { ETag, PartNumber }[] } | { url? } | CompleteMultipartUploadCommand |
abort (optional) | { key, uploadId } | — | AbortMultipartUploadCommand |
The endpoints
The handlers share one client. initiate decides the key — the filename
that arrives is only good for its extension — and hands it back to the
browser, which then quotes it on every later call.
That last part is the bit to watch: on part-url and complete the key
arrives as client input. Signing an UploadPartCommand is granting write
access to whatever key was passed, so if these endpoints are reachable by
more than one user, store the uploadId → key pair (and its owner) at
initiate and check the pair on the way back in. The snippets below leave
that out because it depends on your session layer, not on S3.
// lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3"
export const s3 = new S3Client({ region: process.env.AWS_REGION })
export const BUCKET = process.env.S3_BUCKET!
export const ALLOWED = ["image/png", "image/jpeg", "video/mp4"]// app/api/s3/multipart/initiate/route.ts
import { randomUUID } from "node:crypto"
import path from "node:path"
import { CreateMultipartUploadCommand } from "@aws-sdk/client-s3"
import { NextResponse } from "next/server"
import { ALLOWED, BUCKET, s3 } from "@/lib/s3"
export async function POST(request: Request) {
const { filename, contentType } = await request.json()
if (!ALLOWED.includes(contentType)) {
return NextResponse.json({ error: "File rejected" }, { status: 415 })
}
const key = `uploads/${randomUUID()}${path.extname(filename).toLowerCase()}`
const out = await s3.send(
new CreateMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
}),
)
return NextResponse.json({ uploadId: out.UploadId, key })
}// app/api/s3/multipart/part-url/route.ts
import { UploadPartCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { NextResponse } from "next/server"
import { BUCKET, s3 } from "@/lib/s3"
export async function POST(request: Request) {
const { key, uploadId, partNumber } = await request.json()
// One signature per part, and only for this upload. Nothing else about
// the object is signable from here.
const url = await getSignedUrl(
s3,
new UploadPartCommand({
Bucket: BUCKET,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
}),
{ expiresIn: 900 },
)
return NextResponse.json({ url })
}// app/api/s3/multipart/complete/route.ts
import { CompleteMultipartUploadCommand } from "@aws-sdk/client-s3"
import { NextResponse } from "next/server"
import { BUCKET, s3 } from "@/lib/s3"
export async function POST(request: Request) {
const { key, uploadId, parts } = await request.json()
const out = await s3.send(
new CompleteMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
UploadId: uploadId,
// S3 rejects the call unless the parts arrive in ascending order.
MultipartUpload: {
Parts: [...parts].sort((a, b) => a.PartNumber - b.PartNumber),
},
}),
)
return NextResponse.json({ url: out.Location })
}// app/api/s3/multipart/abort/route.ts
import { AbortMultipartUploadCommand } from "@aws-sdk/client-s3"
import { NextResponse } from "next/server"
import { BUCKET, s3 } from "@/lib/s3"
export async function POST(request: Request) {
const { key, uploadId } = await request.json()
await s3.send(
new AbortMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
UploadId: uploadId,
}),
)
return new NextResponse(null, { status: 204 })
}abort is optional for the adapter but not for your bill: parts of an
abandoned upload sit in the bucket, invisible to ListObjects and charged
for, until something removes them. Wire it up, and add a lifecycle rule with
AbortIncompleteMultipartUpload to catch the uploads that die without the
browser getting a chance to call it.
Your bucket's CORS configuration must include ExposeHeaders: ["ETag"].
Without it, the browser can't read the ETag response header for each part,
and the upload will fail after the first part.