karawaci.kode

← Semua snippet

TypeScript Pemula Validasi

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.ts terpisah, 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 fieldz.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 cek existence dengan optional.

# tags

zodnextjsserver-actionvalidationform

Ditulis oleh Asti Larasati · 9 Juni 2026