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.
Next steps
- Using Nuxt? See the Nuxt contact form guide.
- Add Turnstile for high-traffic forms.
- Send submissions to Discord for community projects.