Drag & Drop multiple images & upload with NextJs, AWS s3 and TailwindCSS

Drag & Drop multiple images & upload with NextJs, AWS s3 and TailwindCSS

Β·

13 min read

ℹ️ I recently did some work on this for a project I am working on, and I don't know if it was just me, but it was not the most enjoyable experience trying to figure out the correct configuration for a frontend file upload to AWS s3 bucket.
I wanted to share my solution, hopefully, it can help others πŸ˜ƒ.

I also want to acknowledge that there are plenty of dependencies available to do all this work for you around file uploads, but considering the file upload API doesn't change very often, it might be a better option to manage this yourself.

AWS s3 bucket setup πŸ“

Setting up an account and logging in as a root user I will leave it up to you to figure out (I can answer questions about that setup if a comment is left about it πŸ‘).
Once logged in, add s3 to the search in the top left, beside the services waffle menu, the top result "S3" will be what you click on. (It is useful to click on the star)

Now you want to create a bucket

I have left all the suggested default settings as they are, except for the "Block Public Access setting for this bucket" this setting I have deactivated and consented to.

Now scroll down to the bottom and click "Create Bucket".
That covers the bucket creation config, I am sure per your requirements, you can make the changes you need. For this example, default settings are good enough.


Bucket Permissions πŸ”

You should see your bucket in the "Buckets" table list now, click on it, and now click on the "Permissions" tab.

update the following sections to what you see below;

