Zod + Next server action form validation
Validasi form di server action Next.js dengan zod. Type-safe, error message ramah, dan tidak butuh useState ratusan baris.
Dipublikasikan 9 Juni 2026
Form validation di Next.js dulu pake react-hook-form + yup + axios + 500 baris. Server action + zod kerjain hal yang sama dengan 50 baris dan type-safe sampai ke handler. Snippet ini pattern submit form pendaftaran ke koperasi simpan pinjam — validasi NIK, nomor HP Indonesia, KTP image.
Kode
// app/daftar/schema.ts — share antara client & server
import { z } from "zod";
export const daftarSchema = z.object({
nama: z
.string()
.trim()
.min(3, "Nama minimal 3 karakter")
.max(100, "Nama terlalu panjang"),
nik: z
.string()
.trim()
.regex(/^\d{16}$/, "NIK harus 16 digit angka"),
email: z.string().trim().email("Format email tidak valid"),
no_hp: z
.string()
.trim()
.regex(
/^(\+62|62|0)8[1-9]\d{6,10}$/,
"Nomor HP harus format Indonesia, contoh 081234567890"
),
tanggal_lahir: z
.string()
.refine(
(val) => {
const d = new Date(val);
const umur = new Date().getFullYear() - d.getFullYear();
return !isNaN(d.getTime()) && umur >= 17 && umur <= 100;
},
"Umur harus antara 17 dan 100 tahun"
),
setuju_syarat: z
.literal("on", { errorMap: () => ({ message: "Setujui syarat dulu" }) }),
});
export type DaftarInput = z.input<typeof daftarSchema>;
// app/daftar/actions.ts — Server Action
"use server";
import { daftarSchema } from "./schema";
export interface FormState {
fieldErrors?: Partial<Record<keyof DaftarInput, string>>;
formError?: string;
success?: boolean;
}
import type { DaftarInput } from "./schema";
export async function daftarAction(
_prev: FormState,
formData: FormData
): Promise<FormState> {
const raw = Object.fromEntries(formData.entries());
const result = daftarSchema.safeParse(raw);
if (!result.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path[0]?.toString();
if (key && !fieldErrors[key]) {
fieldErrors[key] = issue.message;
}
}
return { fieldErrors };
}
// Simulasi: cek NIK sudah pernah daftar
if (result.data.nik === "3201010101010001") {
return { formError: "NIK sudah terdaftar" };
}
// INSERT ke DB di sini
console.log("Pendaftaran sukses:", result.data);
return { success: true };
}
Pemakaian
// app/daftar/page.tsx — client component
"use client";
import { useActionState } from "react";
import { daftarAction } from "./actions";
export default function DaftarPage() {
const [state, action, pending] = useActionState(daftarAction, {});
if (state.success) {
return <p>Pendaftaran berhasil. Cek email kamu.</p>;
}
return (
<form action={action}>
<label>
Nama lengkap
<input name="nama" required />
{state.fieldErrors?.nama && <span>{state.fieldErrors.nama}</span>}
</label>
<label>
NIK
<input name="nik" maxLength={16} inputMode="numeric" required />
{state.fieldErrors?.nik && <span>{state.fieldErrors.nik}</span>}
</label>
<label>
Email
<input name="email" type="email" required />
{state.fieldErrors?.email && <span>{state.fieldErrors.email}</span>}
</label>
<label>
Nomor HP
<input name="no_hp" type="tel" required />
{state.fieldErrors?.no_hp && <span>{state.fieldErrors.no_hp}</span>}
</label>
<label>
Tanggal lahir
<input name="tanggal_lahir" type="date" required />
{state.fieldErrors?.tanggal_lahir && (
<span>{state.fieldErrors.tanggal_lahir}</span>
)}
</label>
<label>
<input name="setuju_syarat" type="checkbox" />
Saya setuju syarat & ketentuan
{state.fieldErrors?.setuju_syarat && (
<span>{state.fieldErrors.setuju_syarat}</span>
)}
</label>
{state.formError && <p role="alert">{state.formError}</p>}
<button type="submit" disabled={pending}>
{pending ? "Mendaftar..." : "Daftar"}
</button>
</form>
);
}
Kapan dipakai
- Form pendaftaran member, koperasi, e-wallet — banyak field dengan rule Indonesia.
- Checkout e-commerce — alamat, kurir, voucher.
- Admin panel CRUD — validasi konsisten antara create / update.
- Settings page — validasi partial update field by field.
Catatan
- safeParse, bukan parse — parse melempar exception. safeParse return object dengan flag success, lebih clean untuk pattern Result.
- Server-side wajib — client validation cuma UX. User bisa bypass dengan disable JS atau direct API call.
- Share schema — taruh di file
schema.tsterpisah, import dari server action & client form. Single source of truth. - Error map untuk i18n — pakai
z.setErrorMap()di root untuk pesan default berbahasa Indonesia. - Transform field —
z.coerce.number(),z.string().toLowerCase()lebih clean dari manual transform.
FormData value selalu string. Untuk checkbox yang unchecked, key-nya nggak ada sama sekali di FormData. Pakai
z.literal("on")atau cekexistencedengan optional.
# tags
zodnextjsserver-actionvalidationform
Ditulis oleh Asti Larasati · 9 Juni 2026