Validasi form adalah bagian penting dalam aplikasi web modern. Tanpa validasi yang baik, data yang masuk ke sistem bisa tidak konsisten, tidak aman, dan menyulitkan proses di backend. Contoh sederhana: email dengan format salah, password terlalu lemah, atau field wajib yang kosong dapat menyebabkan error lanjutan, pengalaman pengguna yang buruk, bahkan celah keamanan.

Di ekosistem Next.js, ada beberapa pendekatan populer untuk validasi form. Anda bisa menggunakan validasi bawaan browser, validasi manual dengan state React, library seperti Formik, atau pendekatan yang lebih ringan dan efisien dengan React Hook Form. Untuk validasi skema, Zod menjadi pilihan populer karena API-nya sederhana, type-safe, dan cocok dipadukan dengan TypeScript.

Pada artikel ini, kita akan fokus pada implementasi form validation di Next.js 16 dengan App Router menggunakan React Hook Form dan Zod. Kita akan membuat form pendaftaran pengguna, menampilkan pesan error per field, dan membahas praktik terbaik agar validasi tetap aman dan konsisten di lingkungan production.

Mengapa Validasi Form Penting

Validasi form bukan sekadar memeriksa apakah input kosong atau tidak. Ada beberapa tujuan utama:

  • Menjaga integritas data: data yang tersimpan di database harus sesuai format dan aturan bisnis.
  • Meningkatkan pengalaman pengguna: error ditampilkan lebih cepat sebelum request dikirim ke server.
  • Mengurangi beban backend: request invalid bisa ditolak lebih awal di sisi client.
  • Meningkatkan keamanan: validasi membantu menyaring input berbahaya, walau sanitasi dan validasi server tetap wajib.

Catatan penting: validasi di client berguna untuk UX, tetapi bukan lapisan keamanan utama. Semua validasi penting tetap harus diulang di server.

Library Populer untuk Form Validation di Next.js

Beberapa opsi yang sering dipakai di proyek Next.js antara lain:

  • HTML5 Validation: cepat dan tanpa dependensi tambahan, tetapi terbatas untuk kebutuhan kompleks.
  • Formik: cukup populer, namun cenderung lebih berat untuk beberapa kasus.
  • React Hook Form: performa baik karena meminimalkan re-render, API praktis, dan cocok untuk form kompleks.
  • Zod: bukan library form, melainkan schema validator yang sangat cocok untuk memvalidasi data secara konsisten di client dan server.
  • Yup: alternatif schema validation yang juga sering digunakan.

Untuk Next.js modern, kombinasi React Hook Form + Zod sangat umum dipilih karena:

  • integrasi baik dengan TypeScript,
  • validasi deklaratif berbasis schema,
  • kode lebih mudah dirawat,
  • schema dapat dipakai ulang di server.

Persiapan Proyek Next.js 16

Misalkan Anda sudah memiliki proyek Next.js 16 dengan App Router. Jika belum, buat proyek baru lalu pasang dependensi yang diperlukan.

Instalasi dependensi

npx create-next-app@latest next-form-validation
cd next-form-validation
npm install react-hook-form zod @hookform/resolvers

Dependensi yang dipakai:

  • react-hook-form untuk manajemen form.
  • zod untuk schema validation.
  • @hookform/resolvers untuk menghubungkan Zod ke React Hook Form.

Struktur folder sederhana

Untuk contoh artikel ini, struktur folder cukup sederhana:

app/
├─ register/
│  └─ page.tsx
components/
├─ register-form.tsx
lib/
├─ validations/
│  └─ register-schema.ts

Pemisahan ini membantu menjaga kode tetap rapi:

  • app/register/page.tsx sebagai halaman App Router.
  • components/register-form.tsx untuk komponen client-side form.
  • lib/validations/register-schema.ts untuk schema Zod yang bisa digunakan ulang.

Membuat Schema Validation dengan Zod

Kita mulai dari schema karena schema adalah sumber aturan validasi utama. Misalnya, form pendaftaran akan memiliki field: name, email, password, dan confirmPassword.