Bucket Policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicListGet",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:List*", // could be "s3:ListBucket"
                "s3:Get*" // could be "s3:GetObject"
            ],
            "Resource": "arn:aws:s3:::view-my-property-au"
        },
        {
            "Sid": "PutFiles",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::view-my-property-au/*"
        }
    ]
}

In the above policy, we have two Statements.

  1. The first is for allowing access to view our images, either a list or individual images.

  2. The second is allowing access to some CRUD actions see the prefix for which actions we are allowing.

  3. The Resource each of the actions has an effect on is slightly different, this is a small distinction to show in this case, that you cannot PUT files without a name or subfolder, you cannot delete all files in a bucket and you can GET individual files.

  4. The Bucket Policy Elements each has a description that can better provide you with some context on the JSON structure: Policy Language Overview & Here

Note: There are no "Conditions" for the Statement "Actions".
Conditional Actions can only be applied to one action, here we have an array of actions. In the case of Delete & Put it is safe to assume that these actions would only be accessible after authentication, and this authentication would only grant access to the images the authed user owns, therefore it is safe to leave these actions without conditions on the s3 bucket. (That is my assumption, happy to hear otherwise, or of a better option).


Cross-origin resource sharing (CORS)

As stated in the cors section of the bucket; "The CORS configuration, written in JSON, defines a way for client web applications that are loaded in one domain to interact with resources in a different domain". This section feels pretty straightforward to me, but please leave a comment if you would like more info, or see here for some examples

[
    {
        "AllowedHeaders": [
            "*" // could be defined if needed
        ],
        "AllowedMethods": [
            "HEAD",
            "PUT",
            "POST",
            "GET"
        ],
        "AllowedOrigins": [
            "*" // should be defined once in production
        ],
        "ExposeHeaders": [
            "ETag",
            "x-amz-meta-custom-header" // not always needed
        ]
    }
]

AWS s3 client setup

import { S3Client } from '@aws-sdk/client-s3'

export const s3Client = new S3Client({
  region: process.env.REGION,
  credentials: {
    accessKeyId: process.env.ACCESS_KEY,
    secretAccessKey: process.env.SECRET_KEY,
  },
})
  • Install dependency

    • yarn add @aws-sdk/client-s3

    • npm install @aws-sdk/client-s3

  • Add your keys to the .env file.

    (see below how to set up a user and get these keys)

    • ACCESS_KEY

    • SECRET_KEY


IAM user (Programmatic) setup πŸ™ˆ

  • The following steps are how you can get the above KEYs

    • Add iam to the search in the top left, beside the services waffle menu, the top result "IAM" will be what you click on.

    • In the menu on the left click "users"

    • Click "Add users"

    • Add the User name and select "Access key - Programmatic access"

  • Go to the next section, section 2

  • Here you can either create a group with the correct permissions or add the correct permissions direct to the user

    • If you have created a group with the correct permissions select it, Or

    • Search for AmazonS3FullAccess and select it to add to the user

      • If you have not set up a group you will only see a place to search for permissions. What you see will look similar to the image just below.
    • Then go to the next optional step.

At the end of this setup, you will be presented with the Access key ID and the Secret access key as listed above, copy them into your .env, .env.local...


Drag&Drop upload components 🫳

UploadFile usage

<UploadFile<ListingDetailsData> // add type to make UploadFile more generic
  {...{
    formDataFields, // some state that is used for adding files to subfolder
    currentFiles, // image urls
    setCurrentFiles, // state setter for image urls
    hasNewFiles, // optional boolean used for updating loader and or user feedback
    setHasNewFiles, // state setter for hasNewFiles boolean
  }}
/>

UploadFile component πŸ—‚οΈ

import React, { useState, Dispatch, SetStateAction } from 'react'
import dynamic from 'next/dynamic'
import { endPoint } from '../../utils/endPoint'
import { handleUploadFiles } from './fileUploadFunction'
import imageResize from './imageResize'
import useWindowSizeTracker from '../../hooks/useWindowSizeTracker' // a hook that keeps track of the window dimensions. Window size tracking could be done with TailwindCSS.

const FlexGridLayout = dynamic(() => import('../common/flexGridLayout')) // just a wrapper for flex which can be used as is or passed some TailwindCSS class styling.
const DragAndDrop = dynamic(() => import('./dragAndDrop'))
const BASE_URL = process.env.BACKEND_URL || endPoint

interface UploadFileProps<T> {
  formDataFields: T
  currentFiles: Record<string, string>[] // TODO:: should be better typed
  setCurrentFiles?: Dispatch<SetStateAction<Record<string, string>[]>>
  hasNewFiles?: boolean
  setHasNewFiles?: Dispatch<SetStateAction<boolean>>
}

const UploadFile = <T,>({
  formDataFields,
  currentFiles,
  setCurrentFiles,
  // hasNewFiles,
  // setHasNewFiles,
}: UploadFileProps<T>) => {
  // ** OPTIONAL STATE you can pass to DragAndDrop if needed. **
  // uncomment and pass to DragAndDrop if you want to use.

  // const [error, setError] = useState<unknown>(null)
  // const [uploadSize, setUploadSize] = useState<string>('0')
  // const [uploadFiles, setUploadFiles] = useState<Record<string, string>>({})
  // const [uploadProgress, setUploadProgress] = useState<string>('')
  // const [uploadRunning, setUploadRunning] = useState<boolean>(false)

  // TODO:: Maybe just do all of this with TailwindCSS
  const { windowSize } = useWindowSizeTracker() 

  const handleDrop = (files: FileList) => {
    for (let i = 0; i < files.length; i += 1) {
      const file = files[i]
      if (!file.name) {
        return
      }

      const fileSize = file.size
      // TODO:: add a function for file size check handling
      const fileSizeCheck = Math.round(fileSize / 1024)

      if (fileSizeCheck < 1024) {
        handleUploadFiles<T>(
          file,
          `${BASE_URL}/s3-upload`,
          formDataFields,
        ).then(response => {
          setCurrentFiles(prevState => [...prevState, { imageUrl: response }])
        })
      } else {
        const image = imageResize<T>(file, formDataFields)

        setCurrentFiles(prevState => [...prevState, { imageUrl: image.url }])
      }
    }
  }

  return (
    <>
      <div className="w-full h-full flex text-left flex-col px-4">
        <DragAndDrop
          {...{
            windowSize,
            handleDrop,
          }}
        >
          <FlexGridLayout
            childContainerClass="w-12"
            containerClass="w-full absolute h-28 top-52 left-1/2 transform -translate-x-1/2 flex flex-wrap items-center overflow-y-scroll mb-4 space-x-2 z-50"
          >
            {currentFiles?.length > 0 &&
              currentFiles.map(({ imageUrl }: Record<string, string>) => {
                return (
                  <img
                    src={imageUrl}
                    key={imageUrl}
                    alt="upload-preview-image"
                    className="w-full object-contain rounded"
                  />
                )
              })}
          </FlexGridLayout>
        </DragAndDrop>
      </div>
    </>
  )
}

export default UploadFile
  • FileList is an object but has some iterator properties like length. This means you will have to use the synchronous OG for loop πŸ‘

  • fileSizeCheck here we want to know if each image is greater than 1MB or not if so we will resize it to be smaller.

  • handleUploadFiles<T>

    • file = image file

    • `${BASE_URL}/s3-upload` = path to Nextjs API handler

    • formDataFields = object with some state in it for constructing an image path in s3 bucket. (or whatever you need it for)

    • imageResize<T> uses the handleUploadFiles<T> function under the hood, once you have finished resizing your image the same args are passed to the handleUploadFiles<T>.

    • handleUploadFiles<T> expects a <File> type not a base64string, but you can change this if you prefer.


DragAndDrop component

import React, { useState, useEffect, useRef, ReactNode } from 'react'
import ActiveAnimation from '../../activeAnimation' // just a loader indicating to user image is being uploaded
import useEventListener from '../../../hooks/useEventListener'
import { WindowSizeTracker } from '../../../hooks/useWindowSizeTracker'

type DragAndDropProps = {
  children: ReactNode // This could be made optional children?:
  windowSize: WindowSizeTracker // { width: number, height: number }
  handleDrop: (fileList: FileList) => void
  uploadSize?: string
  uploadRunning?: boolean
  uploadProgress?: string
}

const DragAndDrop = ({
  children,
  handleDrop,
  uploadProgress,
  uploadRunning,
  uploadSize,
  windowSize, 
}: DragAndDropProps) => {
  const [dragging, setDragging] = useState<boolean>(false) // this state is used to provide user with feedback on dragging "safe" area.

  const dropRef = useRef<HTMLDivElement>(null)
  const selectRef = useRef<HTMLInputElement>(null)

  const handleDrag = (event: DragEvent) => {
    event.preventDefault()
    event.stopPropagation() // stops the event from being propagated through parent and child elements. So we can do what we want!
  }

  const handleDragIn = (event: DragEvent) => {
    event.preventDefault()
    event.stopPropagation()

    // conditional chaining can be used here if you prefer
    // event.dataTransfer?.items?.length > 0
    if (
      event.dataTransfer !== undefined &&
      event.dataTransfer.items &&
      event.dataTransfer.items.length > 0
    ) {
      setDragging(true) // TODO::consider a better name for this state
    }
  }

  const handleDragOut = (event: DragEvent) => {
    event.preventDefault()
    event.stopPropagation()

    setDragging(false)
  }

  const handleDropItem = (event: DragEvent) => {
    event.preventDefault()
    event.stopPropagation()
    const target = event.target as HTMLInputElement


    if (
      event.dataTransfer === undefined &&
      target.files &&
      target.files.length > 0
    ) {
      const fileList: FileList = target.files
      handleDrop(fileList)
    } else if (
      event.dataTransfer !== undefined &&
      event.dataTransfer.files &&
      event.dataTransfer.files.length > 0
    ) {
      handleDrop(event.dataTransfer.files)
    }
  }

  useEventListener('dragenter', handleDragIn, dropRef)
  useEventListener('dragleave', handleDragOut, dropRef)
  useEventListener('dragover', handleDrag, dropRef)
  useEventListener('drop', handleDropItem, dropRef)
  useEventListener('change', handleDropItem, selectRef)

  return (
    <div className="top-0 w-full h-80 flex relative" ref={dropRef}>
      <div className="h-full right-0 bottom-0 left-0 text-center absolute border-dashed border-2 border-gray-800 z-40 bg-gray-100">
        {dragging && !uploadProgress && (
          <div className="top-1/2 text-theme-grey text-4xl right-0 left-0 text-center absolute">
            <div>
              drop here
              <span
                role="img"
                className="ml-2"
                aria-label="Beaming Face with Smiling Eyes"
              >
                😁
              </span>
            </div>
          </div>
        )}
        {!dragging && !uploadProgress ? (
          <div className="top-1/2 text-theme-grey text-4xl right-0 left-0 text-center absolute">
            Drag and Drop
          </div>
        ) : (
          uploadProgress && (
            <div className="top-1/2 text-theme-grey text-4xl right-0 left-0 text-center absolute">
              {uploadProgress}
              {uploadSize && (
                <div>Error: image/s must be below 1Mb or 1028Kb in size</div>
              )}
              {uploadRunning && (
                <ActiveAnimation active="" backgroundColour="" />
              )}
            </div>
          )
        )}
        <label
          htmlFor="file-upload"
          className="flex flex-row items-center mt-16 mx-auto w-32 h-16 bg-contrast-colour hover:bg-gray-500 active:bg-gray-500 text-center text-white border border-1 border-gray-200 border-opacity-10 font-base rounded-lg cursor-pointer"
        >
          <span className="w-full text-center">
            {windowSize.width >= 800 ? 'Upload' : 'Take photo'}
          </span>
          <input
            multiple
            type="file"
            id="file-upload"
            accept="image/*"
            className="hidden"
            ref={selectRef}
          />
        </label>
      </div>
      {children}
    </div>
  )
}

export default DragAndDrop
  • dragging state is used to indicate to the user they can drop the files in the "safe" area.

  • target.files Vs event.dataTransfer.files see this StackOverflow discussion

  • handleDrop is a prop passed down to handle uploading to s3 bucket

  • useEventListener see Add custom context menu to your website with React & TypeScript for an example of this hook

  • windowSize.width is used to show different text when DragAndDrop is being used on a mobile,

  • all event listeners are listening on the respective ref

  • input type file takes care of uploading on mobile device and the native mobile image select or image capture will take over in this case

    • We have limited the input to any image/* type, this is untested with Apple's image type HEIC

    • Must be selected from the album, this includes capturing images with the camera

      • <input type="file" accept="image/*">
    • Must be captured from the camera, this is only allowing you to capture images with the camera no photo album access.

      • <input type="file" accept="image/*" capture="">
    • The input is hidden with a htmlFor reference on the label so we can customize the style on the browser's default "Browse..." input. The selectRef handles the onChange functionality for us.

    • children in this case is just a thumbnail preview of all uploaded images.

Before dragging

After dragging

After the image is uploaded to the s3 bucket


The handleUploadFiles function

import { singlePostRequest } from '../../pages/api/requests' // just a wrapper for axios.post()
import { convertGenericTypeToListingDetails } from '../../forms/addListing/helpers/listingDetailsValueChecker' // some type checking on generic <Type> used in this component
import {
  removeSpecialCharacters,
  replaceWhiteSpace,
} from '../../utils/helpers/commonFunctions' // some regex helper functions to strip out unwanted string items

type FileResponse = {
  url: string
  fields: Record<string, string> // TODO:: better type this
}

type FileRequest = {
  fileType: string
  fileName: string
}

export const handleUploadFiles = async <T>(
  file: File,
  apiUrl: string,
  formDataFields?: T, // type is checked before being used here
) => {
  const fileName = encodeURIComponent(file.name)
  const fileType = encodeURIComponent(file.type)

  const details = convertGenericTypeToListingDetails(formDataFields)
  const fileTitleFormatted = encodeURIComponent(
    replaceWhiteSpace(removeSpecialCharacters(details.fileTitle, ' '), '-'),
  )
  // get presigned put url to send file to s3 bucket
  // TODO:: probably should move this presigned POST to its own little function and add better typing for it.
  const { data } = await singlePostRequest<FileRequest, FileResponse>(
    `${apiUrl}?file=${fileName}&fileType=${fileType}&listingId=${details.fileId}&listingTitle=${fileTitleFormatted}`,
    {
      fileType,
      fileName,
    },
  )
  const { url, fields } = data

  const formData = new FormData()
  // NOTE:: the order of which these fields are appended to the FormData object are important
  // the file: File must be the list attribute in the formData object.
  Object.entries({ ...fields, file }).forEach(([key, value]) => {
    formData.append(key, value)
  })

  // NOTE:: this post request is successful if and only if it returns a 204 status.
  const uploadResponse = await singlePostRequest<FormData, unknown>(
    `${url}`,
    formData,
  )

  if (uploadResponse) {
    // return image url path used in your s3 bucket of image just uploaded this is used for thumbnail preview.
    return `${url}${details.fileId}-${fileTitleFormatted}/${fileName}`
  } else {
    // TODO:: add a toast or some feedback here and logging here
    console.error('Upload failed.')
  }
}
  • Get Presigned URL for uploading files from the frontend to the s3 bucket

  • data

    • url = The values used in the URL used to get the presigned URL must match, see below for how these fields are used in the upload config for presigned URL.

    • fields = These fields must be sent in the payload body of the post request that is using the presigned URL, along with your file.

      • I recommend console.log('presigned fields ->', fields) the fields to get a better understanding of what this object consists of.
    • The file must be the last item on the payload body object, or in this case the formData object

      • Object.entries({ ...fields, file })

Upload config for presigned URL, API handler

import { NextApiRequest, NextApiResponse } from 'next'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
import { s3Client } from '../../../lib/awsS3Client'

const uploadHandler = async (
  req: NextApiRequest,
  res: NextApiResponse,
): Promise<void> => {
  const {
    query: { file, fileType, fileId, fileTitle },
    method,
  } = req

  switch (method) {
    case 'POST':
      try {
        const post = await createPresignedPost(s3Client, {
          Bucket: process.env.S3_BUCKET_NAME,
          // Key: must match POST url for file upload
          Key: `${fileId}-${fileTitle}/${file}`,
          Fields: {
            ContentType: fileType as string,
          },
          Expires: 600, // seconds
          Conditions: [
            { bucket: process.env.S3_BUCKET_NAME },
            ['starts-with', '$ContentType', 'image/'],
          ],
        })

        res.status(200).json(post)
      } catch (error: any) {
        console.error('πŸš€ ~ file: index.ts ~ line 45 ~ error', error)
        // throw error for errorWrapper to handle?
        // send to some error logging service?
        res.json(error)
        // throw error
      }
    default:
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default uploadHandler
  • You will need to install @aws-sdk/s3-presigned-post

    • npm install @aws-sdk/s3-presigned-post

    • yarn add @aws-sdk/s3-presigned-post

  • createPresignedPost handles all the complexity of getting the presigned URL for file upload, you can see it can be configured with options passed in.

  • Key: = This matches the folder and file path used in the s3 bucket which goes after the presigned URL.

  • Conditions: = Checking the bucket is correct and matches the Bucket: and also check the file type is allowed.

  • This API handler only allows POST method

  • You will need to add the following key values to your .env file

    • S3_BUCKET_NAME

    • BACKEND_URL this most likely will be /api/ or something very similar.


There is a lot to go through in this blog, yell out if something doesn't quite make sense or you see some smelly code! At this point you can now drag and drop multiple image files and have them upload to a sub folder directory in your AWS s3 bucket.

Any questions leave me a comment πŸ™Œ or dΓ©jame un comentario en espaΓ±ol πŸ™Œ
(I am working on my espanol)

Did you find this article valuable?

Support Rory by becoming a sponsor. Any amount is appreciated!

Β