hero

Upload File Menggunakan Next.js + Shadcn Ke AWS S3 Bucket

6 min read


Berbagi pengalaman dan tutorial untuk membuat sebuah file upload sederhana menggunakan Next.js, Shadcn dan AWS SDK.



PretextSection titled Pretext

Bulan lalu aku membuat sebuah internal tool untuk membantu rekan kerja agar bisa mengelola asset-asset gambar yang akan digunakan di website. Fiturnya standar, yaitu filter, search, dan juga upload. Aku membuatnya menggunakan Next.js versi 14, Shadcn/ui sebagai UI framework-nya, dan AWS S3 Bucket sebagai Simple Storage Service (S3).

Shadcn kupilih karena instalasi dan penggunaannya cukup simpel, komponen yang sudah di-generate pun bisa aku sesuaikan dengan kebutuhanku nantinya. Shadcn menggunakan Zod Schema dan React Hook Form untuk komponen form-nya. Karena ini baru pertama kalinya aku menggunakan Shadcn, ada beberapa hal yang menjadi tantangan karena tidak ada di dokumentasinya.

Penggunaan library aws-sdk sendiri juga ternyata menjadi pengalaman baru karena aku baru tahu kalau mereka sudah mengumumkan end-of-support (EOS) untuk SDK Javascript V2 dan akan digantikan oleh V3. Terbiasa menggunakan SDK yang lama, ada beberapa perubahan ketika menggunakan package ini. Seperti sekarang kita bisa memanggil servis yang dibutuhkan saja alih-alih memanggil semua servis yang ada di dalam SDK.

Shadcn/ui Upload FormSection titled Shadcn/ui Upload Form

Tutorial ini akan membahas langsung fitur file upload-nya. Untuk inisiasi Next.js dan Shadcn bisa melihat dokumentasinya. Pertama-tama, kita akan membuat dulu layout-nya. Spoiler alert, kodenya tidak akan jalan.

// 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>
  )
}

Jika kita mengikuti dokumentasi langsung dari Shadcn maka akan muncul 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.

Error tersebut terjadi karena awalnya komponen ini tidak mempunyai value (uncontrolled), tapi kemudian kita assign value tersebut dari state (controlled).

Langkah yang bisa kita ambil adalah sedikit memodifikasi kodenya menjadi seperti ini.

<!-- 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 -->

Dengan mendestruct field dan spread fieldProps ke komponen Input, kita bisa membawa props value dan onChange ke dalam komponen Input dan kemudian akan dimanage oleh React Hook Form.

AWS ConfigSection titled AWS Config

Lanjut untuk membuat fungsi upload, kita bisa membuat sebuah helper untuk klien S3.

// 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 }

Ada 2 cara untuk mengunggah file ke AWS S3, pertama menggunakan presigned URLs (@aws-sdk/s3-request-presigner) dan menggunakan direct upload (@aws-sdk/lib-storage).

Kalau bingung memilih yang mana, beberapa perbedaannya adalah sebagai berikut:

Presigned URLs

  • Presigned URL adalah tautan yang diberikan kepada user untuk memberikan akses sementara khusus untuk objek S3 tertentu.
  • Tautan yang dibuat oleh server menggunakan AWS SDK sudah termasuk kredensial, metode HTTP, dan juga waktu kedaluwarsa.
  • Klien bisa menggunakan tautan tersebut untuk langsung mengunggah file ke S3 tanpa harus memerlukan autentikasi ataupun menyimpan secret key.

Keuntungan menggunakan presigned URL yaitu server tidak perlu menghandle file upload, server hanya perlu mengenerate sebuah URL yang bisa kita buat hanya untuk sementara waktu.

Direct Upload

  • Membutuhkan kredensial AWS di mana fungsi ini diinisiasi, biasanya metode ini dilakukan di server.
  • Klien mengirimkan file ke server lalu dari server akan mengunggah ke S3.

Keuntungan menggunakan direct upload yaitu bisa menggunakan fitur-fitur lain seperti multipart upload, retry, dan juga kita bisa melacak progres, dan karena langsung menggunakan AWS SDK jadi tidak perlu menghandle kadaluwarsa atau tidaknya koneksi ke S3 tersebut.

Karena di sini kita membahas untuk mengunggah 1 file saja, kita akan menggunakan metode Presigned URL. Flownya akan menjadi seperti ini:

  1. Klien mendapatkan file yang akan diunggah.
  2. Klien akan mengirimkan data yang diperlukan kepada server.
  3. Server akan mengenerate URL untuk mengunggah ke S3.
  4. Klien akan menggunakan URL tersebut.

Server ActionSection titled Server Action

Di sini kita akan membuat sebuah server action untuk mengenerate 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: ""
    }
  }
}

Dengan menambahkan expiresIn, membuat URL kita hanya bisa diakses selama rentang waktu tersebut, menambahkan keamanan pada aplikasi yang kita buat.

Setelah itu, panggil fungsi getAWSSignedUrl di 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

Setelah itu jalankan kembali aplikasi yang kita buat. Jika masih terkendala CORS saat mengunggah ke S3 karena unggahnya dari klien, bisa ditambahkan aturan ini di S3 Bucket Permissions.

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

ConclusionSection titled Conclusion

Begitulah langkah-langkah untuk membuat sebuah fitur file upload ke AWS S3 menggunakan Next.js dan Shadcn. Setelah aku membuat fitur ini aku merasa membuat sebuah tools yang sederhana seperti file upload ini ternyata juga bisa menambah produktivitas. Developer tidak perlu untuk login ke layanan Simple Storage Service hanya untuk mengunggah file, dan bisa menggunakan waktu yang tersisa untuk mengerjakan hal lain.

Oh iya, untuk contoh kode bisa di-cek di repository ini.

Terima kasih sudah membaca. Jika ada pertanyaan ataupun masukan, bisa berkomentar di bawah.