// lib/validations/register-schema.ts
import { z } from 'zod';

export const registerSchema = z
  .object({
    name: z
      .string()
      .min(3, 'Nama minimal 3 karakter')
      .max(50, 'Nama maksimal 50 karakter'),
    email: z
      .string()
      .email('Format email tidak valid'),
    password: z
      .string()
      .min(8, 'Password minimal 8 karakter')
      .regex(/[A-Z]/, 'Password harus mengandung huruf besar')
      .regex(/[a-z]/, 'Password harus mengandung huruf kecil')
      .regex(/[0-9]/, 'Password harus mengandung angka'),
    confirmPassword: z.string()
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Konfirmasi password tidak sama',
    path: ['confirmPassword']
  });

export type RegisterFormData = z.infer<typeof registerSchema>;

Mengapa pendekatan ini baik?

  • Aturan validasi terpusat dalam satu tempat.
  • TypeScript bisa menurunkan tipe langsung dari schema lewat z.infer.
  • Validasi relasi antar-field seperti password dan confirmPassword bisa dilakukan dengan refine.

Membuat Halaman Register dengan App Router

Karena kita memakai App Router, halaman berada di direktori app. File halaman bisa tetap sederhana dan merender komponen form.

// app/register/page.tsx
import RegisterForm from '@/components/register-form';

export default function RegisterPage() {
  return (
    <main style={{ maxWidth: 480, margin: '40px auto', padding: '0 16px' }}>
      <h1>Pendaftaran Pengguna</h1>
      <p>Silakan isi data berikut untuk membuat akun baru.</p>
      <RegisterForm />
    </main>
  );
}

Perlu diperhatikan bahwa komponen form akan memakai hook React, sehingga komponen tersebut harus menjadi Client Component dengan directive 'use client'.

Implementasi React Hook Form + Zod

Berikut implementasi inti form pendaftaran.

// components/register-form.tsx
'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, type RegisterFormData } from '@/lib/validations/register-schema';

export default function RegisterForm() {
  const [serverMessage, setServerMessage] = useState('');

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset
  } = useForm<RegisterFormData>({
    resolver: zodResolver(registerSchema),
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: ''
    },
    mode: 'onBlur'
  });

  const onSubmit = async (data: RegisterFormData) => {
    setServerMessage('');

    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(data)
      });

      const result = await response.json();

      if (!response.ok) {
        setServerMessage(result.message || 'Terjadi kesalahan saat mendaftar');
        return;
      }

      setServerMessage('Pendaftaran berhasil');
      reset();
    } catch {
      setServerMessage('Server tidak dapat dihubungi');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div style={{ marginBottom: 16 }}>
        <label htmlFor="name">Nama</label>
        <input id="name" type="text" {...register('name')} />
        {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
      </div>

      <div style={{ marginBottom: 16 }}>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>

      <div style={{ marginBottom: 16 }}>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
      </div>

      <div style={{ marginBottom: 16 }}>
        <label htmlFor="confirmPassword">Konfirmasi Password</label>
        <input id="confirmPassword" type="password" {...register('confirmPassword')} />
        {errors.confirmPassword && (
          <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Menyimpan...' : 'Daftar'}
      </button>

      {serverMessage && (
        <p style={{ marginTop: 16 }}>{serverMessage}</p>
      )}
    </form>
  );
}

Kenapa React Hook Form efisien

React Hook Form bekerja dengan pendekatan uncontrolled components untuk banyak kasus, sehingga re-render lebih sedikit dibanding pendekatan yang menyimpan setiap perubahan input ke state React. Hasilnya, performa form cenderung lebih baik, terutama pada form besar.

Kenapa memakai zodResolver

zodResolver memungkinkan React Hook Form menjalankan validasi berdasarkan schema Zod. Keuntungannya, aturan validasi tidak tersebar di setiap field, melainkan dikelola terpusat.

Contoh Validasi Ulang di Server

Di aplikasi production, jangan hanya percaya pada validasi client. Request dapat dimodifikasi dari browser devtools, script, atau request langsung ke endpoint. Karena itu, schema Zod yang sama sebaiknya dipakai ulang di server.

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { registerSchema } from '@/lib/validations/register-schema';

export async function POST(request: Request) {
  const body = await request.json();
  const parsed = registerSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      {
        message: 'Data tidak valid',
        errors: parsed.error.flatten()
      },
      { status: 400 }
    );
  }

  const { name, email } = parsed.data;

  return NextResponse.json({
    message: `Pengguna ${name} dengan email ${email} berhasil didaftarkan`
  });
}

