Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 021 / 100

Forms in Vue: what every Vue & Nuxt developer should know

Inputs, validation and submission.

Expona Admin8 min read

Introduction

Forms are everywhere in modern web applications—login screens, checkout flows, user profiles, support tickets. Yet handling forms well in Vue remains one of the most common pain points we see in production codebases. Forms in Vue: what every Vue & Nuxt developer should know covers the practical patterns that separate rough prototypes from robust, maintainable applications. We'll walk through binding data with v-model, validating inputs with battle-tested libraries, displaying errors clearly, and managing submission states—the core skills that make forms feel polished for both users and maintainers.

v-model — Bind inputs to reactive state

The foundation of form handling in Vue is two-way data binding via v-model. When you attach v-model to an input, Vue automatically synchronizes the input's value with your component's reactive data. Change the input, the data updates; update the data programmatically, the input reflects it. This simplicity is powerful, but the details matter.

In Vue 3, v-model is just syntactic sugar. On a text input, <input v-model="email" /> expands to <input :value="email" @input="email = $event.target.value" />. Understanding this expansion helps when you need to customize behavior or debug unexpected changes.

typescript
// Simple form component
<template>
  <form @submit.prevent="handleSubmit">
    <input
      v-model="formData.email"
      type="email"
      placeholder="your@email.com"
    />
    <textarea v-model="formData.message"></textarea>
    <select v-model="formData.priority">
      <option value="low">Low</option>
      <option value="high">High</option>
    </select>
    <button type="submit">Submit</button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const formData = ref({
  email: '',
  message: '',
  priority: 'low',
})

const handleSubmit = async () => {
  // formData.value contains current form state
  console.log(formData.value)
}
</script>

For more complex data structures—arrays of items, nested objects—keep your reactive state flat or well-normalized. Avoid deeply nested ref structures that become hard to track and reason about. If your form data gets complex, consider using a dedicated form management layer (more on that below).

Also important: v-model modifiers like .trim, .lazy, and .number are handy for preprocessing input. <input v-model.trim="name" /> automatically strips whitespace; v-model.number coerces the value to a number rather than keeping it as a string. These small touches prevent bugs downstream in validation and submission.

Validation libraries — VeeValidate with Zod schemas

Client-side validation is not security—the server must always validate—but it dramatically improves user experience by catching errors immediately. Rolling your own validation logic leads to inconsistency, duplication, and maintenance headaches. Instead, use a library like VeeValidate paired with a schema validator like Zod or Yup.

VeeValidate handles the Vue-specific concerns: managing field state, tracking touched/dirty/pristine status, aggregating errors. Zod defines your validation rules declaratively and provides TypeScript types automatically. Together, they scale from simple forms to complex multi-step wizards.

typescript
import { useForm } from 'vee-validate'
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'

// Define schema with Zod
const signupSchema = z.object({
  email: z.string().email('Invalid email format'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase letter')
    .regex(/[0-9]/, 'Must contain number'),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  }
)

// In your component
const { values, errors, handleSubmit, isSubmitting } = useForm({
  validationSchema: toTypedSchema(signupSchema),
  initialValues: {
    email: '',
    password: '',
    confirmPassword: '',
  },
})

const onSubmit = handleSubmit(async (values) => {
  // values are guaranteed to match schema
  await api.post('/signup', values)
})

VeeValidate tracks field-level metadata automatically. After you blur a field, meta.touched becomes true; after you modify it, meta.dirty becomes true. This metadata is essential for deciding when to show errors—typically after first blur, not on every keystroke.

In Nuxt, install both packages and wrap your form in a VeeValidate component or use the composable directly as shown above. The setup is identical; the benefit of using a schema library is that you can share the same Zod schema between your frontend and backend validation, ensuring consistency across your entire stack.

Show errors — Per-field messages after blur or submit

Showing validation errors at the right time is critical to user experience. Show them too early (on every keystroke while the user is still typing) and they become noise; show them too late (only on submit) and the user wastes time filling out the form only to see a list of failures. The standard pattern is: show errors after the field is blurred or after form submission.

typescript
<template>
  <form @submit.prevent="onSubmit">
    <div class="form-group">
      <label for="email">Email</label>
      <input
        id="email"
        v-model="values.email"
        type="email"
        @blur="() => setFieldTouched('email', true)"
      />
      <!-- Show error only if field is touched or form was submitted -->
      <p v-if="errors.email" class="error">
        {{ errors.email }}
      </p>
    </div>

    <div class="form-group">
      <label for="password">Password</label>
      <input
        id="password"
        v-model="values.password"
        type="password"
        @blur="() => setFieldTouched('password', true)"
      />
      <p v-if="errors.password" class="error">
        {{ errors.password }}
      </p>
    </div>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? 'Signing up...' : 'Sign Up' }}
    </button>
  </form>
