hero

Upload File Using Next.js + Shadcn To AWS S3 Bucket

6 min read


Share an experience and tutorial to make a simple file upload using Next.js Shadcn and AWS SDK



PretextSection titled Pretext

Last month, I created an internal tool to help my coworkers manage image assets that will be used on the website. The features include filter, search, and upload. I developed it using Next.js version 14, Shadcn/ui as the UI framework, and AWS S3 Bucket as the Simple Storage Service (S3).

I decided to use Shadcn because of the simplicity of installation and usage, and the generated components can be tweaked as needed. Shadcn uses Zod Schema and React Hook Form for the Form component. Since this is my first time using Shadcn, there were some challenges because certain aspects were not documented.

Using the aws-sdk library was also a new experience for me because they have announced the end-of-support (EOS) for SDK Javascript V2, which has been replaced with V3. Having been used to the old SDK, there are some differences, such as now being able to use only the packages needed for a specific service instead of the entire SDK package.

Shadcn/ui Upload FormSection titled Shadcn/ui Upload Form

In this tutorial, I will cover the file upload feature. For initializing Next.js and Shadcn, you can check the docs. First, we will create the layout. Spoiler alert, the code will not work.

// app/page.tsx
"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormMessage
} from "@/components/ui/form"

const MAX_FILE_SIZE = 1000000 // 1MB
const ACCEPTED_IMAGE_TYPES = [
  "image/png",
  "image/jpeg",
  "image/svg+xml",
  "image/webp"
]

const formSchema = z.object({
  file: z.any().refine((file) => {
    if (!file) return false
    if (file.size > MAX_FILE_SIZE) return false
    if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) return false
    return true
  })
})

export default function Home() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema)
  })

  async function onSubmit(values: z.infer<typeof formSchema>) {
    console.log(values)
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <FormField
            control={form.control}
            name="file"
            render={({ field }) => (
              <FormField
                control={form.control}
                name="file"
                render={({ field }) => (
                  <FormItem>
                    <FormControl>
                      <Input
                        {...field}
                        type="file"
                        className=" mb-4"
                        accept="image/*"
                      />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}
          />
          <Button className=" w-full" type="submit">
            Upload Image
          </Button>
        </form>
      </Form>
    </main>
  )
}

If we follow the documentation in Shadcn, it will show an error:

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component.

This error occurs because the component initially doesn’t have a value (uncontrolled), but then we assign a value from the state (controlled).

To fix this, we can modify the code to be like this.

<!-- REST OF CODE -->

<FormField
  control={form.control}
  name="file"
  render={({ field: { value, onChange, ...fieldProps } }) => (
    <FormItem>
      <FormControl>
        <Input
          {...fieldProps}
          type="file"
          className=" mb-4"
          accept="image/*"
          onChange={(event) =>
            onChange(event.target.files && event.target.files[0])
          }
        />
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

<!-- REST OF CODE -->

By destructuring field and spreading fieldProps into the Input component, we can bring the props value and onChange into the Input component, which will then be managed by React Hook Form.

AWS ConfigSection titled AWS Config

Next, to create the upload function, we can make a helper for the S3 client.

// lib/aws.ts

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

// Initialize S3Client instance
const client = new S3Client({
  region: process.env.NEXT_AWS_REGION || '',
  credentials: {
    accessKeyId: process.env.NEXT_AWS_ACCESS_KEY_ID || '',
    secretAccessKey: process.env.NEXT_AWS_SECRET_KEY || '',
  },
})

export { client }

There are two ways to upload files to AWS S3: using presigned URLs (@aws-sdk/s3-request-presigner) and using direct upload (@aws-sdk/lib-storage).

If you are confused about which one to choose, here are some differences:

Presigned URLs

  • A Presigned URL is a link given to the user to provide temporary access to a specific S3 object.
  • The link generated by the server using AWS SDK includes credentials, HTTP method, and expiration time.
  • The client can use this link to directly upload files to S3 without needing authentication or storing secret keys.

The advantage of using a presigned URL is that the server does not need to handle the file upload; it only needs to generate a URL that can be used temporarily.

Direct Upload

  • Requires AWS credentials where the function is initiated, usually done on the server.
  • The client sends the file to the server, and the server uploads it to S3.

The advantage of using direct upload is that it can utilize other features such as multipart upload, retry, and progress tracking. Additionally, since it directly uses AWS SDK, there is no need to handle expiration or connection issues to S3.

Since we are discussing uploading a single file here, we will use the Presigned URL method. The flow will be as follows:

  1. The client gets the file to be uploaded.
  2. The client sends the required data to the server.
  3. The server generates a URL to upload to S3.
  4. The client uses that URL.

Server ActionSection titled Server Action

Here we will create a server action to generate a presigned URL.

// app/actions.ts
"use server"

import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { PutObjectCommand } from "@aws-sdk/client-s3"
import { client } from "@/lib/aws"

export async function getAWSSignedUrl({
  fileName,
  fileType
}: {
  fileName: string
  fileType: string
}): Promise<{ status: boolean; putUrl: string }> {
  try {
    const command = new PutObjectCommand({
      Key: `uploads/${fileName}`,
      ContentType: fileType,
      Bucket: process.env.NEXT_AWS_BUCKET_NAME
    })

    // Generate pre-signed PUT URL
    const putUrl = await getSignedUrl(client, command, { expiresIn: 500 })

    return {
      status: true,
      putUrl
    }
  } catch (error) {
    return {
      status: false,
      putUrl: ""
    }
  }
}

By adding expiresIn, the URL we generate can only be accessed within that time range, enhancing the security of our application.

After that, call the getAWSSignedUrl function in page.tsx.

// page.tsx

import { getAWSSignedUrl } from "@/app/actions"

// REST OF CODE

async function onSubmit(values: z.infer<typeof formSchema>) {
  const { file } = values

  const formData = new FormData()
  formData.append("file", file)

  const { putUrl } = await getAWSSignedUrl({
    fileName: file.name,
    fileType: file.type
  })

  const response = await fetch(putUrl, {
    body: file,
    method: "PUT",
    headers: { "Content-Type": file.type }
  })

  if (!response.ok) {
    console.error("Failed to upload image")
    return
  }

  console.log("Image uploaded successfully")
}

// REST OF CODE

After that, run the application again. If you still encounter CORS issues when uploading to S3 because the upload is from the client, you can add this rule in the S3 Bucket Permissions.

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT"
        ],
        "AllowedOrigins": [
            "http://localhost:3000"
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 3000
    }
]

ConclusionSection titled Conclusion

Those are the steps to create a file upload feature to AWS S3 using Next.js and Shadcn. After creating this feature, I realized that building a simple tool like file upload can indeed increase productivity. Developers do not need to log in to the Simple Storage Service just to upload files, and they can use the remaining time to work on other tasks.

The code example can be found in this repository.

Thank you for reading. If you have any questions or feedback, feel free to comment below.