Pendekatan ini memberi beberapa manfaat:

  • aturan validasi konsisten antara client dan server,
  • mengurangi duplikasi logika,
  • lebih aman terhadap request yang dimanipulasi.

Praktik Terbaik untuk Production

1. Validasi di client dan server

Client validation meningkatkan UX, sedangkan server validation menjaga integritas data. Keduanya harus berjalan bersama.

2. Simpan schema di lokasi terpusat

Letakkan schema di folder seperti lib/validations agar mudah dipakai ulang oleh halaman, API route, atau server action.

3. Gunakan pesan error yang spesifik

Pesan seperti "Format email tidak valid" jauh lebih membantu daripada "Input salah". Hindari pesan yang terlalu umum.

4. Jangan bocorkan detail sensitif

Di server, hati-hati saat mengembalikan error. Untuk kasus tertentu, jangan menampilkan informasi internal seperti struktur database atau stack trace.

5. Pertimbangkan aksesibilitas

Hubungkan label dan input dengan benar, tampilkan error dekat field terkait, dan jika perlu tambahkan atribut ARIA seperti aria-invalid dan aria-describedby.

6. Normalisasi input bila diperlukan

Contohnya, email sering disimpan dalam huruf kecil. Zod juga mendukung transformasi, tetapi gunakan dengan hati-hati agar perilakunya tetap mudah dipahami.

7. Tangani status loading dan submit ganda

Properti isSubmitting dari React Hook Form membantu mencegah user menekan tombol submit berkali-kali saat request masih berjalan.

Kesalahan Umum dan Tips Debugging

Schema tidak berjalan

Pastikan Anda sudah memasang @hookform/resolvers dan menggunakan resolver: zodResolver(registerSchema).

Komponen error karena hook dipakai di Server Component

Jika komponen form memakai useForm, file tersebut harus diawali dengan 'use client'.

Error field tidak muncul

Periksa nama field pada register('name') agar cocok dengan nama properti di schema Zod. Kesalahan penamaan adalah sumber bug yang sangat umum.

Validasi password konfirmasi tidak bekerja

Untuk validasi antar-field, gunakan refine atau superRefine. Jangan mencoba memaksakan seluruh logika hanya di field tunggal.

Response API gagal diproses

Pastikan endpoint mengembalikan JSON yang valid dan header request sudah berisi Content-Type: application/json.

Kapan Memilih Pendekatan Ini

Kombinasi React Hook Form dan Zod sangat cocok jika Anda membutuhkan:

  • form dengan validasi yang cukup kompleks,
  • integrasi TypeScript yang kuat,
  • performa baik pada banyak field,
  • schema yang bisa dipakai ulang di client dan server.

Jika kebutuhan sangat sederhana, validasi HTML bawaan browser mungkin sudah cukup. Namun, ketika aturan bisnis mulai bertambah, penggunaan schema validator seperti Zod akan jauh lebih terstruktur.

Penutup

Validasi form yang baik adalah fondasi penting dalam aplikasi web modern. Di Next.js 16 dengan App Router, kombinasi React Hook Form dan Zod memberi solusi yang efisien, type-safe, dan mudah dirawat. React Hook Form menangani state form dengan ringan, sedangkan Zod memastikan aturan validasi tetap konsisten dan bisa dipakai ulang di server.

Dengan struktur folder yang rapi, schema terpusat, pesan error yang jelas, dan validasi ulang di backend, Anda bisa membangun form pendaftaran yang lebih siap untuk kebutuhan production. Mulailah dari schema yang jelas, sambungkan ke form dengan resolver, lalu pastikan server tetap menjadi sumber validasi akhir.