Server Actions Next.js di Produksi: Form, Validasi, Keamanan

Foto oleh Jakub Żerdzicki

Foto oleh Jakub Żerdzicki
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.
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.
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>
)
}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:
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.
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.
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. |
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.