Vue 3 Guide

Vue Form Submission to Email or API

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.

1. Point at your Formigo endpoint

One URL, that's the whole backend

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.

2. The full Vue 3 component

A complete Vue contact form

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>

3. How the submit handler works

Async submit with fetch

Composition 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.
  • The try/catch covers real network failures; the if/else covers HTTP errors Formigo returns.
  • finally always re-enables the button, even when something explodes.

4. Built-in spam protection

Honeypot + timestamp, zero CAPTCHAs

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.

5. Errors and rate limits

Friendly feedback for every outcome

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.

6. Prefer form-encoded? That works too

A non-JSON alternative

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.

Ready to wire up your Vue form?

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.