Formclick

How to Create a Contact Form in Vue.js

Build a contact form in Vue 3 with the Composition API — v-model, validation, loading and success states — and receive submissions without writing a backend.

FFormclick Team3 min read
Close-up of JavaScript code on a computer screen with green text highlighting syntax.
Photo by Jorge Jesus on Pexels
On this page
  1. The component
  2. How it works
  3. Getting your access key
  4. Using FormData instead of JSON
  5. Nuxt
  6. Options API version

Vue makes forms pleasant: v-model keeps inputs and data in sync, and reactive state makes loading and success messages easy. In this tutorial you'll build a complete contact form with Vue 3's <script setup> syntax and send submissions to a form backend — no server code required.

The component

<!-- ContactForm.vue -->
<script setup>
import { reactive, ref, computed } from 'vue';

const form = reactive({ name: '', email: '', message: '', botcheck: false });
const status = ref('idle'); // idle | sending | sent | error
const errorMessage = ref('');
const touched = reactive({ name: false, email: false, message: false });

const errors = computed(() => ({
  name: form.name.trim().length < 2 ? 'Please enter your name.' : '',
  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) ? '' : 'Enter an email like name@example.com.',
  message: form.message.trim().length < 10 ? 'Please write at least 10 characters.' : '',
}));
const isValid = computed(() => !Object.values(errors.value).some(Boolean));

async function submit() {
  Object.keys(touched).forEach((k) => (touched[k] = true));
  if (!isValid.value) return;

  status.value = 'sending';
  try {
    const res = await fetch('https://api.formclick.io/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({
        access_key: import.meta.env.VITE_FORMCLICK_KEY,
        subject: 'New message from the website',
        ...form,
      }),
    });
    const result = await res.json();
    if (!res.ok || !result.success) throw new Error(result.message);
    status.value = 'sent';
    Object.assign(form, { name: '', email: '', message: '' });
    Object.keys(touched).forEach((k) => (touched[k] = false));
  } catch (e) {
    errorMessage.value = e.message || 'Something went wrong. Please try again.';
    status.value = 'error';
  }
}
</script>

<template>
  <form class="contact" novalidate @submit.prevent="submit">
    <div class="field">
      <label for="name">Name</label>
      <input id="name" v-model="form.name" autocomplete="name"
             :aria-invalid="touched.name && !!errors.name" aria-describedby="name-error"
             @blur="touched.name = true">
      <p v-if="touched.name && errors.name" id="name-error" class="error">{{ errors.name }}</p>
    </div>

    <div class="field">
      <label for="email">Email</label>
      <input id="email" v-model.trim="form.email" type="email" autocomplete="email"
             :aria-invalid="touched.email && !!errors.email" aria-describedby="email-error"
             @blur="touched.email = true">
      <p v-if="touched.email && errors.email" id="email-error" class="error">{{ errors.email }}</p>
    </div>

    <div class="field">
      <label for="message">Message</label>
      <textarea id="message" v-model="form.message" rows="5"
                :aria-invalid="touched.message && !!errors.message" aria-describedby="message-error"
                @blur="touched.message = true"></textarea>
      <p v-if="touched.message && errors.message" id="message-error" class="error">{{ errors.message }}</p>
    </div>

    <!-- Honeypot: hidden from people -->
    <input v-model="form.botcheck" type="checkbox" class="hp" tabindex="-1" autocomplete="off">

    <button type="submit" :disabled="status === 'sending'">
      {{ status === 'sending' ? 'Sending…' : 'Send message' }}
    </button>

    <p role="status" aria-live="polite">
      <span v-if="status === 'sent'" class="ok">Thanks! We'll reply soon.</span>
      <span v-else-if="status === 'error'" class="error">{{ errorMessage }}</span>
    </p>
  </form>
</template>

<style scoped>
.contact { max-width: 520px; display: grid; gap: 16px; }
.field { display: grid; gap: 6px; }
input, textarea { padding: 12px 14px; font: inherit; border: 1px solid #d4d4e0; border-radius: 10px; }
input[aria-invalid="true"], textarea[aria-invalid="true"] { border-color: #dc2626; }
.error { margin: 0; font-size: 14px; color: #b91c1c; }
.ok { color: #15803d; font-weight: 600; }
button { padding: 14px; font: inherit; font-weight: 700; color: #fff; background: #645cec; border: 0; border-radius: 10px; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
.hp { display: none; }
</style>

How it works

  • reactive form state with v-model on each field. v-model.trim strips spaces from the email.
  • computed errors recalculate automatically as the user types — but they're only shown after a field is touched (blurred) or the form is submitted, so nobody is scolded mid-word.
  • @submit.prevent stops the page reloading.
  • novalidate turns off the browser bubbles, because we show our own accessible messages.
  • Accessibility: aria-invalid, aria-describedby and a role="status" live region.

Getting your access key

  1. Create a free Formclick account and a form.
  2. Copy the access key into .env:
VITE_FORMCLICK_KEY=your-access-key

The key is safe in front-end code — it can only submit to that form. Every message is emailed to you and stored in your dashboard, with spam filtering included.

Using FormData instead of JSON

If your form includes file uploads, send FormData from the form element instead of JSON:

const res = await fetch('https://api.formclick.io/submit', {
  method: 'POST',
  headers: { Accept: 'application/json' },
  body: new FormData(formElement), // don't set Content-Type yourself
});

Give each input a name attribute and add a hidden access_key input for this approach.

Nuxt

The same component works in Nuxt 3. Read the key with useRuntimeConfig() (a public runtime config value), or post from a server route if you'd rather keep logic on the server.

Options API version

Prefer the Options API? Move form, status and touched into data(), errors into computed, and submit into methods — the template stays the same.

For framework-agnostic details, see How to Submit HTML Forms Using the Fetch API and How to Display Custom Form Validation Messages.

Found this useful? Share it.

All articles