</template>

<script setup lang="ts">
import { useForm, useField } from 'vee-validate'

const { values, errors, handleSubmit, isSubmitting, setFieldTouched } = useForm({
  validationSchema: toTypedSchema(signupSchema),
})

const onSubmit = handleSubmit(async (formValues) => {
  try {
    await api.post('/signup', formValues)
    // Success handling
  } catch (err) {
    // Error handling
  }
})
</script>

Notice the setFieldTouched call on blur. This marks the field as touched so errors appear. You can also extract individual fields using VeeValidate's useField composable if you need finer control, though for most forms the pattern above is sufficient.

For server-side validation errors (e.g., "email already registered"), set the error on the specific field after the API call fails:

typescript
const onSubmit = handleSubmit(async (formValues) => {
  try {
    await api.post('/signup', formValues)
  } catch (err) {
    if (err.response?.status === 409) {
      setFieldError('email', 'Email already registered')
    }
  }
})

This integrates server feedback seamlessly into the client-side validation UI.

Submit — Disable the button while saving

When the user clicks submit, the form should provide immediate feedback. The most essential pattern: disable the submit button while the request is in flight. This prevents accidental double-submission and signals to the user that their action was received. VeeValidate's isSubmitting flag does exactly this.

Beyond disabling the button, consider:

  • Disable all inputs during submission to prevent mid-flight edits that confuse the server state.
  • Show a loading spinner or text change so users know something is happening (especially on slow networks).
  • Handle errors gracefully—don't silently fail. Show a toast, an alert, or inline error messages.
  • Reset or redirect after success depending on your flow. A login form might redirect; a contact form might reset and show a success message.
typescript
<template>
  <form @submit.prevent="onSubmit">
    <!-- Show success message if submit worked -->
    <div v-if="submitSuccess" class="success">
      Form submitted successfully!
    </div>

    <!-- Show general error if submit failed -->
    <div v-if="submitError" class="error">
      {{ submitError }}
    </div>

    <!-- All inputs and fields here -->
    <input
      v-model="values.email"
      :disabled="isSubmitting"
      type="email"
    />

    <button
      type="submit"
      :disabled="isSubmitting"
      class="submit-btn"
    >
      <span v-if="isSubmitting">Saving...</span>
      <span v-else>Save</span>
    </button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useForm } from 'vee-validate'

const submitSuccess = ref(false)
const submitError = ref('')

const { values, errors, handleSubmit, isSubmitting } = useForm({
  validationSchema: toTypedSchema(mySchema),
})

const onSubmit = handleSubmit(async (formValues) => {
  submitError.value = ''
  submitSuccess.value = false

  try {
    await api.post('/endpoint', formValues)
    submitSuccess.value = true
    // Optionally reset form or redirect
  } catch (err) {
    submitError.value = err.response?.data?.message || 'An error occurred'
  }
})
</script>

In Nuxt, if you're using server routes or server actions, the pattern remains the same: the isSubmitting flag disables interactions while the request completes.

Common mistakes

Not validating on the server. Client-side validation is convenience, not security. Always validate on the backend, regardless of what the frontend validated. An attacker can bypass your client-side rules entirely.

Showing errors too eagerly. Displaying validation messages on every keystroke annoys users and creates noise. Wait for blur or submit.

Not handling server errors. After submission, the server might reject the data (duplicate email, business logic violation). Integrate these errors into your client-side form so users see them in context rather than a generic alert.

Forgetting to disable the submit button. Without this, users double-click and submit the form twice, creating duplicate records or duplicate charges. Always tie button state to the submission state.

Over-engineering for simple forms. A single-field search box doesn't need VeeValidate. Use v-model and a simple ref. Reach for a library when your form grows complex enough to justify it.

Key takeaways

  • v-model binds inputs to reactive state; understand that v-model="x" expands to :value="x" @input="x = $event.target.value".
  • Use VeeValidate with Zod (or Yup) to define validation rules declaratively and share schemas between frontend and backend.
  • Show validation errors after blur or submit, not on every keystroke, to keep the UI clean and user-friendly.
  • Always disable the submit button (and ideally all inputs) while submission is in flight to prevent double-submission and signal progress to the user.
  • Handle server-side validation errors by setting field-specific errors in VeeValidate, not just showing generic alerts.
  • Never skip backend validation; client-side validation is for UX, not security.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles