To build a Vue contact form without a backend, submit the form's FormData with fetch to a FormSubmit endpoint and track the status with a ref. FormSubmit emails you each submission, stores it, blocks spam and can forward it to Slack, Google Sheets or a webhook.
The component
src/components/ContactForm.vue:
<script setup>
import { ref } from "vue";
const ENDPOINT = "https://formsubmit.app/f/YOUR_FORM_ID";
const status = ref("idle");
const error = ref("");
const renderedAt = Date.now();
async function onSubmit(e) {
status.value = "sending";
const data = new FormData(e.target);
data.set("_ts", String(renderedAt));
try {
const res = await fetch(ENDPOINT, { method: "POST", body: data, headers: { Accept: "application/json" } });
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error?.message ?? "Something went wrong");
status.value = "success";
e.target.reset();
} catch (err) {
error.value = err.message;
status.value = "error";
}
}
</script>
<template>
<p v-if="status === 'success'" class="text-green-700">Thanks! We'll be in touch soon.</p>
<form v-else class="mx-auto max-w-lg space-y-5" @submit.prevent="onSubmit">
<div class="space-y-1.5">
<label for="fs-name" class="block text-sm font-medium text-gray-900">Name</label>
<input id="fs-name" type="text" name="name" placeholder="Jane Doe" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
</div>
<div class="space-y-1.5">
<label for="fs-email" class="block text-sm font-medium text-gray-900">Email</label>
<input id="fs-email" type="email" name="email" placeholder="jane@example.com" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
</div>
<div class="space-y-1.5">
<label for="fs-message" class="block text-sm font-medium text-gray-900">Message</label>
<textarea id="fs-message" name="message" rows="5" placeholder="How can we help?" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"></textarea>
</div>
<!-- Honeypot: leave this hidden field empty to catch bots -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
<p v-if="status === 'error'" class="text-sm text-red-600">{{ error }}</p>
<button type="submit" :disabled="status === 'sending'" class="inline-flex w-full items-center justify-center rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50">
{{ status === "sending" ? "Sending…" : "Send" }}
</button>
</form>
</template>Walkthrough
@submit.preventstops the full-page post and callsonSubmit.new FormData(e.target)gathers every named field — nov-modelneeded for a simple form.- The
_tsvalue is captured when the component is created so FormSubmit can detect bots that submit instantly. Accept: application/jsonmakes FormSubmit reply with{ ok, id, message }or{ ok: false, error }.
Using v-model
If you already bind inputs with v-model, send JSON:
await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...form.value, _ts: renderedAt }),
});Validation
Native required, type="email" and maxlength attributes are enough for most contact forms. If you use VeeValidate or Vuelidate, run validation first and only call fetch when valid. Error handling and loading states are covered in submitting a form with JavaScript.
Next steps
- Using Nuxt? See the Nuxt contact form guide.
- Add Turnstile for high-traffic forms.
- Send submissions to Discord for community projects.