Build a Vue contact form with the Composition API (and somewhere to send it)
Vue makes building the form itself almost too easy — v-model on a few inputs, @submit.prevent on the form, and you've got fully reactive fields with a fraction of the code other frameworks need. Then you hit the actual requirement behind "add a contact form": something has to receive that data, store it, and get an email to whoever's supposed to see it. v-model doesn't do any of that, and it isn't supposed to — that's a backend concern, not a component concern.
If your Vue app is a Vite SPA with no server behind it, or you just don't want to build and host a mail-sending backend for a form with three fields, you can point the submit handler at a hosted ingestion endpoint instead. Here's a complete Vue 3 component using the Composition API and <script setup> — reactive state, fetch, and nothing to install.
The complete component
<script setup>
import { reactive, ref } from 'vue'
const ENDPOINT = 'https://api.submissionbuddy.io/f/your-form'
const fields = reactive({
name: '',
email: '',
message: '',
_honeypot: '',
})
const status = ref('idle') // idle | submitting | success | error
async function handleSubmit() {
status.value = 'submitting'
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
// 202 Accepted: { "ok": true, "submission_id": "…" }
status.value = 'success'
fields.name = ''
fields.email = ''
fields.message = ''
} catch {
status.value = 'error'
}
}
</script>
<template>
<p v-if="status === 'success'" role="status">
Thanks — we got your message and will be in touch.
</p>
<form v-else @submit.prevent="handleSubmit">
<label>
Name
<input v-model="fields.name" type="text" name="name" required />
</label>
<label>
Email
<input v-model="fields.email" type="email" name="email" required />
</label>
<label>
Message
<textarea v-model="fields.message" name="message" required></textarea>
</label>
<!-- honeypot: hidden from real visitors, silently trips up bots -->
<input
v-model="fields._honeypot"
type="text"
name="_honeypot"
style="display: none"
tabindex="-1"
autocomplete="off"
/>
<button type="submit" :disabled="status === 'submitting'">
{{ status === 'submitting' ? 'Sending…' : 'Send' }}
</button>
<p v-if="status === 'error'" role="alert">Something went wrong. Please try again.</p>
</form>
</template>
That's a fully working, reactive contact form: a reactive() object for the fields, a ref for submission status, fetch for the actual network call. No form library, no Pinia store, no plugin to register — SubmissionBuddy has no SDK to install, which means there's nothing here beyond Vue itself and the browser's fetch.
Spam protection
The _honeypot field bound above is invisible to real visitors and pulled out of the tab order, so nobody using the form normally will ever type into it. A bot filling in every field it finds in the DOM will fill that one too — and when it comes back non-empty, SubmissionBuddy quietly discards the submission (no storage write, no email, nothing in your dashboard) while still returning what looks like a normal success, so the bot has no signal to adapt to. This is on every plan, including free, and needs no configuration beyond the field itself.
For forms that attract more persistent bots, Pro and Business plans add an invisible proof-of-work CAPTCHA on top — solved silently by the browser, verified entirely on SubmissionBuddy's servers, no third-party requests or cookies involved.
Handling success without _redirect
Because this is a reactive SPA component rather than a full page load, the example above swaps in a success message via the status ref rather than navigating away. If your Vue app is more of a multi-page site and you'd rather send the visitor to a dedicated thanks page, add a _redirect field to the payload and check for a 303 response — but for a component like this one, staying in place and showing the confirmation message (as above) is usually the better UX, since there's no full-page navigation to justify.
What happens after the request
The 202 your fetch call resolves to means the raw submission was already durably stored before that response was sent — not queued to be stored later. From there it passes through independent, automatically retried stages: a worker writes it into the database that powers your dashboard, and if the form has notification recipients set up, another sends an email. A slow or failing mail delivery retries on its own; it doesn't hold up your component or affect the response you already got.
Cost
Free tier: 100 submissions a month, 2 forms, no credit card. That's enough for a personal site, a small business's contact page, or a project's feedback form. Sign up for a real endpoint slug and drop it in place of your-form above.