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:
- Di file terpisah (cara yang dianjurkan): tulis
'use server'di baris paling atas file, misalnyaapp/actions/posts.js. Semua function yang di-export dari file itu menjadi Server Action dan bisa di-import oleh Server Component maupun Client Component. - 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)
state: nilai terakhir yang di-returnaction (misalnya objek error).formAction: versi action yang dibungkus, inilah yang kamu pasang di<form action={formAction}>.pending:trueselama action berjalan.
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:
z.object({ title: z.string().trim().min(3, 'Judul minimal 3 karakter') })mendefinisikan bentuk data.Schema.safeParse(data)mengembalikan{ success: true, data }atau{ success: false, error }tanpa melempar error.z.flattenError(result.error).fieldErrorsmengubah error menjadi objek{ title: ['Judul minimal 3 karakter'] }yang mudah ditampilkan per field.
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
- Lupa cek autentikasi di dalam action. Menyembunyikan tombol di UI bukan pengamanan. Panggil
verifyAdmin()(atau sejenisnya) di baris pertama setiap action yang mengubah data. - Salah urutan argumen saat memakai
useActionState. Action yang dipakai hook ini menerima(prevState, formData). Kalau kamu menulis(formData)saja,formDatasebenarnya berisi state lama danformData.getakan error. - Memanggil
useFormStatusdi komponen yang merender<form>. Hook itu hanya membaca form induk. Pindahkan ke komponen anak sepertiSubmitButton. - Membungkus
redirect()dengantry/catchlebar.redirectbekerja dengan melempar error khusus; kalau tertangkapcatch, redirect batal. Taruhredirectdi luartry. - Hanya mengandalkan validasi HTML.
requiredbisa dilewati. Validasi dengan Zod di server, lalu kembalikan pesan error per field.
Latihan
- Mudah, Buat halaman
app/kontak/page.jsdengan form nama dan pesan. Action inline'use server'cukup mencetak isinya denganconsole.logdi terminal server. - 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,!". - Menantang, Tambahkan tombol kedua "Simpan sebagai draf" di
PostFormyang memakai propformActionpada<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
'use server'menandai function yang berjalan di server; kodenya tidak pernah dikirim ke browser.<form action={fn}>mengirimFormDatake action dan tetap bekerja tanpa JavaScript (progressive enhancement).useActionStatememberistate,formAction, danpending; action-nya menerima(prevState, formData).useFormStatusdipakai di komponen anak form untuk tombol loading.- Validasi di server dengan Zod 4 (
safeParse+z.flattenError), laluupdateTag/revalidatePathsebelumredirect. - Setiap action adalah endpoint publik: selalu cek autentikasi di dalamnya.