
Formulir adalah salah satu komponen yang paling sering kita temui di sebuah website. Entah itu form pendaftaran, login, komentar, atau sekadar form kontak sederhana — hampir semua aplikasi web butuh yang namanya form.
Tapi meskipun terlihat sepele, form bisa jadi momok yng bikin pusing kepala, apalagi kalau sudah bicara soal validasi data. Mulai dari "harus diisi", "format email salah", sampai "password minimal 8 karakter" — semua itu perlu dicek biar data yang masuk ke sistem bersih dan valid. Kalau tidak, bisa kacau urusannya: data berantakan, user bingung, dan kita sebagai developer yang kena getahnya 😅
Nah, di artikel ini, kita akan bahas cara membuat form sederhana di React menggunakan Next.js 15, versi terbaru yang sudah memakai App Router dan punya struktur file modern. Selain itu, kita juga akan pakai Zod untuk validasi — ini adalah library validasi schema-based yang simpel tapi powerfull banget. Dan supaya proses input jadi lebih smooth, kita juga akan gabungkan dengan React Hook Form, salah satu library form handling terbaik di dunia React.
Kenapa Zod? Karena validasi dengan Zod itu:
- Gampang ditulis
- Jelas error-nya
- Bisa dipakai bareng React Hook Form dengan integrasi super mudah
Jadi, kalau kamu:
- Masih baru di dunia React atau Next.js
- Sering bingung gimana cara validasi input form yang rapi
- Pengen bikin form yang sederhana tapi tetap profesional
...artikel ini cocok banget buat kamu.
Kita akan mulai dari nol — setup project, bikin UI form sederhana, pasang validasi dasar, sampai submit dan menampilkan data yang sudah diverifikasi. Tenang aja, semuanya akan dijelaskan pelan-pelan dan step by step.
Yuk, kita langsung mulai dari instalasi proyek Next.js-nya! 🚀
Persiapan Proyek
Sebelum kita mulai ngoding form dan validasinya, tentu kita harus siapin dulu proyek Next.js-nya. Di sini kita pakai Next.js 15 — versi terbaru yang sudah full power dengan App Router, Server Actions, dan banyak fitur keren lainnya. Kita juga bakal pakai TypeScript biar kodenya lebih aman dan terstruktur.
Inisialisasi Proyek Next.js 15
Pertama, buka terminal (atau Command Prompt kalau kamu pakai Windows), lalu jalankan perintah berikut:
bunx create-next-app@latest bwa-form
Nanti kamu akan ditanya beberapa hal. Jawaban yang disarankan:
✔ Would you like to use TypeScript? › Yes
✔ Would you like to use ESLint? › Yes
✔ Would you like to use Tailwind CSS? › Yes (Opsional, tapi bikin form lebih cakep)
✔ Would you like to use `src/` directory? › Yes
✔ Would you like to use App Router? › Yes
✔ Would you like to use Turbopack for `next dev`? › Yes
✔ Would you like to customize the default import alias (@/*)? › No
Setelah selesai, masuk ke folder proyek:
cd bwa-form
Lalu jalankan server:
bun run dev
Kalau semua berjalan lancar, kamu bisa buka browser dan akses http://localhost:3000
untuk melihat project Next.js kamu tampil dengan halaman default.

Install Zod + React Hook Form
Sekarang kita install library yang dibutuhkan untuk validasi form dan pengelolaan input:
bun add zod react-hook-form @hookform/resolvers
Penjelasan singkat:
- zod: untuk bikin schema validasi yang readable.
- react-hook-form: buat ngatur input form, handle submit, dan baca error dengan efisien.
- @hookform/resolvers: ini semacam "jembatan" antara Zod dan React Hook Form, jadi validasi Zod bisa langsung dipakai di form React.
Membuat Form Sederhana
Sekarang kita akan membuat sebuah form sederhana yang terdiri dari dua input:
- Nama
- Email dan satu tombol submit.
Belum ada validasi dulu ya, kita fokus ke tampilan dan logika submit dasar dulu. Validasi Zod-nya akan kita bahas di bagian berikutnya.
Buat Halaman Register
Buat file page.tsx
pada direktori src/app/register/page.tsx
seperti pada gambar berikut:

Ubah kode jadi seperti berikut ini:
import RegisterForm from "@/components/register-form";
function RegisterPage() {
return (
<main className="flex items-center justify-center min-h-screen">
<div className="flex flex-col gap-4 w-1/4">
<h1 className="text-2xl font-bold">Register</h1>
<RegisterForm />
</div>
</main>
);
}
export default RegisterPage;
Kemdian buat file register-form.tsx
padaa direktori src/components
. Kalo direktorinya nggak ada, tinggal buat aja.

dan tambahkan kode berikut:
"use client";
import { useState } from "react";
const RegisterForm = () => {
const [result, setResult] = useState("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get("name");
const email = formData.get("email");
setResult(`Nama: ${name}, Email: ${email}`);
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Nama
</label>
<input
id="name"
name="name"
type="text"
className="w-full border border-gray-300 p-2 rounded"
/>
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
className="w-full border border-gray-300 p-2 rounded"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Kirim
</button>
</form>
{result && (
<div className="mt-4 bg-green-100 text-green-800 p-3 rounded">
{result}
</div>
)}
</>
);
};
export default RegisterForm;
Penjelasan Singkat
- Kita pakai
useState
buat nampung hasil submit. handleSubmit
akan dijalankan saat form disubmit, dan kita ambil nilai input dariFormData
.- Setelah itu, kita tampilkan hasilnya di bawah form.
- TailwindCSS dipakai buat styling dasar biar form-nya enak dilihat.
Kalau kamu buka http://localhost:3000/register
, sekarang kamu udah bisa isi form dan lihat hasilnya muncul setelah submit. Tapi ingat, belum ada validasi apa-apa. Jadi kalau kamu isi kosong atu email ngaco, tetap aja dianggap valid. Nah, di bagian berikutnya, kita akan atasi itu dengan Zod + React Hook Form.

Menambahkan Validasi dengan Zod
Form udah tampil, bisa diisi, bisa disubmit — tapi belum ada validasi sama sekali. Ini kayak pintu yang dibuka lebar tanpa filter. Sekarang kita akan integrasikan Zod untuk validasi input, dan sambungkan dengan React Hook Form biar prosesnya efisien dan nyaman.
Setup Schema dengan Zod
Pertama-tama, kita bikin schema validasi menggunakan Zod. Misalnya kita pengen:
- Nama wajib diisi dan minimal 2 karakter
- Email wajib diisi dan harus format email yang benar
Tambahkan ini di atas fungsi RegisterForm()
:
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
// 1. Buat skema validasi dengan Zod
const schema = z.object({
name: z.string().min(2, { message: "Nama minimal 2 karakter" }),
email: z.string().email({ message: "Format email tidak valid" }),
})
// 2. Tipe otomatis dari Zod
type FormData = z.infer<typeof schema>
Sehingga menjadi seperti berikut ini:
"use client";
import { useState } from "react";
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
// 1. Buat skema validasi dengan Zod
const schema = z.object({
name: z.string().min(2, { message: "Nama minimal 2 karakter" }),
email: z.string().email({ message: "Format email tidak valid" }),
})
// 2. Tipe otomatis dari Zod
type FormData = z.infer<typeof schema>
const RegisterForm = () => {
const [result, setResult] = useState("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get("name");
const email = formData.get("email");
setResult(`Nama: ${name}, Email: ${email}`);
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Nama
</label>
<input
id="name"
name="name"
type="text"
className="w-full border border-gray-300 p-2 rounded"
/>
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
className="w-full border border-gray-300 p-2 rounded"
/>
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Kirim
</button>
</form>
{result && (
<div className="mt-4 bg-green-100 text-green-800 p-3 rounded">
{result}
</div>
)}
</>
);
};
export default RegisterForm;
Dengan begini, kita udah punya aturan validasi dan juga tipe datanya.
Pakai React Hook Form + Zod
Selanjutnya kita ubah RegisterForm()
agar pakai React Hook Form untuk handle form-nya:
Ganti useState
dan handleSubmit
lama dengan ini:
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = (data: FormData) => {
setResult(`Nama: ${data.name}, Email: ${data.email}`)
}
Kemudian ubah bagian <form>
jadi seperti ini:
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Nama
</label>
<input
id="name"
{...register("name")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.name && (
<p className="text-red-600 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.email && (
<p className="text-red-600 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Kirim
</button>
</form>
Sehingga kode lengkapnya akan sepreti ini:
"use client";
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// 1. Buat skema validasi dengan Zod
const schema = z.object({
name: z.string().min(2, { message: "Nama minimal 2 karakter" }),
email: z.string().email({ message: "Format email tidak valid" }),
});
// 2. Tipe otomatis dari Zod
type FormData = z.infer<typeof schema>;
const RegisterForm = () => {
const [result, setResult] = useState("");
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
setResult(`Nama: ${data.name}, Email: ${data.email}`);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Nama
</label>
<input
id="name"
{...register("name")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.name && (
<p className="text-red-600 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.email && (
<p className="text-red-600 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Kirim
</button>
</form>
{result && (
<div className="mt-4 bg-green-100 text-green-800 p-3 rounded">
{result}
</div>
)}
</>
);
};
export default RegisterForm;
Apa yang Berubah?
- Kita pakai
register()
dari React Hook Form untuk menghubungkan input dengan state form. - Semua validasi ditangani otomatis oleh Zod +
zodResolver
. - Error ditampilkan tepat di bawah input dengan
errors.nama?.message
danerrors.email?.message
.
Sekarang kalau kamu isi form dengan kosong atau email ngawur, akan muncul pesan error. Dan kalau datanya valid, hasilnya akan tampil seperti sebelumnya.
Contoh validasi gagal:

Meningkatkan UX: Reset Form dan Loading State
Setelah validasi jalan, kita bisa kasih sentuhan UX tambahan: form bisa di-reset setelah berhasil dikirim, dan ada indikator loading pas proses submit berlangsung.
Reset Form Setelah Submit
React Hook Form sudah menyediakan fungsi reset()
yang bisa langsung kita pakai buat membersihkan semua input.
Update onSubmit
seperti ini:
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = (data: FormData) => {
setResult(`Nama: ${data.name}, Email: ${data.email}`)
reset() // ← bersihkan form setelah submit
}
Mudah banget kan? Cukup panggil reset()
setelah submit sukses, dan semua input akan kembali kosong.

Menambahkan Loading State
Sekarang kita tambahkan indikator loading biar user tahu kalau form sedang diproses. Kita pakai state isSubmitting
dari formState
milik React Hook Form:
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
})
Lalu ubah tombol submit jadi seperti ini:
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
disabled={isSubmitting}
>
{isSubmitting ? "Mengirim..." : "Kirim"}
</button>
Dengan begini:
- Tombol akan menampilkan "Mengirim..." saat proses berlangsung
- Tombol otomatis nonaktif supaya user nggak bisa klik berkali-kali
Untuk testing kitaa perlu mengubah kode ketiak onSubmit
menjadi seperti berikut:
const onSubmit = async (data: FormData) => {
setResult("") // clear result sebelumnya
await new Promise((resolve) => setTimeout(resolve, 2000)) // ← delay 2 detik
setResult(`Nama: ${data.name}, Email: ${data.email}`)
reset()
}
Maka hasilnya akan seperti ini:

Kenapa Ini Penting?
Karena di dunia nyata, proses submit biasanya melibatkan request ke server (misalnya kirim ke Supabase, API Route, atau Server Action). Nah, selama proses itu, user perlu feedback visual bahwa aplikasi sedang "bekerja".
Validasi Username: Biar Gak Ada "admin" Palsu 😎
Username adalah salah satu input penting yang sering dipakai buat identitas pengguna. Karena itu, kita perlu pastikan nilai yang dimasukkan:
- Nggak terlalu pendek,
- Nggak pakai karakter aneh-aneh,
- Dan yang paling penting: nggak nyamar jadi akun khusus seperti
admin
,root
, atausuperuser
.
Di bagian ini, kita tambahkan validasi lengkap untuk username
menggunakan zod
. Validasi ini mencakup:
- Panjang minimal 4 karakter Supaya nggak ada username kayak
a
,me
, atauxy
yang terlalu singkat dan susah dikenali. - Hanya boleh huruf, angka, dash (-), dan underscore (_) Untuk menjaga format username tetap konsisten dan aman, tanpa karakter spesial seperti
@
,#
, atau spasi. - Larangan pakai kata-kata berbahaya seperti
admin
,root
, dsb. Ini penting untuk menghindari penyalahgunaan identitas. Kita pakairefine()
untuk mendeteksi apakah ada kata-kata yang seharusnya tidk boleh ada dalam username.
Dengan kombinasi tiga aturan ini, kamu bisa menjaga kualitas dan keamanan input username
sejak dari sisi klien — sebelum data dikirim ke server.
Pertama, kita tentukan username yang dilarang dipakai, biasanya untuk alasan keamanan (menghindari user yang pura-pura jadi admin, root, dll). Buka file register-form.tsx
lalu tambahkan kode berikut di atas schema:
// list username yang dilarang
const disallowedUsernamePatterns = ["admin", "superuser", "superadmin", "root"];
Lalu kita buat skema validasi untuk field username
:
username: z
.string()
.min(4, { message: "Username minimal 4 karakter" })
.regex(/^[a-zA-Z0-0_-]+$/, "Gunakan hanya huruf, angka, - dan _")
.refine(
(username) => {
for (const pattern of disallowedUsernamePatterns) {
if (username.toLowerCase().includes(pattern)) {
return false;
}
}
return true;
},
{ message: "Username terdapat kata yang dilarang" }
),
Penjelasan Kenapa Ini Penting
min(4)
: mencegah username yang terlalu pendek sepertia
,ab
, atauxyz
.regex
: memastikan karakter-karakter aneh (spasi, simbol, dll) tidak bisa masuk.refine
: custom logic yang nggak bisa ditangani oleh validator bawaan Zod. Cocok untuk validasi yang butuhloop
atau pengecekansubstring
seperti ini.
Pada bagian onSubmit
juga ubah jadi sepertii ini:
const onSubmit = async (data: FormData) => {
setResult(""); // clear result sebelumnya
await new Promise((resolve) => setTimeout(resolve, 2000)); // ← delay 2 detik
setResult(
`Nama: ${data.name}, Email: ${data.email}, Username: ${data.username}`
);
reset(); // ← bersihkan form setelah submit
};
Terakhir tambahkn input form untuk username, seperti ini:
<div>
<label htmlFor="username" className="block font-medium">
Username
</label>
<input
id="username"
type="text"
{...register("username")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.username && (
<p className="text-red-600 text-sm mt-1">
{errors.username.message}
</p>
)}
</div>
Maka kode lengkapnya akn menjadi seperti ini:
"use client";
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// list username yang dilarang
const disallowedUsernamePatterns = ["admin", "superuser", "superadmin", "root"];
const schema = z.object({
name: z.string().min(2, { message: "Nama minimal 2 karakter" }),
email: z.string().email({ message: "Format email tidak valid" }),
username: z
.string()
.min(4, { message: "Username minimal 4 karakter" })
.regex(/^[a-zA-Z0-0_-]+$/, "Gunakan hanya huruf, angka, - dan _")
.refine(
(username) => {
for (const pattern of disallowedUsernamePatterns) {
if (username.toLowerCase().includes(pattern)) {
return false;
}
}
return true;
},
{ message: "Username terdapat kata yang dilarang" }
),
});
type FormData = z.infer<typeof schema>;
const RegisterForm = () => {
const [result, setResult] = useState("");
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
setResult(""); // clear result sebelumnya
await new Promise((resolve) => setTimeout(resolve, 2000)); // ← delay 2 detik
setResult(
`Nama: ${data.name}, Email: ${data.email}, Username: ${data.username}`
);
reset(); // ← bersihkan form setelah submit
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Nama
</label>
<input
id="name"
{...register("name")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.name && (
<p className="text-red-600 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.email && (
<p className="text-red-600 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="username" className="block font-medium">
Username
</label>
<input
id="username"
type="text"
{...register("username")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.username && (
<p className="text-red-600 text-sm mt-1">
{errors.username.message}
</p>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
disabled={isSubmitting}
>
{isSubmitting ? "Mengirim..." : "Kirim"}
</button>
</form>
{result && (
<div className="mt-4 bg-green-100 text-green-800 p-3 rounded">
{result}
</div>
)}
</>
);
};
export default RegisterForm;
Dan hasilnya akan seperti ini:

Validasi Password: Minimal Aman, Gak Asal Asal
Saat membuat form pendaftaran, bagian password
itu penting banget. Gak cuma asal diisi, tapi juga perlu divalidasi supaya pengguna:
- Masukkan password yang cukup kuat (minimal panjangnya),
- Ada huruf besar (A–Z)
- Ada angka (0–9)
- Ada simbol (!@#$%^&* dsb.)
- Dan memastikan mereka mengetik ulang password dengan benar (konfirmasi password).
Dengan begini, kita sudah membuat form-nya lebih aman dan lebih user-friendly. Nggak lucu kan, udah bikin akun, eh password-nya salah sendiri 😅
Tambahkan kode berikut pada zod schema:
password: z
.string()
.min(8, {
message: "Password minimal 8 karakter",
})
.regex(/[A-Z]/, "Password harus mengandung huruf besar (A-Z)")
.regex(/[0-9]/, "Password harus mengandung angka (0-9)")
.regex(/[^a-zA-Z0-9]/, "Password harus mengandung simbol (!@#$, dll)"),
confirmPassword: z.string().min(8, {
message: "Password minimal 8 karakter",
}),
.string()
→ Input harus berupa string (teks)..min(8, ...)
→ Wajib minimal 8 karakter untuk mencegah password yang terlalu pendek dan mudah ditebak..regex(/[A-Z]/, ...)
→ Harus ada setidaknya satu huruf besar (misalnyaA
,B
,Z
). Ini bikin password lebih bervariasi..regex(/[0-9]/, ...)
→ Harus mengandung angka. Misalnya1
,2
, atau9
..regex(/[^a-zA-Z0-9]/, ...)
→ Harus mengandung karakter khusus/simbol, seperti@
,!
,#
,&
, dll.
Dan juga tambahkan ini untuk validasi kecocokan password:
.refine((data) => data.password === data.confirmPassword, {
message: "Password tidak cocok",
path: ["confirmPassword"],
})
Baris ini penting banget karena:
.refine()
digunakan untuk validasi lintas field (cross-field validation).(data) => data.password === data.confirmPassword
→ memeriksa apakah password dan konfirmasinya sama.- Kalau nggak sama, maka akan muncul error
"Password tidak cocok"
khusus di bagianconfirmPassword
.
Sehingga kode schema akan menjadi seperti ini:
const schema = z
.object({
name: z.string().min(2, { message: "Nama minimal 2 karakter" }),
email: z.string().email({ message: "Format email tidak valid" }),
username: z
.string()
.min(4, { message: "Username minimal 4 karakter" })
.regex(/^[a-zA-Z0-0_-]+$/, "Gunakan hanya huruf, angka, - dan _")
.refine(
(username) => {
for (const pattern of disallowedUsernamePatterns) {
if (username.toLowerCase().includes(pattern)) {
return false;
}
}
return true;
},
{ message: "Username terdapat kata yang dilarang" }
),
password: z
.string()
.min(8, {
message: "Password minimal 8 karakter",
})
.regex(/[A-Z]/, "Password harus mengandung huruf besar (A-Z)")
.regex(/[0-9]/, "Password harus mengandung angka (0-9)")
.regex(/[^a-zA-Z0-9]/, "Password harus mengandung simbol (!@#$, dll)"),
confirmPassword: z.string().min(8, {
message: "Password minimal 8 karakter",
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Password tidak cocok",
path: ["confirmPassword"],
});
Tujuannya Apa?
Validasi seperti ini mendorong pengguna membuat password yang:
- Tidak mudah ditebak,
- Sulit diretas lewat brute force,
- Dan lebih sesuai standar keamanan modern (mirip sistem perbankan atau layanan email besar).
Tambahkan kode berikut ini utnuk input password:
<div>
<label htmlFor="password" className="block font-medium">
Password
</label>
<input
id="password"
type="password"
{...register("password")}
className="w-full border border-gray-300 p-2 rounded"
/>
<div className="mt-1 h-2 w-full bg-gray-200 rounded">
<div
className="h-full rounded transition-all"
style={{
width: `${(passwordStrength / 4) * 100}%`,
backgroundColor:
passwordStrength < 2
? "red"
: passwordStrength === 2 || passwordStrength === 3
? "orange"
: "green",
}}
/>
</div>
<p className="text-sm mt-1">
Kekuatan:{" "}
{passwordStrength < 2
? "Lemah"
: passwordStrength === 2 || passwordStrength === 3
? "Sedang"
: "Kuat"}
</p>
{errors.password && (
<p className="text-red-600 text-sm mt-1">
{errors.password.message}
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block font-medium">
Konfirmasi Password
</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.confirmPassword && (
<p className="text-red-600 text-sm mt-1">
{errors.confirmPassword.message}
</p>
)}
</div>
Sehingga kode lengkapnya akan menjadi seperti berikut ini:
"use client";
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// list username yang dilarang
const disallowedUsernamePatterns = ["admin", "superuser", "superadmin", "root"];
const schema = z
.object({
name: z.string().min(2, { message: "Nama minimal 2 karakter" }),
email: z.string().email({ message: "Format email tidak valid" }),
username: z
.string()
.min(4, { message: "Username minimal 4 karakter" })
.regex(/^[a-zA-Z0-0_-]+$/, "Gunakan hanya huruf, angka, - dan _")
.refine(
(username) => {
for (const pattern of disallowedUsernamePatterns) {
if (username.toLowerCase().includes(pattern)) {
return false;
}
}
return true;
},
{ message: "Username terdapat kata yang dilarang" }
),
password: z
.string()
.min(8, {
message: "Password minimal 8 karakter",
})
.regex(/[A-Z]/, "Password harus mengandung huruf besar (A-Z)")
.regex(/[0-9]/, "Password harus mengandung angka (0-9)")
.regex(/[^a-zA-Z0-9]/, "Password harus mengandung simbol (!@#$, dll)"),
confirmPassword: z.string().min(8, {
message: "Password minimal 8 karakter",
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Password tidak cocok",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof schema>;
const RegisterForm = () => {
const [result, setResult] = useState("");
const calculatePasswordStrength = (password: string) => {
let strength = 0;
if (password.length >= 8) strength += 1;
if (/[A-Z]/.test(password)) strength += 1;
if (/[0-9]/.test(password)) strength += 1;
if (/[^a-zA-Z0-9]/.test(password)) strength += 1;
return strength;
};
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const passwordValue = watch("password");
const passwordStrength = calculatePasswordStrength(passwordValue || "");
const onSubmit = async (data: FormData) => {
setResult(""); // clear result sebelumnya
await new Promise((resolve) => setTimeout(resolve, 2000)); // ← delay 2 detik
setResult(
`Nama: ${data.name}, Email: ${data.email}, Username: ${data.username}`
);
reset(); // ← bersihkan form setelah submit
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block font-medium">
Nama
</label>
<input
id="name"
{...register("name")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.name && (
<p className="text-red-600 text-sm mt-1">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block font-medium">
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.email && (
<p className="text-red-600 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="username" className="block font-medium">
Username
</label>
<input
id="username"
type="text"
{...register("username")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.username && (
<p className="text-red-600 text-sm mt-1">
{errors.username.message}
</p>
)}
</div>
<div>
<label htmlFor="password" className="block font-medium">
Password
</label>
<input
id="password"
type="password"
{...register("password")}
className="w-full border border-gray-300 p-2 rounded"
/>
<div className="mt-1 h-2 w-full bg-gray-200 rounded">
<div
className="h-full rounded transition-all"
style={{
width: `${(passwordStrength / 4) * 100}%`,
backgroundColor:
passwordStrength < 2
? "red"
: passwordStrength === 2 || passwordStrength === 3
? "orange"
: "green",
}}
/>
</div>
<p className="text-sm mt-1">
Kekuatan:{" "}
{passwordStrength < 2
? "Lemah"
: passwordStrength === 2 || passwordStrength === 3
? "Sedang"
: "Kuat"}
</p>
{errors.password && (
<p className="text-red-600 text-sm mt-1">
{errors.password.message}
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block font-medium">
Konfirmasi Password
</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
className="w-full border border-gray-300 p-2 rounded"
/>
{errors.confirmPassword && (
<p className="text-red-600 text-sm mt-1">
{errors.confirmPassword.message}
</p>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
disabled={isSubmitting}
>
{isSubmitting ? "Mengirim..." : "Kirim"}
</button>
</form>
{result && (
<div className="mt-4 bg-green-100 text-green-800 p-3 rounded">
{result}
</div>
)}
</>
);
};
export default RegisterForm;
Dan hasilnya akan menjadi seperti berikut ini:

Penutup
Wah, akhirnya sampai juga ya di ujung artikel ini. Kita udah bareng-bareng bikin form pendaftaran sederhana pakai React dan Next.js 15, terus kita kasih bumbu validasi dari Zod, dan ditambah sentuhan UX kayak loading state, reset, sampai indikator kekuatan password. Lumayan lengkap, kan?
Kalau kita ibaratkan form ini kayak pintu masuk ke rumah aplikasi kita, maka validasi itu adalah sistem keamanan biar yang masuk beneran "orang baik-baik". Kita nggak cuma asal tampung data, tapi juga jaga kualitas dan konsistensi sejak dari form paling depan.
Menariknya, meskipun form ini terlihat sederhana, banyak banget pelajaran penting yang bisa kita ambil — mulai dari logika validasi dasar, pengelolaan state di React, sampai bagaimana bikin pengalaman pengguna jadi lebih nyaman dan jelas.
🚀 Eksplorasi Lanjutan? Yuk Gas!
Kalau kamu udah sampai sini, berarti kamu udah punya pondasi yang solid buat ngembangin form yang lebih kompleks. Beberapa ide buat eksplorasi selanjutnya:
- 🔢 Multi-step form: cocok buat form panjang biar nggak bikin user kabur
- ⏳ Async validation: misalnya ngecek email/username udah dipakai atau belum
- 📱 Optimasi mobile UX: fokus ke input UX dan auto-capitalize
- 🧠 Integrasi ke backend (misalnya Supabase atau tRPC)
Terima kasih udah ngikutin sampai akhir 🎉
Semoga artikel ini bisa bantu kamu bikin form yang lebih baik, lebih aman, dan lebih nyaman buat user kamu. Kalau ada pertanyaan, ide, atau mau share hasil implementasi, jangan sungkan buat ngobrol bareng. Sampai jumpa di tutorial selanjutnya ya! 👋