How to Build a Vue.js Contact Form
Last updated: March 2026
Vue.js is a progressive JavaScript framework for building user interfaces. Unlike Nuxt (which adds server-side rendering, file-based routing, and auto-imports on top of Vue), a standalone Vue app is purely client-side, so there's no server to process form submissions. If you want a contact form that sends emails, stores data, and filters spam, you need an external backend to handle that. If you're using Nuxt instead, see the Nuxt contact form guide which covers Nuxt-specific features like auto-imports and Nitro.
This guide shows how to build a working contact form in a Vue 3 app using the Composition API and FormWit as the form backend. No Express server, no API proxy, no email service setup.
How it works
Your Vue component renders a form. When a visitor submits it, the component sends the form data to a FormWit endpoint via a POST request using fetch. FormWit validates the data, filters spam, stores the submission in your dashboard, and sends you an email notification. The visitor sees a success message without leaving the page.
The flow:
- A visitor fills out the contact form in your Vue app
- Your Vue component sends the form data to your FormWit endpoint
- FormWit validates the data, checks for spam, and stores the submission
- You receive an email notification and can view the submission in your dashboard
- The visitor sees a success or error message inline
Because the submission happens entirely in the browser, no server-side code is needed.
Build a Vue contact form
Step 1: Create a FormWit account
Go to app.formwit.com/auth/signup and create a free account. No credit card required. Once signed in, click Create Form and give it a name (e.g., "Vue Contact Form"). You'll get a unique endpoint URL that looks like https://app.formwit.com/api/s/YOUR_FORM_ID. Copy this. You'll need it in the next step.
Step 2: Create the ContactForm component
Create a new file called ContactForm.vue in your project's components directory. This single-file component uses the Vue 3 Composition API with <script setup> syntax, reactive refs for form state, v-model for two-way binding, and a honeypot field for spam protection.
<script setup>
import { ref } from 'vue'
const FORM_URL = 'https://app.formwit.com/api/s/YOUR_FORM_ID'
const name = ref('')
const email = ref('')
const message = ref('')
const honeypot = ref('')
const status = ref('idle')
async function handleSubmit() {
status.value = 'sending'
const formData = new FormData()
formData.append('name', name.value)
formData.append('email', email.value)
formData.append('message', message.value)
formData.append('_gotcha', honeypot.value)
try {
const response = await fetch(FORM_URL, {
method: 'POST',
body: formData,
})
if (response.ok) {
status.value = 'success'
name.value = ''
email.value = ''
message.value = ''
} else {
status.value = 'error'
}
} catch {
status.value = 'error'
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="name">Name</label>
<input
type="text"
id="name"
name="name"
v-model="name"
required
/>
</div>
<div>
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
v-model="email"
required
/>
</div>
<div>
<label for="message">Message</label>
<textarea
id="message"
name="message"
v-model="message"
rows="5"
required
></textarea>
</div>
<!-- Honeypot spam protection -->
<input
type="text"
name="_gotcha"
v-model="honeypot"
style="display:none"
tabindex="-1"
autocomplete="off"
/>
<button type="submit" :disabled="status === 'sending'">
{{ status === 'sending' ? 'Sending...' : 'Send Message' }}
</button>
<p v-if="status === 'success'" style="color:#16a34a">
Message sent successfully!
</p>
<p v-if="status === 'error'" style="color:#dc2626">
Something went wrong. Please try again.
</p>
</form>
</template> Replace YOUR_FORM_ID with the endpoint ID from your FormWit dashboard. The component uses FormData to serialize the fields and sends them via fetch. The hidden _gotcha field is a honeypot. Bots fill it in automatically, but real visitors never see it, so FormWit uses it to filter spam. For more on how this works, see the spam protection guide.
Step 3: Add the component to your app
Import the ContactForm component and use it in a page or view. If you're using Vue Router, create a contact route. Otherwise, import it directly wherever you need it.
With Vue Router
Create a view file at src/views/ContactView.vue:
<script setup>
import ContactForm from '../components/ContactForm.vue'
</script>
<template>
<main>
<h1>Contact Us</h1>
<p>Have a question or want to get in touch? Fill out the form below.</p>
<ContactForm />
</main>
</template> Then add the route to your router configuration:
import ContactView from '../views/ContactView.vue'
const routes = [
// ...existing routes
{
path: '/contact',
name: 'contact',
component: ContactView,
},
] Without Vue Router
If you're not using a router, import the component directly in your App.vue or any parent component:
<script setup>
import ContactForm from './components/ContactForm.vue'
</script>
<template>
<div>
<h1>Contact Us</h1>
<ContactForm />
</div>
</template> Step 4: Test the form
Start your Vue development server:
npm run dev Navigate to your contact page, fill out the form, and click "Send Message." You should see the success message appear without a page reload. Check your FormWit dashboard (the submission will appear there) and check your email inbox for the notification.
Options API variant
If you prefer the Options API over the Composition API, here's the same component written with data and methods:
<script>
export default {
data() {
return {
formUrl: 'https://app.formwit.com/api/s/YOUR_FORM_ID',
name: '',
email: '',
message: '',
honeypot: '',
status: 'idle',
}
},
methods: {
async handleSubmit() {
this.status = 'sending'
const formData = new FormData()
formData.append('name', this.name)
formData.append('email', this.email)
formData.append('message', this.message)
formData.append('_gotcha', this.honeypot)
try {
const response = await fetch(this.formUrl, {
method: 'POST',
body: formData,
})
if (response.ok) {
this.status = 'success'
this.name = ''
this.email = ''
this.message = ''
} else {
this.status = 'error'
}
} catch {
this.status = 'error'
}
},
},
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="name">Name</label>
<input type="text" id="name" v-model="name" required />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" v-model="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" v-model="message" rows="5" required></textarea>
</div>
<input type="text" name="_gotcha" v-model="honeypot"
style="display:none" tabindex="-1" autocomplete="off" />
<button type="submit" :disabled="status === 'sending'">
{{ status === 'sending' ? 'Sending...' : 'Send Message' }}
</button>
<p v-if="status === 'success'" style="color:#16a34a">Message sent!</p>
<p v-if="status === 'error'" style="color:#dc2626">Something went wrong. Try again.</p>
</form>
</template> Both versions behave identically. The Composition API version is recommended for new Vue 3 projects, but the Options API works just as well if that's what your codebase uses.
Vue-specific details
Composition API and <script setup>
The primary example uses <script setup>, which is Vue 3's compile-time syntactic sugar for the Composition API. It provides a cleaner syntax with less boilerplate. No need to explicitly return reactive state or methods. All top-level bindings declared in <script setup> are automatically available in the template.
Reactive refs and v-model
The component uses ref() for reactive state. Each form field is bound to a ref with v-model, which provides two-way data binding. When the form is submitted successfully, the refs are reset to empty strings, which clears the form fields automatically. This is the standard Vue 3 pattern for controlled form inputs.
@submit.prevent
The @submit.prevent directive on the form element is shorthand for calling event.preventDefault() inside the handler. This prevents the browser from doing a full-page form submission and lets the component handle the POST request with fetch instead. This is preferred in Vue over manually calling preventDefault in the handler function.
Works with Vite, Vue CLI, and other build tools
The ContactForm.vue component is a standard single-file component (SFC) that works with any Vue-compatible build tool:
- Vite (via
create-vueornpm create vite@latest) - works out of the box, no configuration needed. - Vue CLI (legacy) - works with both webpack and Vue CLI's default setup.
- Custom webpack config - works as long as you have
vue-loaderconfigured.
Works with Vue Router
If your app uses Vue Router, you can add the contact form as a dedicated route (as shown in Step 3) or embed it in any existing page. The component doesn't depend on the router. It's a self-contained form that can be placed anywhere in your component tree.
Adding a custom redirect
If you prefer to redirect visitors to a different page after submission instead of showing an inline message, handle the redirect in the submit function:
if (response.ok) {
window.location.href = '/thank-you'
} If you're using Vue Router, you can use programmatic navigation instead:
import { useRouter } from 'vue-router'
const router = useRouter()
// Inside handleSubmit:
if (response.ok) {
router.push('/thank-you')
} The Vue Router approach keeps the navigation within your SPA without a full page reload.
Using FormData from the DOM
The primary example above uses v-model refs to build the FormData manually. An alternative approach is to grab the form element directly from the submit event and use new FormData(event.target). This works well when you have many fields and don't need individual reactive refs for each one:
async function handleSubmit(event) {
status.value = 'sending'
try {
const response = await fetch(FORM_URL, {
method: 'POST',
body: new FormData(event.target),
})
if (response.ok) {
status.value = 'success'
event.target.reset()
} else {
status.value = 'error'
}
} catch {
status.value = 'error'
}
} With this approach, each input needs a name attribute (which the form fields already have), and you call event.target.reset() to clear the form instead of resetting individual refs. Both approaches send identical data to FormWit.
Styling the form
The component above uses no CSS, so it fits into any styling approach. You can add scoped styles directly in the .vue file:
<style scoped>
form {
max-width: 500px;
}
div {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: 600;
}
input,
textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background: #4f46e5;
color: white;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #4338ca;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> Vue's scoped attribute ensures these styles only apply to this component. If you use Tailwind CSS, UnoCSS, or another utility framework, apply classes directly to the template elements instead.
Why not build your own API?
You could spin up an Express or Fastify server to handle form submissions. But that means:
- Managing a server process and deployment pipeline
- Configuring an email service like SendGrid or Resend
- Storing API keys and managing environment variables
- Writing validation, error handling, and retry logic
- No built-in submission dashboard or history
- No built-in spam protection
A form backend handles all of this out of the box. Your Vue app stays purely client-side, and you can deploy it as a static site to any CDN or hosting provider.
Summary
Adding a contact form to a Vue.js app doesn't require a backend server, API proxy, or email configuration. With FormWit, you create a form endpoint, build a standard Vue component using the Composition API or Options API, and let the service handle spam filtering, data storage, and email notifications. The component works with Vite, Vue CLI, Vue Router, and any other tooling in the Vue ecosystem.
FormWit's free plan includes unlimited forms, 100 submissions per month, email notifications, and built-in spam protection. Create your free account and add a contact form to your Vue app in minutes.
Related guides: Nuxt contact form · React contact form · HTML contact form · Spam protection · Contact form templates
Frequently asked questions
Can I use FormWit with Vue 3?
Yes. FormWit works with Vue 3 using either the Composition API (<script setup>) or the Options API. Create a component that sends form data via fetch to your FormWit endpoint. No Vue plugins or Vuex/Pinia stores needed.
Do I need Pinia for form state?
No. Contact form state (field values, submission status) is local to the component. Use ref() from Vue's Composition API or data() from the Options API. Global state management with Pinia or Vuex adds unnecessary complexity for a single-form use case.
Does it work with Vite?
Yes. The ContactForm.vue single-file component works with Vite out of the box, with no extra configuration. It also works with Vue CLI (legacy webpack setup) and any other Vue-compatible build tool. FormWit is just a fetch POST to an external URL.
Want to skip the setup?
FormWit gives you a form endpoint in 60 seconds. Free plan, no credit card.
Need a form fast?
Build one visually with our free HTML form generator — no coding required.
Try the Form Generator →Add FormWit to your Vue app
Add a contact form to your site in 30 seconds. No backend code required.
Try FormWit Free