Server Actions adalah peningkatan kualitas hidup terbesar yang pernah dibawa App Router. Sebelumnya, setiap form di situs Next.js berarti ritual yang sama: API route, panggilan fetch, loading state manual, penanganan error manual, dan kontrak JSON yang harus Anda jaga sinkron secara manual. Sekarang form langsung melakukan POST ke sebuah async function dan Anda mendapat UI terbaru dalam satu roundtrip.
Tapi saya sudah me-review cukup banyak codebase Next.js — portfolio saya sendiri, situs klien untuk UKM Indonesia, dan tool internal di kantor — untuk tahu di mana tim sering terjebak. Pergeseran mental yang sering terlewat adalah ini: Server Action bukanlah pemanggilan fungsi. Ia adalah endpoint POST publik tanpa autentikasi yang kebetulan terlihat seperti pemanggilan fungsi. Siapa pun dengan curl bisa memanggilnya dengan argumen apa pun.
Artikel ini adalah playbook produksi yang saya harap ada saat Server Actions menjadi stabil: pipeline validasi form yang benar-benar tahan banting, apa yang sungguh Anda dapat dari progressive enhancement, dan empat jebakan keamanan yang muncul berulang kali di code review nyata.
Apa Sebenarnya Server Action Itu
Saat Anda menandai sebuah fungsi dengan directive use server, bundler menggantinya di sisi client dengan reference ID — hash yang diturunkan dari lokasi source — dan mengekspos handler POST di server yang meneruskan panggilan ke fungsi itu. Dokumentasi Next.js terang-terangan soal konsekuensinya: Server Functions dapat diakses lewat request POST langsung, bukan hanya lewat UI aplikasi Anda. Signature TypeScript yang Anda tulis hanyalah saran, bukan kontrak. Penyerang yang mengambil action ID dari HTML Anda bisa mengirim string di tempat Anda mengharapkan number, atau object di tempat Anda mengharapkan FormData.
Ada dua proteksi bawaan yang perlu Anda tahu. Pertama, action hanya merespons POST, dan Next.js membandingkan header Origin dengan header Host, yang menetralkan sebagian besar CSRF klasik tanpa token. Kedua, variabel yang di-closure oleh inline action dienkripsi dengan key per-build sebelum dikirim ke client. Sisanya — autentikasi, otorisasi, validasi input, rate limiting — adalah tugas Anda, di dalam body action, setiap saat.
Pipeline Form: FormData, Zod, useActionState
Inilah pola yang sekarang saya pakai untuk setiap form produksi. Action melakukan tiga hal dengan urutan ketat: otorisasi, validasi, mutasi. Validasi lewat Zod karena FormData yang masuk adalah input berbahaya, apa pun yang diberlakukan form di sisi client.
// app/actions/contact.ts
"use server"
import { z } from "zod"
import { auth } from "@/lib/auth"
import { revalidatePath } from "next/cache"
const ContactSchema = z.object({
name: z.string().min(2).max(120),
email: z.string().email(),
message: z.string().min(10).max(5000),
})
export type ContactState = {
ok: boolean
errors?: Record<string, string[]>
}
export async function submitContact(
_prev: ContactState,
formData: FormData
): Promise<ContactState> {
// 1. AuthZ first — every action, every time
const session = await auth()
if (!session?.user) return { ok: false, errors: { _form: ["Unauthorized"] } }
// 2. Validate the hostile input
const parsed = ContactSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors }
}
// 3. Mutate, then revalidate
await saveMessage(session.user.id, parsed.data)
revalidatePath("/inbox")
return { ok: true }
}Di sisi client, useActionState menghubungkan action ke form dan memberi Anda flag pending serta object state hasil return secara gratis. Tidak ada useState untuk error, tidak ada try-catch di sekitar fetch, tidak ada spinner loading yang dirakit manual. Region aria-live membuat screen reader ikut mengumumkan error validasi — bonus aksesibilitas yang hampir gratis.
// app/contact/form.tsx
"use client"
import { useActionState } from "react"
import { submitContact } from "@/app/actions/contact"
export function ContactForm() {
const [state, formAction, pending] = useActionState(submitContact, { ok: false })
return (
<form action={formAction}>
<input name="name" required minLength={2} />
<input name="email" type="email" required />
<textarea name="message" required minLength={10} />
<p aria-live="polite">{state.errors?.email?.[0]}</p>
<button disabled={pending}>{pending ? "Sending..." : "Send"}</button>
</form>
)
}Progressive Enhancement: Apa yang Benar-Benar Anda Dapat
Jargon marketingnya: form Server Action tetap bekerja tanpa JavaScript. Itu benar, dengan nuansa, dan nuansanya penting di jaringan tempat saya membangun aplikasi. Di ponsel Android kelas menengah dengan koneksi 3G di Jakarta, JavaScript hydration bisa butuh beberapa detik untuk tiba. Selama jendela waktu itu:
- Form yang dirender di Server Component dikirim sebagai POST full-page klasik meski JavaScript tidak pernah dimuat — pengguna tetap bisa menyelesaikan tugas utamanya.
- Form di Client Component mengantrekan submission jika pengguna submit sebelum hydration selesai, dan React memprioritaskan hydration form tersebut. Tidak ada yang hilang, tapi UI pending yang cantik baru muncul setelah hydration.
- Apa pun yang digerakkan useActionState, useOptimistic, atau handler onChange — error inline, optimistic update, penghitung karakter — diam-diam tidak berfungsi sampai bundle dieksekusi.
Jadi rancang jalur tanpa-JS dengan sengaja: atribut validasi HTML native seperti required, minLength, dan type email memberi garis pertahanan pertama yang bekerja sebelum hydration, dan schema Zod di server adalah penegakan yang sebenarnya di belakangnya. Progressive enhancement bukan checkbox gratis; ia adalah baseline yang Anda pilih untuk tetap dijaga.
Empat Jebakan Keamanan yang Terus Saya Temukan
Ini bukan teori. Masing-masing berasal dari pola yang pernah saya tandai di code review nyata proyek App Router.
Auth dicek di page, bukan di action
Page-nya dibungkus middleware atau pengecekan session, jadi developer berasumsi action-nya ikut terlindungi. Tidak. Action adalah endpoint tersendiri. Jika baris-baris pertama body action tidak memverifikasi session dan izin pengguna untuk mutasi spesifik ini, siapa pun bisa memanggilnya langsung.
Mempercayai signature TypeScript
Mendeklarasikan argumen sebagai id number tidak berbuat apa-apa saat runtime. Client mengendalikan setiap byte payload. Validasi tipe dan rentang dengan Zod atau pengecekan manual sebelum nilai menyentuh database — terutama ID, karena ID yang tidak dicek adalah kerentanan IDOR yang menunggu terjadi.
Data sensitif di argumen bind
Variabel closure di inline action dienkripsi, tapi argumen yang dikirim lewat method bind TIDAK — mereka pergi ke client dalam bentuk polos dan kembali sebagai input yang bisa dikendalikan penyerang. Jangan pernah bind secret atau harga, dan verifikasi ulang apa pun yang di-bind, seperti kepemilikan record, di dalam action.
Action mati masih terekspos
Setiap fungsi yang diekspor dari file use server menjadi endpoint, bahkan jika tidak ada komponen yang mengimpornya lagi. Eksperimen lama dan fitur yang dimatikan tetap memiliki route POST yang hidup. Audit file actions Anda seperti mengaudit permukaan API publik, karena memang itulah mereka.
Perlakukan setiap daftar argumen dari setiap Server Action sebagai input berbahaya, sama seperti Anda memperlakukan req.body di handler Express. Sintaks pemanggilan fungsi hanyalah gula ergonomis; model keamanannya tetap client-server.
Setelah Mutasi: Revalidate, Refresh, Redirect
Mutasi baru setengah pekerjaan — UI dan cache harus ikut menyusul. Next.js memberi tiga primitif, dan memilih yang tepat mencegah Anda menghantam cache dengan palu godam:
| API | Apa yang dilakukan | Kapan saya memakainya |
|---|---|---|
| revalidatePath | Membersihkan cache untuk satu path route dan me-render ulang pada request berikutnya. | Kasus sederhana: form di halaman yang sama atau halaman list yang menampilkan data yang dimutasi. |
| revalidateTag | Menginvalidasi setiap fetch yang diberi cache tag tersebut, di semua route yang memakainya. | Data bersama yang muncul di banyak tempat — data produk yang tampil di halaman listing, detail, dan dashboard sekaligus. |
| redirect | Melempar exception control-flow dan menavigasi pengguna; kode setelahnya tidak pernah berjalan. | Alur create-lalu-lihat. Panggil revalidatePath sebelum redirect, jangan pernah sesudahnya, karena sesudahnya adalah dead code. |
Checklist Produksi
- Setiap action dimulai dengan pemanggilan auth dan pengecekan otorisasi eksplisit untuk resource spesifik yang dimutasi — bukan sekadar apakah pengguna sudah login.
- Setiap action memvalidasi seluruh inputnya dengan schema Zod dan mengembalikan field error yang sudah diratakan untuk dirender useActionState.
- Rate limiting untuk action yang berat mutasi — saya pakai limiter kecil berbasis Redis dengan key user ID, karena action melewati middleware API route apa pun yang Anda tulis.
- Atribut validasi HTML native mencerminkan schema Zod sehingga submission pra-hydration gagal cepat untuk kesalahan yang jelas.
- Error yang dilempar di produksi disamarkan React menjadi hash — jadi kembalikan state error terstruktur untuk kegagalan yang terduga dan simpan throw untuk yang benar-benar luar biasa.
- File actions di-review seperti permukaan API publik: tidak ada export yang tak terpakai, tidak ada argumen object yang terlalu lebar, tidak ada secret di argumen bind.
Kesimpulan
Server Actions sungguh menghapus satu lapisan boilerplate penuh — di portfolio ini alur kontak kehilangan API route-nya, wrapper fetch-nya, dan sekitar delapan puluh baris pengelolaan state. Model satu-roundtrip di mana mutasi dan UI terbaru kembali bersamaan adalah arsitektur yang tepat untuk aplikasi yang penuh form.
Tapi kenyamanan itu justru yang membuatnya berbahaya. Disiplinnya membosankan dan tidak bisa ditawar: otorisasi dulu, validasi semuanya, revalidasi dengan presisi, dan audit file use server Anda sebagai endpoint publik. Lakukan itu, dan Server Actions adalah cerita mutasi terbaik yang pernah dimiliki React.
Grep codebase Anda untuk use server dan hitung fungsi yang diekspor. Angka itu adalah jumlah endpoint POST publik Anda. Kalau angkanya mengejutkan, Anda punya pekerjaan audit minggu ini.

