Episode ini membahas pembangunan form di Nuxt: pengikatan data dengan v-model, validasi client-side memakai VeeValidate, pemrosesan form di server dengan server actions, serta aspek UX form seperti pesan error, state loading, dan accessibility.

Form adalah jembatan antara aplikasi dan pengguna — tapi juga sumber error paling banyak bila dikerjakan asal-asalan. Episode 10 membahas cara membangun form yang benar di Nuxt: dari pengikatan data, validasi di client, pemrosesan di server, sampai pengalaman pengguna saat terjadi error atau proses yang lama.
Pendekatan kita: validasi di client untuk feedback cepat, dan validasi ulang di server untuk keamanan. Dua lapis ini tidak bisa ditawar — form tanpa validasi server berbahaya, dan form tanpa validasi client terasa lambat dan membingungkan.
Mulai dari yang paling dasar: mengikat input ke state reaktif dengan v-model:
<script setup lang="ts">
const form = ref({
nama: "",
email: "",
alamat: "",
})
</script>
<template>
<form>
<input v-model="form.nama" type="text" placeholder="Nama lengkap" />
<input v-model="form.email" type="email" placeholder="Email" />
<textarea v-model="form.alamat" placeholder="Alamat"></textarea>
</form>
</template>v-model="form.nama" membuat dua arah: saat pengguna mengetik, state berubah; saat state berubah dari kode, input ikut tampil. Ini pola dasar seluruh form Nuxt.
Install VeeValidate dan adapter validasi yang diinginkan:
npm install vee-validate @vee-validate/zod zodVeeValidate menyediakan useForm dan useField untuk membungkus input dengan validasi:
<script setup lang="ts">
import { useForm, useField } from "vee-validate"
import { toTypedSchema } from "@vee-validate/zod"
import { z } from "zod"
const schema = toTypedSchema(
z.object({
nama: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().email("Format email tidak valid"),
})
)
const { handleSubmit, errors } = useForm({ validationSchema: schema })
const { value: nama } = useField("nama")
const { value: email } = useField("email")
const onSubmit = handleSubmit(async (values) => {
console.log("Data valid:", values)
})
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="nama" type="text" placeholder="Nama" />
<p v-if="errors.nama">{{ errors.nama }}</p>
<input v-model="email" type="email" placeholder="Email" />
<p v-if="errors.email">{{ errors.email }}</p>
<button type="submit">Kirim</button>
</form>
</template>useForm({ validationSchema: schema }) menghubungkan skema Zod ke form. Setiap useField membungkus satu field, dan errors menampung pesan validasi yang siap dirender. handleSubmit hanya menjalankan handler jika seluruh form valid.
Buat pesan error yang menjelaskan apa yang salah dan cara memperbaikinya. Kalimat seperti "Format email tidak valid" jauh lebih membantu daripada "error" generik.
Validasi client hanya untuk kenyamanan. Kebenaran final harus di server — server action kita dari episode 6 diterapkan penuh di sini:
import { z } from "zod"
const skemaPesanan = z.object({
nama: z.string().min(3),
email: z.string().email(),
alamat: z.string().min(10),
})
export const buatPesanan = defineServerAction(
async (input: unknown) => {
const validasi = skemaPesanan.safeParse(input)
if (!validasi.success) {
throw createError({
statusCode: 400,
message: "Data pesanan tidak valid",
})
}
const id = await simpanPesanan(validasi.data)
return { id }
}
)skemaPesanan.safeParse(input) memvalidasi ulang data di server dengan skema yang sama. createError({ statusCode: 400, ... }) mengembalikan error HTTP yang bisa ditangkap client.
const { handleSubmit, errors, isSubmitting } = useForm({
validationSchema: schema,
})
const onSubmit = handleSubmit(async (values) => {
try {
const hasil = await buatPesanan(values)
await navigateTo(`/pesanan/${hasil.id}`)
} catch (e) {
console.error("Pesanan gagal", e)
}
})Alur lengkapnya: validasi di client untuk feedback instan, server action memvalidasi lagi dan menyimpan, lalu redirect ke halaman sukses. isSubmitting dari VeeValidate bisa dipakai untuk menampilkan loading.
Tampilkan error di tempat yang mudah ditemukan, dan beri umpan balik saat form sedang diproses:
<template>
<form @submit.prevent="onSubmit">
<label for="email">Email</label>
<input id="email" v-model="email" type="email" :disabled="isSubmitting" />
<p v-if="errors.email" role="alert">{{ errors.email }}</p>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? "Mengirim..." : "Buat Pesanan" }}
</button>
</form>
</template>role="alert" membuat pesan error dibacakan screen reader saat muncul. :disabled="isSubmitting" mencegah submit ganda yang sering menyebabkan pesanan dobel.
Beberapa prinsip yang wajib: setiap input punya <label> yang terhubung lewat atribut id, fokus keyboard berpindah dengan tab yang logis, dan pesan error terhubung ke field-nya. Episode 17 akan membahas accessibility secara menyeluruh.
Episode 10 membuat form kalian berfungsi penuh dan aman: v-model untuk pengikatan data, VeeValidate dengan Zod untuk validasi client, server actions untuk pemrosesan yang divalidasi ulang di server, serta UX yang memperhatikan error, loading, dan accessibility.
Inti yang harus dibawa pulang:
v-model menghubungkan input form dengan state reaktif dua arah.isSubmitting untuk menonaktifkan tombol dan mencegah submit ganda.role="alert" untuk pesan error.Di episode 11 selanjutnya kita akan membahas content dan CMS integration — integrasi konten statis dengan @nuxt/content, sourcing konten dari Markdown, MDX, dan headless CMS, membangun halaman berbasis konten dengan pencarian, serta preview mode untuk alur kerja editorial. Blog toko kalian siap berdiri.