Why a Vue contact form doesn't need a backend
A Vue contact form is HTML at the leaves — every reactive v-modelcompiles down to inputs the browser can POST. Once you accept that, the question of "where does the submission go?" has three answers: write your own Node service (10× more work than the form), use a serverless function (still infra to manage, still SMTP credentials to rotate), or point the submission at a hosted form backend that handles email, storage and spam filtering. The third option is the entire content of this post.
The hosted-backend pattern works for plain Vue 3 (Vite SPA, hosted on Cloudflare Pages or Netlify), Nuxt 3 SSR/SSG sites, Quasar apps, and embedded Vue widgets on a different host. The component code is identical in all four; only deployment changes. We'll use splitforms as the Vue form backend because the free tier covers 1,000 submissions per month and the integration is one fetch call.
Vue 3 Composition API contact form
Create src/components/ContactForm.vue. The component uses <script setup>, three refs for the form fields, and a fourth ref for the submission status:
<!-- src/components/ContactForm.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const ACCESS_KEY = import.meta.env.VITE_SPLITFORMS_KEY as string;
const FORM_URL = 'https://splitforms.com/api/submit';
const name = ref('');
const email = ref('');
const message = ref('');
type Status = 'idle' | 'submitting' | 'success' | 'error';
const status = ref<Status>('idle');
const errorMsg = ref('');
async function onSubmit(e: Event) {
const formEl = e.target as HTMLFormElement;
status.value = 'submitting';
errorMsg.value = '';
try {
const res = await fetch(formEl.action, {
method: 'POST',
body: new FormData(formEl),
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
status.value = 'success';
name.value = email.value = message.value = '';
} catch (err) {
status.value = 'error';
errorMsg.value = (err as Error).message;
}
}
</script>
<template>
<form
:action="FORM_URL"
method="POST"
class="contact"
@submit.prevent="onSubmit"
novalidate
>
<input type="hidden" name="access_key" :value="ACCESS_KEY" />
<label>
Your name
<input v-model="name" name="name" type="text" required autocomplete="name" />
</label>
<label>
Your email
<input v-model="email" name="email" type="email" required autocomplete="email" />
</label>
<label>
Your message
<textarea v-model="message" name="message" rows="5" required></textarea>
</label>
<button type="submit" :disabled="status === 'submitting'">
{{ status === 'submitting' ? 'Sending…' : 'Send message' }}
</button>
<p v-if="status === 'success'" role="status" class="ok">
Thanks — we received your message.
</p>
<p v-if="status === 'error'" role="alert" class="err">
Couldn't send: {{ errorMsg }}. Please try again.
</p>
</form>
</template>
<style scoped>
.contact { display: grid; gap: 14px; max-width: 32rem; }
.contact label { display: grid; gap: 6px; font-size: 14px; }
.contact input, .contact textarea {
padding: 10px 12px; border: 1px solid #d4d4d8;
border-radius: 8px; font: inherit;
}
.contact button {
padding: 12px 18px; border: none; border-radius: 8px;
background: #ff4f00; color: #fff; font-weight: 600; cursor: pointer;
}
.contact button:disabled { opacity: 0.6; cursor: not-allowed; }
.ok { color: #14532d; }
.err { color: #991b1b; }
</style>Add the access key to .env at the project root with the VITE_ prefix so Vite exposes it to the client:
# .env
VITE_SPLITFORMS_KEY=sf_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxDrop the component into any page:
<!-- src/views/Contact.vue -->
<script setup lang="ts">
import ContactForm from '@/components/ContactForm.vue';
</script>
<template>
<main class="container">
<h1>Contact us</h1>
<p>We respond within one business day.</p>
<ContactForm />
</main>
</template>How the submission flow works
When the user clicks submit, four things happen in order:
- The browser fires a
submitevent on the form.@submit.preventintercepts it before the default page-reload behavior. - The handler builds a
FormDataobject from the live DOM. This includes every named input — including the hiddenaccess_key. - A single
fetch()POSTs the FormData to splitforms withAccept: application/json, which tells the backend to return JSON instead of a redirect. - splitforms validates the access key, runs spam filtering, stores the submission, sends the notification email, and returns
{ success: true }. The Vue component swaps to its success state.
Because the form has a real actionattribute, it also works without JavaScript — visitors with JS disabled (or aggressive content blockers) get a working full-page-reload submission. That's the splitforms-specific reason to keep the action URL on the form even when you're intercepting the submit event.
Adding validation with VeeValidate (optional)
The HTML5 attributes (required, type="email", pattern) cover most contact forms — the browser blocks submission and surfaces native error bubbles. For inline error messages or async validation ("is this email already registered?"), VeeValidate is the lightest option.
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { object, string } from 'yup';
const schema = object({
name: string().required().min(2),
email: string().email().required(),
message: string().required().min(10),
});
const { handleSubmit, errors } = useForm({ validationSchema: schema });
const { value: name } = useField<string>('name');
const { value: email } = useField<string>('email');
const { value: message } = useField<string>('message');
const onSubmit = handleSubmit(async () => {
const body = new URLSearchParams({
access_key: import.meta.env.VITE_SPLITFORMS_KEY,
name: name.value,
email: email.value,
message: message.value,
});
const res = await fetch('https://splitforms.com/api/submit', {
method: 'POST',
body,
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error('Submission failed');
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<input v-model="name" name="name" />
<span v-if="errors.name">{{ errors.name }}</span>
<input v-model="email" name="email" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<textarea v-model="message" name="message"></textarea>
<span v-if="errors.message">{{ errors.message }}</span>
<button type="submit">Send</button>
</form>
</template>The trade-off: VeeValidate adds about 8KB gzipped. For a single contact form, the HTML5 attributes are usually enough — for a multi-step lead-gen form with conditional logic, VeeValidate or a similar library pays for itself.
Nuxt 3: keeping the access key server-side
The access key is bound to a single splitforms account and rate-limited per origin, so shipping it in the client bundle is fine. If you'd still rather not — for a high-traffic site, an internal portal, or any case where you want a single point of failure — a Nuxt server route is six lines:
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ name: string; email: string; message: string }>(event);
const params = new URLSearchParams({
access_key: process.env.SPLITFORMS_KEY!,
...body,
});
const res = await $fetch('https://splitforms.com/api/submit', {
method: 'POST',
body: params,
headers: { Accept: 'application/json' },
});
return res;
});Then the Vue component posts to /api/contact instead of splitforms directly. The access key never appears in the client bundle. Use this pattern when the access key needs to live in .env alongside other server secrets.
Spam protection on a Vue form
Add a honeypot field to the template — a hidden input bots fill but humans don't see. splitforms drops any submission with a non-empty honeypot value automatically:
<form @submit.prevent="onSubmit" :action="FORM_URL" method="POST">
<input type="hidden" name="access_key" :value="ACCESS_KEY" />
<!-- Honeypot. Bots fill it; humans never see it. -->
<div aria-hidden="true" style="position:absolute;left:-9999px">
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<input v-model="name" name="name" required />
<input v-model="email" name="email" type="email" required />
<textarea v-model="message" name="message" required></textarea>
<button type="submit">Send</button>
</form>For determined bots that skip honeypots, layer Cloudflare Turnstile on top — it works on Vue SPAs and Nuxt SSR alike. Read honeypot vs reCAPTCHA for the trade-offs and the complete spam protection guide for the layered approach.
splitforms vs other Vue form backends
The Vue ecosystem has the same set of hosted form backends as React. The differences worth knowing in 2026:
- Formspree. Free tier is 50 submissions/month — too small for most sites. Paid starts at $10/mo. See splitforms vs Formspree.
- Web3Forms. Free tier is unlimited but submissions per day are capped, and the success page shows their branding on the free plan. See splitforms vs Web3Forms.
- Netlify Forms. Only works on sites hosted on Netlify, and the form has to be in the static HTML at build time — Vue SPAs that render the form client-side need a workaround.
- splitforms. 1,000 submissions/month free, no per-day cap, no branding on the success page, webhooks on the free tier. Pro is $5/mo for 5,000.
For a deeper side-by-side comparison see the 2026 ranked list of free form backends.
Ship the form
Get a free access key at /login or generate one without signup at /free-contact-form. Drop it into the Composition API component above and your Vue contact form is live in under five minutes. The same component works on Vue 3, Nuxt 3, Quasar and any Vite + Vue toolchain.
If you'd rather see the equivalent in another framework first, check the Svelte form submission guide, the Astro contact form tutorial, or the React form library comparison. The pricing details are at /pricing and the dashboard configuration walkthrough lives in /docs.
FAQ
Do I need a Vue backend like Express or Nuxt server routes for a contact form?
No. A Vue contact form can post directly to a hosted form backend (splitforms, Formspree, Web3Forms) from the browser. You skip building, hosting and securing a Node service entirely. The only time you need server code is if you want the access key kept private — and even then, a one-line Nuxt server route is enough; you don't need a full Express app.
Vue 2 or Vue 3 — does the form code change?
The HTML inside the template is identical. Only the script-block syntax differs. Vue 2 uses Options API (`data() { return { name: '' } }`); Vue 3 uses Composition API with `<script setup>` and `ref()`. The submission logic — `fetch(action, { method: 'POST', body: new FormData(form) })` — is the same in both. The Composition API examples in this post run on Vue 3.x and Nuxt 3.x.
Can I use this with Nuxt 3?
Yes — the component drops into a Nuxt 3 page or layout unchanged. Nuxt's auto-imports remove the explicit `import { ref } from 'vue'` line; otherwise the file is identical. If you'd rather keep the access key server-side, Nuxt's server routes (`server/api/contact.post.ts`) let you proxy the request without spinning up a separate backend service.
How do I add validation to the Vue contact form?
The HTML5 validation attributes (`required`, `type="email"`, `pattern`, `minlength`) work in any Vue template — the browser enforces them before the form submits. For richer validation (showing errors next to fields, async checks), use VeeValidate or Vuelidate. The fetch-based submission code in this post stays the same; you just don't call fetch until your validation passes.
How do I show a success or error message after submit?
Track three reactive refs: `submitting`, `succeeded`, `errorMsg`. Toggle them based on the fetch response. The Composition API example in this post shows the exact pattern. Use `<Transition>` if you want a fade between states.
Can I attach files (resumes, screenshots) to a Vue form submission?
Yes. Add `enctype="multipart/form-data"` to the form, an `<input type="file" name="resume" />`, and pass `new FormData(form)` as the fetch body. Don't manually JSON.stringify — JSON can't carry binary file bytes, and the fetch call has to use FormData. The splitforms backend accepts files up to 5MB on the free plan.
What's the cheapest way to scale this beyond the free tier?
splitforms Pro is $5/mo for 5,000 submissions. The $59 4-year plan covers 15,000/mo for 48 months — useful if you already know your project will run for years. Both plans use the same Vue component you wrote on the free tier; only the dashboard config changes.