kodingup.camp

Beranda/Next.js Mastery: Full-Stack, Auth & Deploy

Server Actions & Form

Modul 2 dari 116 menit bacaServer Actions and Forms

Isi modul

    Kenapa ini penting

    Coba ingat cara lama mengirim form di React: buat state untuk setiap input, tulis onSubmit, panggil e.preventDefault(), fetch ke endpoint API, atur state loading, tangkap error, lalu refresh data di layar. Untuk satu form saja bisa 40 baris lebih, dan kamu masih harus membuat endpoint API terpisah di backend.

    Server Actions memotong hampir semua langkah itu. Kamu menulis satu function async bertanda 'use server', lalu memberikannya ke atribut action milik <form>. Saat tombol submit ditekan, React mengirim isi form ke server, menjalankan function itu di server, lalu memperbarui halaman, semuanya dalam satu perjalanan bolak-balik. Bonusnya: form tetap bisa dikirim walau JavaScript di browser belum selesai dimuat (progressive enhancement).

    Analogi: Route Handler itu seperti mengirim surat lewat pos, kamu harus menulis alamat lengkap, perangko, dan menunggu balasan. Server Action itu seperti interkom di meja resepsionis: kamu tekan tombol, bicara, dan petugas di dalam (server) langsung mengerjakan permintaanmu lalu menyerahkan hasilnya.

    Konsep inti

    Directive 'use server'

    Server Action adalah Server Function (function yang berjalan di server) yang dipakai untuk mutasi data. Ada dua cara mendeklarasikannya:

    1. Di file terpisah (cara yang dianjurkan): tulis 'use server' di baris paling atas file, misalnya app/actions/posts.js. Semua function yang di-export dari file itu menjadi Server Action dan bisa di-import oleh Server Component maupun Client Component.
    2. Inline di Server Component: tulis 'use server' di baris pertama body function. Cara ini praktis untuk contoh kecil, tapi tidak bisa dipakai di dalam Client Component.

    Saat build, Next.js mengganti isi function itu di bundle browser dengan sebuah referensi (ID action terenkripsi). Kode aslinya, termasuk query database dan rahasia, tetap tinggal di server.

    Form + action + FormData

    Saat function diberikan ke <form action={createPost}>, ia otomatis menerima objek FormData, yaitu kumpulan pasangan nama-nilai dari semua input yang punya atribut name. Ambil nilainya dengan formData.get('title'). Checkbox yang dicentang bernilai 'on', yang tidak dicentang tidak dikirim sama sekali (hasilnya null).

    Butuh argumen tambahan selain isi form, misalnya id artikel yang sedang diedit? Gunakan bind: const action = updatePost.bind(null, post.id). Function updatePost(id, prevState, formData) akan menerima id sebagai argumen pertama.

    useActionState: menampilkan hasil dan error

    Form yang hanya mengirim data belum cukup; pengguna perlu tahu kalau judulnya terlalu pendek. Hook React 19 useActionState menghubungkan form dengan state hasil action:

    const [state, formAction, pending] = useActionState(action, initialState)
    

    Karena memakai hook, komponen form harus Client Component ('use client'). Perhatikan juga bahwa tanda tangan action berubah: argumen pertama sekarang prevState, baru kemudian formData.

    useFormStatus: tombol yang tahu sedang loading

    useFormStatus (dari react-dom) mengembalikan { pending, data, method, action } milik form induk. Hook ini hanya bekerja di komponen yang berada di dalam <form>, bukan di komponen yang merender <form> itu sendiri. Pola umum: buat komponen SubmitButton kecil yang dipakai ulang di semua form.

    Validasi di server dengan Zod

    Atribut HTML seperti required atau minLength hanya kenyamanan, karena bisa dilewati dengan mudah lewat DevTools atau curl. Validasi yang sebenarnya harus di server. Library Zod (versi 4) membuat validasi jadi deklaratif:

    Catatan versi: tutorial lama memakai result.error.flatten() dan opsi invalid_type_error. Di Zod 4, pesan error ditulis langsung sebagai string (atau { error: '...' }), dan helper flatten yang dianjurkan adalah z.flattenError().

    Setelah mutasi: revalidatePath, updateTag, redirect

    Data berubah, tapi halaman yang sudah di-cache masih menampilkan versi lama. Kamu perlu memberi tahu Next.js:

    Fungsi Kegunaan
    revalidatePath('/blog') Buang cache untuk satu path URL
    updateTag('posts') Langsung kedaluwarsakan data bertag posts; pengguna langsung melihat perubahannya. Hanya di Server Action
    revalidateTag('posts', 'max') Tandai basi, data baru diambil di latar belakang (stale-while-revalidate). Bisa di Route Handler
    redirect('/admin') Arahkan pengguna ke halaman lain

    redirect bekerja dengan melempar sinyal khusus, jadi kode setelahnya tidak dijalankan. Karena itu panggil revalidasi sebelum redirect, dan jangan taruh redirect di dalam try yang menangkap semua error.

    Keamanan: action adalah endpoint publik

    Ini bagian yang sering dilupakan. Di balik layar, Server Action dipanggil lewat request POST ke halaman tempat ia dipakai. Siapa pun yang bisa mengirim POST yang sama bisa memanggilnya, walaupun tombolnya hanya tampil untuk admin. Next.js sudah memberi perlindungan dasar (cek Origin vs Host untuk mencegah CSRF, batas body 1 MB, ID action terenkripsi), tapi cek login dan hak akses tetap tanggung jawabmu di dalam setiap action.

    Contoh kode

    Pertama, file action dengan validasi Zod. Ini versi ringkas dari proyek akhir.

    // app/actions/posts.js
    'use server'
    
    import { z } from 'zod'
    import { updateTag } from 'next/cache'
    import { redirect } from 'next/navigation'
    import { db } from '@/lib/db'
    import { posts } from '@/db/schema'
    import { verifyAdmin } from '@/lib/dal'
    import { slugify } from '@/lib/slugify'
    
    const PostSchema = z.object({
      title: z.string().trim().min(3, 'Judul minimal 3 karakter').max(120),
      excerpt: z.string().trim().min(10, 'Ringkasan minimal 10 karakter').max(300),
      content: z.string().trim().min(20, 'Isi minimal 20 karakter'),
      published: z.boolean(),
    })
    
    function readForm(formData) {
      return {
        title: formData.get('title') ?? '',
        excerpt: formData.get('excerpt') ?? '',
        content: formData.get('content') ?? '',
        published: formData.get('published') === 'on', // checkbox → boolean
      }
    }
    
    // Dipakai bersama useActionState → argumen pertama = prevState
    export async function createPost(prevState, formData) {
      await verifyAdmin() // WAJIB: action = endpoint publik
    
      const values = readForm(formData)
      const parsed = PostSchema.safeParse(values)
      if (!parsed.success) {
        // Kembalikan error + nilai yang diketik supaya form tidak kosong lagi
        return { errors: z.flattenError(parsed.error).fieldErrors, values }
        // → { errors: { title: ['Judul minimal 3 karakter'] }, values: {...} }
      }
    
      try {
        await db.insert(posts).values({
          ...parsed.data,
          slug: slugify(parsed.data.title),
          createdAt: new Date(),
        })
      } catch {
        return { errors: { title: ['Judul (slug) sudah dipakai artikel lain'] }, values }
      }
    
      updateTag('posts') // cache daftar artikel langsung kedaluwarsa
      redirect('/admin') // dilempar → kode setelah ini tidak jalan
    }
    

    Kedua, tombol submit yang bisa dipakai ulang.

    // components/SubmitButton.jsx
    'use client'
    
    import { useFormStatus } from 'react-dom'
    
    export function SubmitButton({ children, pendingText = 'Menyimpan...' }) {
      const { pending } = useFormStatus() // status milik <form> induk
      return (
        <button type="submit" disabled={pending}>
          {pending ? pendingText : children}
        </button>
      )
    }
    

    Ketiga, form Client Component yang memakai useActionState.

    // components/PostForm.jsx
    'use client'
    
    import { useActionState } from 'react'
    import { SubmitButton } from '@/components/SubmitButton'
    
    function FieldError({ errors }) {
      if (!errors?.length) return null
      return <p className="text-sm text-red-600">{errors[0]}</p>
    }
    
    export function PostForm({ action, initial }) {
      const [state, formAction] = useActionState(action, { errors: {}, values: initial })
      const v = state.values ?? {}
    
      return (
        // key memaksa React memasang ulang input saat nilai dari server berubah
        <form action={formAction} key={JSON.stringify(v)} className="flex flex-col gap-3">
          <label>
            Judul
            <input name="title" defaultValue={v.title} />
            <FieldError errors={state.errors?.title} />
          </label>
          <label>
            Ringkasan
            <input name="excerpt" defaultValue={v.excerpt} />
            <FieldError errors={state.errors?.excerpt} />
          </label>
          <label>
            Isi
            <textarea name="content" rows={10} defaultValue={v.content} />
            <FieldError errors={state.errors?.content} />
          </label>
          <label>
            <input type="checkbox" name="published" defaultChecked={v.published} /> Terbitkan
          </label>
          <SubmitButton>Simpan</SubmitButton>
        </form>
      )
    }
    

    Terakhir, halaman yang memakai form itu, untuk membuat baru dan untuk mengedit dengan bind.

    // app/admin/new/page.js  (Server Component)
    import { createPost } from '@/app/actions/posts'
    import { PostForm } from '@/components/PostForm'
    
    export default function NewPostPage() {
      return <PostForm action={createPost} initial={{ title: '', excerpt: '', content: '', published: false }} />
    }
    
    // app/admin/[id]/edit/page.js  (potongan)
    // const action = updatePost.bind(null, post.id)   → updatePost(id, prevState, formData)
    // return <PostForm action={action} initial={post} />
    

    Alur saat admin mengetik judul "Hi" lalu menekan Simpan: tombol berubah jadi "Menyimpan..." → action jalan di server → Zod gagal → action me-return error → state.errors.title berisi ['Judul minimal 3 karakter'] → pesan merah muncul, isi form tetap ada. Kalau valid: data tersimpan → cache dibuang → pengguna dipindah ke /admin dan melihat artikel barunya.

    Progressive enhancement

    Karena <form action={...}> adalah form HTML sungguhan, form ini tetap berfungsi walau JavaScript belum termuat, misalnya di koneksi 3G yang lambat. Browser mengirim POST biasa, server menjalankan action, lalu mengirim halaman hasil. Begitu JavaScript siap, React mengambil alih dan pengalaman berubah menjadi tanpa reload. Kamu mendapatkan ini gratis selama tidak menggantinya dengan onSubmit + fetch manual.

    Kesalahan umum

    1. Lupa cek autentikasi di dalam action. Menyembunyikan tombol di UI bukan pengamanan. Panggil verifyAdmin() (atau sejenisnya) di baris pertama setiap action yang mengubah data.
    2. Salah urutan argumen saat memakai useActionState. Action yang dipakai hook ini menerima (prevState, formData). Kalau kamu menulis (formData) saja, formData sebenarnya berisi state lama dan formData.get akan error.
    3. Memanggil useFormStatus di komponen yang merender <form>. Hook itu hanya membaca form induk. Pindahkan ke komponen anak seperti SubmitButton.
    4. Membungkus redirect() dengan try/catch lebar. redirect bekerja dengan melempar error khusus; kalau tertangkap catch, redirect batal. Taruh redirect di luar try.
    5. Hanya mengandalkan validasi HTML. required bisa dilewati. Validasi dengan Zod di server, lalu kembalikan pesan error per field.

    Latihan

    1. Mudah, Buat halaman app/kontak/page.js dengan form nama dan pesan. Action inline 'use server' cukup mencetak isinya dengan console.log di terminal server.
    2. Sedang, Ubah form kontak menjadi Client Component dengan useActionState. Validasi dengan Zod: nama minimal 2 karakter, pesan minimal 10 karakter. Tampilkan error per field dan pesan sukses "Terima kasih, !".
    3. Menantang, Tambahkan tombol kedua "Simpan sebagai draf" di PostForm yang memakai prop formAction pada <button>, sehingga satu form bisa memanggil dua action berbeda.
    Lihat solusi
    // 1. app/kontak/page.js
    export default function KontakPage() {
      async function kirim(formData) {
        'use server'
        console.log('Pesan masuk:', formData.get('nama'), formData.get('pesan'))
      }
      return (
        <form action={kirim}>
          <input name="nama" placeholder="Nama" />
          <textarea name="pesan" placeholder="Pesan" />
          <button>Kirim</button>
        </form>
      )
    }
    // Terminal server → Pesan masuk: Sari Halo, saya mau tanya...
    
    // 2a. app/kontak/actions.js
    'use server'
    import { z } from 'zod'
    
    const Schema = z.object({
      nama: z.string().trim().min(2, 'Nama minimal 2 karakter'),
      pesan: z.string().trim().min(10, 'Pesan minimal 10 karakter'),
    })
    
    export async function kirimPesan(prevState, formData) {
      const parsed = Schema.safeParse({ nama: formData.get('nama'), pesan: formData.get('pesan') })
      if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors }
      // simpan ke DB / kirim email di sini
      return { ok: `Terima kasih, ${parsed.data.nama}!` }
    }
    
    // 2b. app/kontak/KontakForm.jsx
    'use client'
    import { useActionState } from 'react'
    import { kirimPesan } from './actions'
    import { SubmitButton } from '@/components/SubmitButton'
    
    export function KontakForm() {
      const [state, formAction] = useActionState(kirimPesan, {})
      return (
        <form action={formAction}>
          <input name="nama" />
          {state.errors?.nama && <p>{state.errors.nama[0]}</p>}
          <textarea name="pesan" />
          {state.errors?.pesan && <p>{state.errors.pesan[0]}</p>}
          <SubmitButton pendingText="Mengirim...">Kirim</SubmitButton>
          {state.ok && <p aria-live="polite">{state.ok}</p>}
        </form>
      )
    }
    
    // 3. Di PostForm: dua tombol, dua action.
    // saveDraft adalah Server Action lain, misalnya createPost dengan published dipaksa false.
    // Catatan: formAction di <button> menerima action biasa (formData), bukan versi useActionState.
    <SubmitButton>Terbitkan</SubmitButton>
    <button type="submit" formAction={saveDraft}>Simpan sebagai draf</button>
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Next.js Forms Are Different Now (Server Actions, useActionState, Form Component, Form Backend)ByteGrad · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.