Upload to S3 from Next.js with a presigned URL
A server action signs a short-lived S3 URL and the browser PUTs the file straight to the bucket. An XMLHttpRequest reports the bytes, so every file in the free dropzone gets a true progress ring.
Use it in Next.js
"use client";
import UploadDropzone, { type Uploader } from "qodesign-free/upload-dropzone";
import { signUpload } from "./actions";
const upload: Uploader = async (file, onProgress, signal) => {
const { url } = await signUpload(file.name, file.type, file.size);
// fetch() can't report upload progress yet, XMLHttpRequest can
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100);
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`S3 answered ${xhr.status}`)));
xhr.onerror = () => reject(new Error("Network error, check the bucket's CORS rules"));
signal.addEventListener("abort", () => xhr.abort());
xhr.send(file);
});
};
export default function UploadPage() {
return <UploadDropzone upload={upload} accept="image/*,.pdf" maxSize={100 * 1024 * 1024} />;
}"use server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function signUpload(name: string, type: string, size: number) {
// check the session here, and refuse what you don't want
if (size > 100 * 1024 * 1024) throw new Error("Files up to 100 MB");
const key = `uploads/${crypto.randomUUID()}-${name.replace(/[^\w.-]/g, "_")}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: type }),
{ expiresIn: 60 },
);
return { url, key };
}Good to know
- The bucket needs a CORS rule that allows PUT from your site's origin, with the Content-Type header allowed. Without it the browser blocks the request and the row shows the network error.
- The Content-Type the browser sends has to match the one you signed, or S3 answers 403 SignatureDoesNotMatch.
- A presigned PUT doesn't let you cap the size on S3's side. The size check in signUpload trusts the browser's number. If that matters, use a presigned POST with a content-length-range condition.
- The same code works with Cloudflare R2, Backblaze B2 and other S3-compatible stores: give S3Client their endpoint.
- Server credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) stay on the server. The browser only sees a URL that expires after 60 seconds.
- We read the AWS SDK's documented API for this recipe. We haven't run it against a live bucket.
Install
- One command
npm i qodesign-free framer-motion @aws-sdk/client-s3 @aws-sdk/s3-request-presigner - Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.
Props
- upload
- (file, onProgress, signal) → Promise
- maxSize
- bytes, 50 MB by default
- accept
- for the file picker, e.g. image/*
- onAllDone
- every file finished
How qodesign compares
- qodesign vs 21st.dev21st.dev is best for: browsing a huge marketplace and copying many components a month.
- qodesign vs Magic UIMagic UI is best for: landing pages built with shadcn/ui.
All comparisons, with prices read on each library's own site.
Questions
Why XMLHttpRequest and not fetch?
fetch has no upload progress event in browsers today. XHR does, and its abort() stops the upload when the visitor removes a file.
Does it work with R2?
Yes. Point S3Client at your R2 endpoint with region "auto" and add a CORS rule on the R2 bucket.
Is the dropzone free?
Yes. MIT licence, from npm or the shadcn registry.
Does the dropzone work in Next.js?
Yes, as a client component. Keep "use client" at the top of the file that renders it, as in the snippet above. App Router and Pages Router both work.