Introduction

File uploading is a common feature in web applications. AWS provides a robust solution with S3 (Simple Storage Service) as an object storage to store files as objects. 
If your server handles every file directly, it quickly becomes a bottleneck as the client sends the entire file to the server which then sends it again to S3, using up CPU, memory, and bandwidth. That's why scalable systems offload this responsibility to cloud storage using pre-signed URLs. In this guide, we'll build a scalable file upload system using React.js, Express.js, AWS S3, and pre-signed URLs, powered by the modern AWS SDK v3.

In this guide, we’ll build a scalable file upload system using React.js, Express.js, AWS S3, and pre-signed URLs, powered by the modern AWS SDK v3.

What’s a Pre-Signed URL?

A pre-signed URL allows you to grant temporary upload/download access to a specific S3 object, without exposing your AWS credentials to the client or opening up public access.

Benefits

  • No server bottleneck: clients upload directly to S3.
  • Scalable: your server handles only metadata to create a pre-signed URL.
  • Secure: limited-time access, file type and path constraints.

Server-side Code

Let's see how we can create a simple file upload API using Express.js and AWS SDK v3.

import express from "express";
import dotenv from "dotenv";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { v4 as uuidv4 } from "uuid";

dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;

const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

app.get("/api/generate-presigned-url", async (req, res) => {
  const fileName = `${uuidv4()}.jpg`; // You can use other extensions or get extension from client side using query params
  const bucketName = process.env.S3_BUCKET_NAME;

  const command = new PutObjectCommand({
    Bucket: bucketName,
    Key: fileName,
    ContentType: "image/jpeg", // You could allow dynamic types by getting content type from client side using query params
  });

  try {
    const url = await getSignedUrl(s3, command, { expiresIn: 60 });
    res.json({ uploadURL: url, fileName });
  } catch (err) {
    console.error("Error generating pre-signed URL:", err);
    res.status(500).json({ error: "Could not generate pre-signed URL" });
  }
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Client-side Code

Let's create a simple React component to upload a file from client side.

import React, { useState } from 'react';

export default function S3Uploader() {
  const [file, setFile] = useState(null);
  const [uploading, setUploading] = useState(false);

  const handleUpload = async () => {
    if (!file) {
      alert('No file selected');
      return;
    }

    try {
      setUploading(true);

      // Step 1: Get pre-signed URL from backend
      const res = await fetch(`${process.env.SERVER_BASE_URL}/api/generate-presigned-url?fileName=${encodeURIComponent(file.name)}`);
      const { uploadURL, fileName } = await res.json();

      // Step 2: Upload the file to S3 using the pre-signed URL
      const upload = await fetch(uploadURL, {
        method: 'PUT',
        headers: { 'Content-Type': file.type },
        body: file,
      });

      if (!upload.ok) {
        alert('Upload failed!');
        return;
      }

      alert(`File uploaded! Name: ${fileName}`);
    } catch (err) {
      console.error(err);
      alert('Something went wrong!');
    } finally {
      setUploading(false);
      setFile(null);
    }
  };

  return (
    
       setFile(e.target.files?.[0] || null)}
      />
      
        {uploading ? 'Uploading...' : 'Upload'}
      
    
  );
};

Improvements

  • Modify pre-signed URL endpoint to accept command type based on query params to use GetObject command for downloading files.
  • Validate file types and size on the backend before generating a pre-signed URL.
  • Store file metadata in your DB after upload for future use.
  • Add authentication to protect the pre-signed URL endpoint.

Summary

Pre-signed URLs are a clean, efficient, and secure way to implement file uploads, keeping scalability in mind. With the AWS SDK v3, the integration is simple, modular and future-proof.