Upload file to S3 Bucket with NextJS using Server Actions

·

4 min read

Uploading files to an S3 bucket in a Next.js application can be streamlined using Server Actions, a powerful feature that enables handling server-side logic directly within your components. This guide walks you through setting up file uploads with Next.js, Server Actions, and AWS S3.

State variables we need

  const fileInputRef = useRef<HTMLInputElement>(null);
  const [isLoading, setIsLoading] = useState(false);

Our button (from Shadcn)

<Button
  disabled={isLoading}
  className='w-full justify-start space-x-2'
  onClick={() => fileInputRef.current?.click()}
>
  <FileUp size={18} />
  <span>Subir resultados</span>
  <Input
    ref={fileInputRef}
    disabled={isLoading}
    className='hidden'
    type='file'
    accept='application/pdf' placeholder='Resultados'
    id='upload-results'
    onChange={async (event) => {
      await handleResultsUpload(event, est)
    }} />
</Button>

The handler, route: `order-${estimate.id}/result` is the Bucket where our file will be

  const handleResultsUpload = async (event: React.ChangeEvent<HTMLInputElement>, estimate: Estimate) => {
    try {
      setIsLoading(true)
      if (event.target.files) {
        const file = event.target.files[0];
        const data = new FormData()
        data.append('file', file, 'resultados.pdf')

        const response = await uploadFileS3({
          route: `order-${estimate.id}/result`,
          fileName: estimate.qrId,
          content: data,
        })

        response.ok ? toast.success('Resultados guardados') : toast.error('Ocurrio un error al guardar los resultados')
        setIsLoading(false)
      }
    } catch (error) {
      toast.error('Ocurrio un error insesperado')
    } finally {
      setIsLoading(false)
    }
  }

The server action that will upload the file to our Bucket

'use server';

import { S3Client, PutObjectCommand, ListObjectsCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { parseError } from '../utils';

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

export const uploadFileS3 = async ({
  route,
  fileName,
  content
}: {
  route: String,
  fileName: String,
  content: FormData
}) => {
  try {
    let file = content.get('file') as File
    const arrayBuffer = (await file.arrayBuffer())
    const buffer = Buffer.from(arrayBuffer)
    if (!file) throw 'No se encontro el archivo para subir.'

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

    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME || '',
      Key: `${route}/${fileName}`,
      ContentType: 'application/pdf',
      Body: buffer
    })

    const response = await s3Client.send(command)

    return {
      ok: response.$metadata.httpStatusCode === 200,
    }
  } catch (error) {
    console.trace(error)
    return {
      ok: false
    }
  }
}

The final result

And if we check our bucket in AWS, we will have our file with a directory like structure

To download our file we would need to allow public access to our Bucket, I would not recommend doing this, I preffer to get a signed url so that we can download our file

This is the server action

'use server';

...

export const getObjectSignedUrl = async ({ key }: { key: string }) => {
  const params = {
    Bucket: process.env.AWS_S3_BUCKET_NAME,
    Key: key,
  };

  try {
    const command = new GetObjectCommand(params);
    // this will throw an exeption if fails
    const response = await s3Client.send(command)

    const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });

    return {
      ok: true,
      data: url
    }
  } catch (error) {
    parseError(error)
    return {
      ok: false,
      error: 'Ocurrio un error al obtener el documento desde S3'
    }
  }
}

In our fronted we can have, the key would be like:

order-1440/result/ae4d3845-3331-44e1-9cbf-f91716f3598e

  const handleDownload = async ({
    objectKey,
  }: {
    objectKey: string,
  }) => {
    try {
      const signedUrl = await getObjectSignedUrl({ key: objectKey })

      if (!signedUrl.ok || !signedUrl.data) {
        toast.error('Error al descargar el objeto')
        return;
      }

      window.open(signedUrl.data, '_blank');
    } catch (error) {
      console.log(error)
      toast.error('Ocurrio un error al descargar')
    } finally {

    }
  }

Server action to list our objects

'use server';
...

export const listObjectsS3 = async ({
  route,
}: {
  route: String,
}) => {
  try {

    const command = new ListObjectsCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME || '',
      Prefix: `${route}`,
      Delimiter: '/'
    })

    const response = await s3Client.send(command)

    return {
      ok: response.$metadata.httpStatusCode === 200,
      data: response.Contents?.map(content => content.Key)
    }
  } catch (error) {
    console.trace(error)
    return {
      ok: false
    }
  }
}

In our fronted we can get the objects with, this just gets the first object

const getObjectsS3 = async () => {
    try {
      setIsLoading(true)
      const route = `order-${estimateId}/result/`
      const objectsS3 = await listObjectsS3({ route })

      if (!objectsS3.ok) return;
      if (!objectsS3.data?.length) return;

      setFiles([...objectsS3.data])
    } catch (error) {
    } finally {
      setIsLoading(false)
    }
  }