S3 File Upload in Node.js and React - Build React App
Tags: node, javascript, react, aws
Published: 2023-10-15 17:07:43

In this three parts series, we are learning how to add secure file upload feature in your application using AWS S3, Node.js and React with Signed URLs.

Check out the previous parts if you haven't
{% embed https://dev.to/umakantv/file-upload-using-aws-s3-nodejs-and-react-2boh %}

{% embed https://dev.to/umakantv/s3-file-upload-in-nodejs-and-react-setting-up-nodejs-app-15mk %}

## Table of Contents

- [Understanding the flow](#understanding-the-flow)
- [Setting up React App](#setting-up-react-app)
  - [Setting up App Config](#setting-up-app-config)
  - [Setting Up API Client](#setting-up-api-client)
- [Testing the Signed URL in a Playground Component](#testing-the-signed-url-in-a-playground-component)
- [Reusable Components](#reusable-components)
- [Keep in Mind](#keep-in-mind)
  - [Limiting File Uploads](#limiting-file-uploads)
  - [Maintaining Storage](#maintaining-storage)

In this part, we will build the react application to upload files directly to AWS S3 using Signed URLs generated from our node.js application.

If you prefer video tutorials, here is the series on YouTube.

{% embed https://www.youtube.com/embed/wJF7VI4SpII?si=T42FtHoA8svspT82&v=wJF7VI4SpII %}

## Understanding the flow

![File Upload Complete Flow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vfk1ljpm3lcz3du0mk9z.png)
Fig: _File Upload Complete Flow_


The above diagram shows the complete flow of the file upload process.

1. User selects the file to upload
2. React application sends a request to the node.js server to generate a signed URL
3. Node.js server generates a signed URL by calling AWS S3 APIs with AWS Credentials and sends it back to the react application.
4. React application uses the signed URL to upload the file directly to AWS S3.

Once the file is uploaded, we can save the URL in the database and use it to display the file in the application.

## Setting up React App

Let's start by creating a new react application using `create-react-app` command.

```bash
  npx create-react-app client
```

Once the application is created, we need to install the following dependencies.

```bash
  npm install axios @emotion/react @emotion/styled @mui/icons-material @mui/material
```

We will use `axios` to make API calls to our node.js server, `@emotion` and `@mui` for creating the styled components.

### Setting up App Config

We will create a new file `config.js` in the `src` folder to store the configuration for our application.

```js
// src/config/index.js

const config = {
  API_BASE_URL: process.env.REACT_APP_API_BASE_URL,
};

export default config;
```

We will use `REACT_APP_API_BASE_URL` environment variable to store the base URL of our node.js server.  
Create a new file `.env.development.local` in the root of the project and add the following content.

```bash
REACT_APP_API_BASE_URL=http://localhost:3010/api
```

### Setting Up API Client

We will create a new file `api/index.js` in the `src` folder to create an API client using `axios` to make API calls to our node.js server and to AWS S3.

```js
// src/api/index.js
import axios from "axios";
import config from "../config";

const apiClient = axios.create({
  baseURL: config.API_BASE_URL,
});

export async function getSignedUrl({ key, content_type }) {
  const response = await apiClient.post("/s3/signed_url", {
    key,
    content_type,
  });

  return response.data;
}

export async function uploadFileToSignedUrl(
  signedUrl,
  file,
  contentType,
  onProgress,
  onComplete
) {
  axios
    .put(signedUrl, file, {
      onUploadProgress: onProgress,
      headers: {
        "Content-Type": contentType,
      },
    })
    .then((response) => {
      onComplete(response);
    })
    .catch((err) => {
      console.error(err.response);
    });
}
```

## Testing the Signed URL in a Playground Component

We will create a new component `Playground` in the `src/components` folder to test the signed URL flow.

```jsx
// src/components/Playground.js

import React, { useState } from "react";
import { getSignedUrl, uploadFileToSignedUrl } from "../api";

const Playground = () => {
  const [fileLink, setFileLink] = useState("");
  const onFileSelect = (e) => {
    const file = e.target.files[0];

    const content_type = file.type;

    const key = `test/image/${file.name}`;

    getSignedUrl({ key, content_type }).then((response) => {
      console.log(response);

      uploadFileToSignedUrl(
        response.data.signedUrl,
        file,
        content_type,
        null,
        () => {
          setFileLink(response.data.fileLink);
        }
      );
    });
  };
  return (
    <div>
      <h1>Playground</h1>

      <img src={fileLink} />

      <input type="file" accept="*" onChange={onFileSelect} />
    </div>
  );
};

export default Playground;
```

We will add the `Playground` component to the `App.js` file to test the flow.

```js
// src/App.js

import React from "react";
import Container from "@mui/material/Container";
import Playground from "./components/Playground";

function App() {
  return (
    <div className="App">
      <Container style={{ display: "flex", justifyContent: "center" }}>
        <Playground />
      </Container>
    </div>
  );
}
```

## Reusable Components

We can wrap the above functionality into a hook component that can be reused in multiple parts of your application where file upload is required, as below:

```jsx
// src/hooks/useFileUpload.js

import { useCallback, useState } from "react";
import { getSignedUrl, uploadFileToSignedUrl } from "../api";

function getKeyAndContentType(file, prefix = "documents") {
  const [fileName, extension] = file.name.split(".");

  // to generate unique key everytime
  let key = prefix + `/${fileName}-${new Date().valueOf()}.${extension}`;

  let content_type = file.type;

  return { key, content_type };
}

export default function useFileUpload(onSuccess, prefix) {
  const [uploading, setUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(null);

  const uploadFile = useCallback((file) => {
    if (file) {
      const { key, content_type } = getKeyAndContentType(file, prefix);

      getSignedUrl({ key, content_type }).then((response) => {
        const signedUrl = response.data?.signedUrl;
        const fileLink = response.data?.fileLink;

        if (signedUrl) {
          setUploading(true);
          uploadFileToSignedUrl(
            signedUrl,
            file,
            content_type,
            (progress) => {
              setUploadProgress((progress.loaded / progress.total) * 100);
            },
            () => {
              onSuccess(fileLink);
              setUploading(false);
            }
          ).finally(() => {
            setUploadProgress(0);
          });
        }
      });
    }
    // eslint-disable-next-line
  }, []);

  return {
    uploading,
    uploadProgress,
    uploadFile,
  };
}
```

The usage for this hook would look something like this:

```jsx
// src/components/EditAvatar.js
import React, { useEffect, useState } from "react";

import { MenuItem, Menu } from "@mui/material";
import useFileUpload from "../hooks/useFileUplaod";

function EditAvatar({ inputId, image, name, onChange, prefix = "avatars" }) {
  const { uploadFile } = useFileUpload(onChange, prefix);

  const [file, setFile] = useState(null);

  useEffect(() => {
    uploadFile(file);
    // Do NOT put uploadFile function as dependency here
    // eslint-disable-next-line
  }, [file]);

  return (
    <div>
      <img src={image} alt={name} className="edit-avatar" />
      <input
        type="file"
        accept="image/jpeg, image/png"
        onChange={(e) => {
          setFile(e.target.files[0]);
        }}
        id={inputId}
        className="edit-file-input"
      />
      <div className="edit-menu-button">
        <Menu>
          <label htmlFor={inputId}>
            <MenuItem>Upload New</MenuItem>
          </label>
          {image && (
            <a href={image} target="_blank" rel="noreferrer">
              <MenuItem>Preview</MenuItem>
            </a>
          )}
          <MenuItem onClick={() => onChange(null)}>Remove</MenuItem>
        </Menu>
      </div>
    </div>
  );
}
```

You can find the rest of the code here:  
https://github.com/umakantv/yt-channel-content/tree/main/file-upload-using-s3

---

Thank you for reading, please follow/subscribe if you liked the content, I will share more such in-depth content related to full-stack development.

## Keep in Mind

### Limiting File Uploads

File upload should be limited so that adversaries can't abuse the feature and bloat your s3 bucket with unnecessary files. Some things you can do to be careful are:
* Creating Signed URL behind auth
* Rate limiting on creating Signed URLs with IP Address
* Specific key prefix format for each user/organization,  E.g. -
  * public/assets/<user_id>/<file_name>, 
  * public/assets/<user_id>/avatar/<file_name>

### Maintaining Storage
* Using S3 Lifecycle Rules
* Using S3 Object Expiry
* Deleting unused files in S3 bucket when replaced with new files

Happy learning. :)