How to upload files to AWS S3 using pre-signed URLs in Next JS.
By Kundan Bhosale
Last Updated on Feb 15, 2026
AWS
S3
Next JS
File Upload
In this post we will be learning how you can upload files to AWS S3 with progress bar using pre-signed urls and good old XHR in NextJS 15.
I assume you know how to setup AWS S3 and can generate API Keys. After setting up your service follow below steps.
- Create file in _server/helpers/s3.ts which includes:
- New AWS S3 client function.
- Function to get file download URL
- Function to get signed URL.
import { env } from "@/env"
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
const s3Client = new S3Client({
region: env.S3_UPLOAD_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY,
secretAccessKey: env.AWS_SECRET_KEY,
},
signingRegion: env.S3_UPLOAD_REGION,
})
export async function getDownloadUrl(objectName: string) {
return getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: env.S3_UPLOAD_BUCKET,
Key: objectName,
}),
{ expiresIn: 3600 }
)
}
export async function getPresignedUrl({
key,
contentType,
contentLength,
}: {
contentLength: number
contentType: string
key: string
}) {
const url = await getSignedUrl(
s3Client,
new PutObjectCommand({
Bucket: env.S3_UPLOAD_BUCKET,
Key: key,
ContentType: contentType,
ContentLength: contentLength,
}),
{ expiresIn: 3600 }
)
return url
}
export async function getFileUrl({ key }: { key: string }) {
const url = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: env.S3_UPLOAD_BUCKET,
Key: key,
}),
{ expiresIn: 3600 }
)
return url
}
2. Create Server Actions (_server/actions/s3Actions), which includes:
- Action to get all documents
- Action to upload document
- Action when document is successfully uploaded from frontend.
- Action when document is removed by user.
- Action to get document download URL.
import { db } from "../db";
import { z } from "zod";
import { getDownloadUrl, getPresignedUrl } from "../helpers/s3";
import { sql } from "kysely";
const allowedDocumentTypes = [
"image/webp",
"image/jpeg",
"image/png",
"image/gif",
"application/pdf",
// Add more as needed
];
export const getAllDocuments = async () => {
const user_id = "user"; //get your userID from session.
// Then Check user permission
const result = db
.selectFrom("orgs.storage")
.select([
"id",
"location",
"name",
"size",
"type",
"user_id",
"uploaded_at",
"created_at",
])
.where(("user_id" = user_id))
.execute();
return result;
};
export const documentUpload = async (data: {
id: string;
filename: string;
contentType: string;
contentLength: number;
userId: string;
}) => {
const { id, filename, contentLength, contentType, userId } = z
.object({
id: z.string().uuid(),
userId: z.string(),
filename: z.string().min(1).max(255),
contentLength: z.number().int(),
contentType: z
.string()
.refine((val) => allowedDocumentTypes.includes(val), {
message: "Invalid document type",
}),
})
.parse(data);
const result = await db.transaction().execute(async (trx) => {
const key = `users/${userId}/${id}.${filename.split(".").pop()}`;
const presigned = await getPresignedUrl({
contentLength,
contentType,
key,
});
const url = new URL(presigned);
await trx
.insertInto("orgs.storage")
.values({
id,
name: filename,
type: contentType,
size: contentLength,
// org_id: org!.id,
user_id: userId,
// uploaded_by: session.user.id,
location: `https://${url.host}/${key}`,
})
.execute();
return presigned;
});
return result;
};
export const onDocumentUploadSuccess = async (data: { id: string }) => {
const { id } = z
.object({
id: z.string().uuid(),
})
.parse(data);
const user_id = "user"; //get your userID from session.
// Then Check user permission
await db
.updateTable("orgs.storage")
.where("user_id", "=", user_id)
.where("id", "=", id)
.set({
uploaded_at: sql`now()`,
})
.execute();
return true;
};
export const onDocumentUploadRemove = async (data: { id: string }) => {
const { id } = z
.object({
id: z.string().uuid(),
})
.parse(data);
const user_id = "user"; //get your userID from session.
// Then Check user permission
await db
.deleteFrom("orgs.storage")
.where("user_id", "=", user_id)
.where("id", "=", id)
.execute();
return id;
};
export const getDocumentDownloadUrl = async (data: { path: string }) => {
const { path } = z
.object({
path: z.string(),
})
.parse(data);
const user_id = "user"; //get your userID from session.
// Then Check user permission
const url = new URL(path);
return await getDownloadUrl(url.pathname.substring(1));
};
3. Now create file upload component (components/FileUpload.tsx)
import { useEffect, useRef, useState } from "react";
import { documentUpload } from "@/_server/actions/s3Actions";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
type SingleFile = {
id: string;
file: File;
status: "pending" | "success" | "error";
};
export function FileUpload({
data,
onRemove,
onSuccess,
onError,
userID,
}: {
data: SingleFile;
onRemove: (id: string) => void;
onSuccess: (id: string) => void;
onError: (id: string) => void;
userID: string;
}) {
const [progress, setProgress] = useState(0);
const abortController = useRef(new AbortController());
const uploadFile = async (file: File) => {
try {
// Get presigned URL
const url = await documentUpload({
id: data.id,
filename: file.name,
contentType: file.type,
contentLength: file.size,
userID,
});
// Custom upload with progress
await uploadWithProgress(url, file);
onSuccess(data.id);
console.log("Upload successful");
} catch (error: any) {
if (error.name === "AbortError") {
console.log("Upload cancelled");
} else {
console.error("Upload failed:", error);
}
}
};
const uploadWithProgress = (url: string, file: File) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setProgress(percentComplete);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve(xhr.response);
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => {
onError(data.id);
reject(new Error("Network error occurred"));
};
xhr.onabort = () => {
reject(new Error("Upload aborted"));
};
abortController.current.signal.addEventListener("abort", () =>
xhr.abort()
);
xhr.send(file);
});
};
const cancelUpload = () => {
abortController.current.abort();
abortController.current = new AbortController();
onRemove(data.id);
};
useEffect(() => {
const timer = setTimeout(() => {
data.file && data.status === "pending" && uploadFile(data.file);
}, 500);
return () => clearTimeout(timer);
}, [data.file]);
return (
<div>
<ProgressPrimitive.Root
className={"bg-primary/20 relative w-full overflow-hidden rounded-md"}
>
<ProgressPrimitive.Indicator
className="bg-primary/70 absolute left-0 top-0 size-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (progress || 0)}%)` }}
/>
<div className="relative z-10 flex h-8 items-center px-4">
<div className="flex-1">
<p className=" line-clamp-1 max-w-screen-sm">{data.file.name}</p>
</div>
{data.status === "pending" ? (
<Button
variant={"link"}
className="text-foreground hover:text-primary"
size={"icon-sm"}
onClick={cancelUpload}
>
<X className="size-4" />
</Button>
) : data.status === "success" ? (
<Check className="size-4" />
) : null}
</div>
</ProgressPrimitive.Root>
</div>
);
}
4. Now you can use this FileUpload component like this:
import { Fragment, useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { produce } from "immer";
import { buttonVariants } from "@/components/ui/button";
import { FileUpload } from "@/components/FileUpload";
import {
onDocumentUploadRemove,
onDocumentUploadSuccess,
} from "@/_server/actions/document";
import { Upload } from "lucide-react";
type SingleFile = {
id: string;
file: File;
status: "pending" | "success" | "error";
};
const UploadDocuments = () => {
const [list, setList] = useState<Array<SingleFile>>([]);
const setFile = (files: FileList) => {
const newFiles: any = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
newFiles.push({
id: crypto.randomUUID(),
file: f,
status: "pending",
});
}
setList((p) => [...newFiles, ...p]);
};
const onRemove = async (id: string) => {
setList((p) => p.filter((f) => f.id !== id));
await onDocumentUploadRemove({ id });
};
const onError = (id: string) => {
setList((p) =>
produce(p, (d) => {
const idx = d.findIndex((f) => f.id === id);
if (idx === -1) return;
d[idx].status = "error";
})
);
};
const onSuccess = async (id: string) => {
setList((p) =>
produce(p, (d) => {
const idx = d.findIndex((f) => f.id === id);
if (idx === -1) return;
d[idx].status = "success";
})
);
await onDocumentUploadSuccess({ id });
};
// const onDelete = async (id: string) => {
// await onDocumentUploadRemove({ id });
// };
useEffect(() => {
if (list.length === 0) return;
const pendingList = list.filter((f) => f.status === "pending");
if (pendingList.length === 0) {
setList((p) => p.filter((f) => f.status !== "success"));
}
}, [list]);
return (
<Fragment>
<div>
<label className={buttonVariants({})}>
<input
className="absolute left-0 top-0 w-full opacity-0"
type="file"
multiple={true}
onChange={(e) => e.target.files && setFile(e.target.files)}
/>
<Upload /> <span>Upload</span>
</label>
</div>
{list.length > 0 && (
<Card>
<CardHeader className="grid grid-cols-[auto,200px]">
<CardTitle>Documents Uploading...</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{list.map((t, i) => (
<FileUpload
data={t}
key={t.id}
onRemove={onRemove}
onSuccess={onSuccess}
userId={"user"}
onError={onError}
/>
))}
</CardContent>
</Card>
)}
</Fragment>
);
};
export default UploadDocuments;
In above example I haven’t fetch uploaded files, you can simple fetch using DB call and then add actions like delete, edit etc.
I hope you learnt something new today, this article was meant for intermediate coders because I have skipped many steps to keep this article short and to the point.