Build a copy-pasteable Vue 3 contact form with <script setup>, the Composition API, and a
single fetch to Formigo. No backend to babysit, no servers to wrangle. Your submissions just show up.
Vue is brilliant at the front end, but a contact form still needs somewhere to send the data. Formigo is that somewhere: point your form at a single URL and we handle the email, Slack, Discord, Google Sheets, and webhook delivery for you.
This guide walks through Vue form submission end to end using modern Vue 3 with
<script setup> and the Composition API (ref, onMounted).
Everything below is real, copy-pasteable code. Swap in your own form slug and you are done.
Replace your-form with your form slug
Every Formigo form has a unique endpoint. Your Vue app POSTs submissions straight to it. The endpoint
accepts both JSON and form-encoded bodies, and it is CORS-enabled, so you can call it directly from the
browser with fetch, no proxy required.
https://formigo.io/f/your-form
A successful submission returns a 2xx status. If you are sending too fast, Formigo replies
with 429 and a JSON body like { "retry_after": 30 } so you know when to try again.
Single-file component, <script setup>, fetch + state
Drop this into a .vue file (say ContactForm.vue) and import it anywhere. It
covers reactive state with ref, a loading flag, success and error messages, honeypot and
timestamp spam fields, and 429 rate-limit handling. We break down the interesting bits below.
<template>
<div class="max-w-md mx-auto">
<h2 class="text-2xl font-bold mb-4">Contact Us</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label for="name" class="block mb-2">Name *</label>
<input
type="text"
id="name"
v-model="form.name"
required
class="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label for="email" class="block mb-2">Email *</label>
<input
type="email"
id="email"
v-model="form.email"
required
class="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label for="message" class="block mb-2">Message *</label>
<textarea
id="message"
v-model="form.message"
required
rows="5"
class="w-full px-4 py-2 border rounded-lg"
/>
</div>
<!-- Honeypot: hidden from humans, catnip for bots -->
<input
v-model="form._formigo_hp"
type="text"
name="_formigo_hp"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
style="position: absolute; left: -9999px;"
/>
<button
type="submit"
:disabled="loading"
class="w-full px-6 py-3 bg-pink-500 text-white rounded-lg
hover:bg-pink-600 disabled:opacity-50"
>
{{ loading ? "Sending..." : "Send Message" }}
</button>
<p v-if="status === 'success'" class="text-green-600" role="status">
Thank you! Your message has been sent.
</p>
<p v-else-if="status === 'rate-limited'" class="text-yellow-600" role="alert">
Whoa, slow down! Try again in {{ retryAfter }} seconds.
</p>
<p v-else-if="status === 'error'" class="text-red-600" role="alert">
Something went wrong. Please try again.
</p>
</form>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
const ENDPOINT = "https://formigo.io/f/your-form";
const form = ref({
name: "",
email: "",
message: "",
_formigo_hp: "", // honeypot, must stay empty
_formigo_t: 0, // timestamp, set on mount
});
const loading = ref(false);
const status = ref(""); // "" | "success" | "error" | "rate-limited"
const retryAfter = ref(0);
// Stamp the render time so Formigo can reject instant bot submissions.
onMounted(() => {
form.value._formigo_t = Math.floor(Date.now() / 1000);
});
const handleSubmit = async () => {
loading.value = true;
status.value = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form.value),
});
if (response.ok) {
status.value = "success";
// Reset the form and re-stamp the timestamp.
form.value = {
name: "",
email: "",
message: "",
_formigo_hp: "",
_formigo_t: Math.floor(Date.now() / 1000),
};
} else if (response.status === 429) {
const data = await response.json().catch(() => ({}));
retryAfter.value = data.retry_after ?? 30;
status.value = "rate-limited";
} else {
status.value = "error";
}
} catch (error) {
// Network failure, CORS, offline, etc.
status.value = "error";
console.error("Formigo submission failed:", error);
} finally {
loading.value = false;
}
};
</script>
fetchComposition API, no extra dependencies
We use @submit.prevent in the template so Vue stops the default page reload for us. The
handler flips a loading ref (which disables the button), sends the whole reactive
form object as JSON, then branches on the response. Here is the core of it on its own:
const handleSubmit = async () => {
loading.value = true;
status.value = "";
try {
const response = await fetch("https://formigo.io/f/your-form", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form.value),
});
if (response.ok) {
status.value = "success"; // any 2xx means delivered
} else if (response.status === 429) {
const data = await response.json().catch(() => ({}));
retryAfter.value = data.retry_after ?? 30;
status.value = "rate-limited";
} else {
status.value = "error";
}
} catch (error) {
status.value = "error"; // network / CORS / offline
} finally {
loading.value = false;
}
};
response.ok is true for any 2xx, which is your "message delivered" signal.try/catch covers real network failures; the if/else covers HTTP errors Formigo returns.finally always re-enables the button, even when something explodes.Two fields keep the bots out
Formigo looks for two optional fields that quietly filter out automated junk without bothering real visitors:
_formigo_hp: a honeypot field hidden off-screen. Humans never see it,
so it should always be empty. Bots that fill it in get rejected.
_formigo_t: a timestamp (seconds) captured when the component mounts.
Submissions that arrive impossibly fast look like bots and get dropped.
In Vue 3 both are just reactive state. Set the timestamp in onMounted and keep the honeypot
empty in your initial ref:
import { ref, onMounted } from "vue";
const form = ref({
name: "",
email: "",
message: "",
_formigo_hp: "", // honeypot, leave empty
_formigo_t: 0, // timestamp, set below
});
onMounted(() => {
form.value._formigo_t = Math.floor(Date.now() / 1000);
});
Render the honeypot input visually hidden (off-screen, tabindex="-1",
aria-hidden) so assistive tech and real users skip it entirely.
Success, error, and 429 handled
The template uses v-if / v-else-if on a single status ref to show
exactly one message at a time. When Formigo rate-limits a burst of submissions it returns 429
with a retry_after value in seconds. Surface it so visitors know when to try again.
<p v-if="status === 'success'" class="text-green-600" role="status">
Thank you! Your message has been sent.
</p>
<p v-else-if="status === 'rate-limited'" class="text-yellow-600" role="alert">
Whoa, slow down! Try again in {{ retryAfter }} seconds.
</p>
<p v-else-if="status === 'error'" class="text-red-600" role="alert">
Something went wrong. Please try again.
</p>
The role="status" and role="alert" attributes mean screen readers announce the
result the moment it appears, a small touch that makes your Vue form usable for everyone.
Same endpoint, URLSearchParams body
Formigo accepts application/x-www-form-urlencoded just as happily as JSON. If you would
rather not stringify, build a URLSearchParams body from your reactive state and skip the
Content-Type header (the browser sets it for you):
const handleSubmit = async () => {
loading.value = true;
const body = new URLSearchParams(form.value);
const response = await fetch("https://formigo.io/f/your-form", {
method: "POST",
body, // browser sets the form-encoded Content-Type
});
status.value = response.ok ? "success" : "error";
loading.value = false;
};
Either format hits the same formigo.io/f/ endpoint and triggers the same email, Slack,
Discord, Sheets, and webhook deliveries. Pick whichever feels cleaner in your codebase.
Create a free Formigo form, grab your formigo.io/f/ endpoint, paste it into the component
above, and start receiving submissions in minutes. Free forever, no credit card.