This episode covers building forms in Nuxt: data binding with v-model, client-side validation using VeeValidate, server-side form processing with server actions, and form UX aspects such as error messages, loading states, and accessibility.

Forms are the bridge between an application and its users — but they are also the biggest source of errors when done carelessly. Episode 10 covers how to build forms correctly in Nuxt: from data binding, client-side validation, server-side processing, to the user experience when errors occur or the process takes a while.
Our approach: client-side validation for fast feedback, and server-side revalidation for security. These two layers are non-negotiable — a form without server validation is dangerous, and a form without client validation feels slow and confusing.
Start from the very basics: binding inputs to reactive state with 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" works in both directions: when the user types, the state changes; when the state changes from code, the input updates too. This is the basic pattern of every Nuxt form.
Install VeeValidate and the validation adapter of your choice:
npm install vee-validate @vee-validate/zod zodVeeValidate provides useForm and useField to wrap inputs with validation:
<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 }) connects the Zod schema to the form. Each useField wraps one field, and errors holds the validation messages ready to render. handleSubmit only runs the handler if the whole form is valid.
Write error messages that explain what went wrong and how to fix it. Sentences like "Format email tidak valid" are much more helpful than a generic "error".
Client validation is only for convenience. The final truth must live on the server — our server action from episode 6 gets fully applied here:
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) revalidates the data on the server with the same schema. createError({ statusCode: 400, ... }) returns an HTTP error the client can catch.
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)
}
})The complete flow: client-side validation for instant feedback, the server action validates again and saves, then a redirect to the success page. VeeValidate's isSubmitting can be used to show loading.
Show errors where they're easy to find, and give feedback while the form is being processed:
<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" makes the error message read aloud by screen readers when it appears. :disabled="isSubmitting" prevents double submissions that often cause duplicate orders.
A few must-have principles: every input has a <label> connected through the id attribute, keyboard focus moves in a logical tab order, and error messages are associated with their fields. Episode 17 will cover accessibility comprehensively.
Episode 10 makes your forms fully functional and secure: v-model for data binding, VeeValidate with Zod for client validation, server actions for processing revalidated on the server, and UX that pays attention to errors, loading, and accessibility.
Key takeaways:
v-model connects form inputs with reactive state in both directions.isSubmitting to disable buttons and prevent double submissions.role="alert" for error messages.In the next episode, episode 11, we will discuss content and CMS integration — integrating static content with @nuxt/content, sourcing content from Markdown, MDX, and headless CMSes, building content-based pages with search, and preview mode for editorial workflows. Your store's blog will be ready to stand up.