How to Upload Image to AWS S3 Bucket with React & NodeJS

main

Let's upload an image to an AWS S3 bucket using React and NodeJS.

Here we have a form with an avatar input. We can drag an image, and the app will save it to the AWS S3 bucket and return a link to the avatar.

The UploadAvatarInput component takes a value that is imageUrl, onChange handler. We use the react-dropzone library to allow the user to drop an image to the input. All we need to do is to pass root props to the container, and input props to a hidden input inside. On the drop, we'll check if the file has an accepted format and call the uploadAvatar function.

export const UploadAvatarInput = ({ value, onChange, error }: Props) => {
  const { mutate: uploadAvatar, isLoading: isAvatarUploading } = useMutation(
    async (image: File) => {
      const fileExtension = getLast(image.name.split("."))
      const { url } = await uploadAvatarParamsQuery({ fileExtension })

      const avatar = await uploadAvatarToStorage({ url, image })
      onChange(avatar.url)
    }
  )

  const { openSnackbar } = useSnackbar()

  const { getRootProps, getInputProps } = useDropzone({
    maxFiles: 1,
    onDrop: acceptedFiles => {
      const [image] = acceptedFiles

      const { type } = image
      if (!supportedFileTypes.includes(type)) {
        openSnackbar({ text: "Unsupported file type" })
      }

      uploadAvatar(image)
    },
  })

  return (
    <Container
      error={error}
      showContentOnlyOnHover={Boolean(value) && !isAvatarUploading}
      {...getRootProps()}
    >
      <Input {...getInputProps()} />
      {value && <AvatarBackground src={value} />}
      <Content>
        {isAvatarUploading ? (
          <Loader />
        ) : (
          <VStack alignItems="center" gap={12}>
            <AddPhotoIcon />
            <VStack alignItems="center" gap={4}>
              <Text centered>
                {value
                  ? "Change profile picture"
                  : "Upload profile picture with your face 😀"}
              </Text>
            </VStack>
          </VStack>
        )}
      </Content>
    </Container>
  )
}

To make that asynchronous operation, we'll leverage the react-query library. Here we take the file extension from the image and call the uploadAvatarParamsQuery. It queries our NodeJS API to receive a short-lived pre-signed URL we can use to upload the image to S3 directly from the front-end.

import { assertUserId } from "../../auth/assertUserId"
import { OperationContext } from "../../graphql/OperationContext"
import { s3, PUBLIC_FILES_BUCKET } from "../../common/storage"

interface UploadAvatarParamsInput {
  fileExtension: string
}

interface UploadAvatarParams {
  url: string
}

export const uploadAvatarParams = async (
  _: any,
  { input }: { input: UploadAvatarParamsInput },
  context: OperationContext
): Promise<UploadAvatarParams> => {
  const userId = assertUserId(context)

  const filename = [userId, `${Date.now()}-avatar.${input.fileExtension}`].join(
    "/"
  )

  const url = await s3.getSignedUrlPromise("putObject", {
    Bucket: PUBLIC_FILES_BUCKET,
    Key: filename,
    ContentType: `image/${input.fileExtension}`,
    Expires: 100,
  })

  return {
    url,
  }
}

On the back-end, we'll create a path for the image by combining the user's id with a random string that will serve as the image name. After that, we'll create a pre-signed URL using AWS SDK. Once we have that URL on the front-end, we can call the uploadAvatarToStorage that runs the PUT method against the URL. In response, AWS S3 will return us an URL we can save as an avatar field in our database.

I've created the bucket with Terraform, but you can do the same through AWS CLI or web interface. We make the bucket public, allow CORS, and set the policy.

resource "aws_s3_bucket" "public_files" {
  bucket = var.public_files_bucket
  acl    = "public-read"

  cors_rule {
    allowed_headers = ["*"]
    allowed_methods = ["PUT", "POST", "GET"]
    allowed_origins = ["*"]
    max_age_seconds = 3000
  }

  policy = <<EOF
{
  "Id": "bucket_policy_site",
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "bucket_policy_site_main",
      "Action": [
        "s3:GetObject"
      ],
      "Effect": "Allow",
      "Resource": "arn:aws:s3:::${var.public_files_bucket}/*",
      "Principal": "*"
    }
  ]
}
EOF
}