Vue Contact Form Tutorial - Send Email Without a Backend

5 min read
Static Forms Team

Building a contact form in Vue.js usually means setting up a backend server to handle submissions and send emails. With Static Forms, you can skip the backend entirely. Your Vue component sends form data to the Static Forms API, which handles email delivery, spam protection, and submission storage for you.

In this tutorial, we will build a complete contact form using Vue 3's Composition API, handle loading and error states, and briefly cover the Vue 2 Options API approach.

Prerequisites

Vue 3 Composition API Contact Form

Create a ContactForm.vue component:

Vue
<script setup>
import { ref, reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  subject: '',
  message: '',
  honeypot: '',
})

const loading = ref(false)
const success = ref(false)
const error = ref('')

async function handleSubmit() {
  loading.value = true
  success.value = false
  error.value = ''

  try {
    const response = await fetch('https://api.staticforms.dev/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        apiKey: import.meta.env.VITE_STATIC_FORMS_KEY,
        name: form.name,
        email: form.email,
        subject: form.subject,
        message: form.message,
        honeypot: form.honeypot,
        replyTo: '@',
      }),
    })

    const result = await response.json()

    if (result.success) {
      success.value = true
      form.name = ''
      form.email = ''
      form.subject = ''
      form.message = ''
    } else {
      throw new Error(result.message || 'Submission failed')
    }
  } catch (err) {
    error.value = 'Something went wrong. Please try again.'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="contact-form-wrapper">
    <h2>Contact Us</h2>

    <div v-if="success" class="alert alert-success">
      Thank you! Your message has been sent successfully.
    </div>

    <div v-if="error" class="alert alert-error">
      {{ error }}
    </div>

    <form v-if="!success" @submit.prevent="handleSubmit">
      <div class="form-group">
        <label for="name">Name *</label>
        <input
          id="name"
          v-model="form.name"
          type="text"
          required
          placeholder="Your name"
        />
      </div>

      <div class="form-group">
        <label for="email">Email *</label>
        <input
          id="email"
          v-model="form.email"
          type="email"
          required
          placeholder="you@example.com"
        />
      </div>

      <div class="form-group">
        <label for="subject">Subject</label>
        <input
          id="subject"
          v-model="form.subject"
          type="text"
          placeholder="What is this about?"
        />
      </div>

      <div class="form-group">
        <label for="message">Message *</label>
        <textarea
          id="message"
          v-model="form.message"
          rows="5"
          required
          placeholder="Your message..."
        ></textarea>
      </div>

      <!-- Honeypot field for spam protection -->
      <input v-model="form.honeypot" type="text" style="display: none" />

      <button type="submit" :disabled="loading">
        {{ loading ? 'Sending...' : 'Send Message' }}
      </button>
    </form>
  </div>
</template>

<style scoped>
.contact-form-wrapper {
  max-width: 500px;
  margin: 0 auto;
  padding: 2rem;
}

.form-group {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.25rem;
  font-weight: 500;
}

input[type="text"],
input[type="email"],
textarea {
  width: 100%;
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  font-size: 1rem;
  box-sizing: border-box;
}

input:focus,
textarea:focus {
  outline: none;
  border-color: #3b82f6;
  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}

button {
  width: 100%;
  padding: 0.625rem 1rem;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 0.375rem;
  font-size: 1rem;
  cursor: pointer;
}

button:hover {
  background-color: #2563eb;
}

button:disabled {
  background-color: #9ca3af;
  cursor: not-allowed;
}

.alert {
  padding: 1rem;
  border-radius: 0.375rem;
  margin-bottom: 1rem;
}

.alert-success {
  background-color: #d1fae5;
  color: #065f46;
}

.alert-error {
  background-color: #fee2e2;
  color: #991b1b;
}
</style>

How It Works

  1. Reactive form state is managed with reactive(), which tracks all field values
  2. handleSubmit sends a JSON POST request to the Static Forms endpoint with your API key and form data
  3. Loading state disables the button and shows "Sending..." while the request is in flight
  4. Success state hides the form and displays a confirmation message
  5. Error handling catches network or API failures and shows an error message
  6. Honeypot field is hidden from users but catches bots that auto-fill all fields

Environment Variables

Store your API key in a .env file:

Plain Text
VITE_STATIC_FORMS_KEY=your-api-key-here

Vite exposes variables prefixed with VITE_ to the client. If you are using Nuxt, use NUXT_PUBLIC_STATIC_FORMS_KEY instead and access it via useRuntimeConfig().public.

Vue 2 Options API Version

If you are working with Vue 2, here is a condensed version using the Options API:

Vue
<script>
export default {
  data() {
    return {
      form: { name: '', email: '', subject: '', message: '', honeypot: '' },
      loading: false,
      success: false,
      error: '',
    }
  },
  methods: {
    async handleSubmit() {
      this.loading = true
      this.error = ''

      try {
        const response = await fetch('https://api.staticforms.dev/submit', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            apiKey: process.env.VUE_APP_STATIC_FORMS_KEY,
            ...this.form,
            replyTo: '@',
          }),
        })

        const result = await response.json()
        if (result.success) {
          this.success = true
          this.form = { name: '', email: '', subject: '', message: '', honeypot: '' }
        } else {
          throw new Error(result.message)
        }
      } catch {
        this.error = 'Something went wrong. Please try again.'
      } finally {
        this.loading = false
      }
    },
  },
}
</script>

The template is the same as the Vue 3 version. The main difference is that data is returned from a data() function and the submit handler is defined in methods.

Adding reCAPTCHA

For stronger spam protection beyond the honeypot, you can add reCAPTCHA to your form. Install a Vue reCAPTCHA package, get your site key from Google reCAPTCHA, and configure your secret key in your Static Forms CAPTCHA settings.

Include the g-recaptcha-response token in your submission body and Static Forms will verify it server-side.

For a detailed reCAPTCHA walkthrough, see our understanding reCAPTCHA guide.

Using with Nuxt

If you are building with Nuxt, check out our dedicated Nuxt.js integration guide for Nuxt-specific setup instructions and best practices.

Get Started

You now have everything you need to add a working contact form to your Vue application. Static Forms handles email delivery, spam protection, and submission storage so you do not need to build or maintain a backend.

Sign up for Static Forms to get your API key and start receiving form submissions for free.