Vue

A Vue 3 contact form component that posts to SilentForms and shows success and error states. No server needed.

A Vue 3 single-page app, whether it is built with Vite or create-vue, ships as static files. Its contact form still needs somewhere to send messages, and that is the part SilentForms covers: the component posts to the submit endpoint and you get an email for every submission.

What you'll build#

A ContactForm.vue single-file component using <script setup> that:

  • binds the fields with v-model to one reactive object,
  • posts JSON with fetch and shows sending, success and error states,
  • resets itself after a successful submission,
  • keeps the hidden honeypot field.

If your site uses Nuxt, the Nuxt guide covers runtime config and $fetch instead.

1. Get an access key and add it to .env.local#

Get a key on the access key page, then create .env.local next to package.json:

VITE_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HERE

Vite exposes only VITE_-prefixed variables to your components, through import.meta.env, and writes the value into the bundle at build time. That is fine here: the key only says whose inbox receives the submission. Restart the dev server after changing the file and set the variable in your host's build environment too.

2. The component#

Create src/components/ContactForm.vue:

<script setup>
import { reactive } from 'vue';

const initial = { name: '', email: '', message: '', botcheck: false };
const fields = reactive({ ...initial });
const state = reactive({ status: 'idle', message: '' }); // status: idle | sending | sent | error

async function submit() {
  state.status = 'sending';
  state.message = '';
  try {
    const response = await fetch('https://silentforms.com/api/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ ...fields, accessKey: import.meta.env.VITE_SILENTFORMS_KEY }),
    });
    const result = await response.json();
    if (!response.ok) throw new Error(result.message);
    Object.assign(fields, initial);
    state.status = 'sent';
    state.message = 'Thanks! Your message has been sent.';
  } catch (error) {
    state.status = 'error';
    state.message = error.message || 'Something went wrong. Please try again.';
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <label for="cf-name">Name</label>
    <input id="cf-name" v-model.trim="fields.name" type="text" autocomplete="name" required />

    <label for="cf-email">Email</label>
    <input id="cf-email" v-model.trim="fields.email" type="email" autocomplete="email" required />

    <label for="cf-message">Message</label>
    <textarea id="cf-message" v-model="fields.message" rows="5" required></textarea>

    <!-- Honeypot: hidden from people, ticked by bots -->
    <input v-model="fields.botcheck" type="checkbox" style="display: none" tabindex="-1" autocomplete="off" />

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

    <p :role="state.status === 'error' ? 'alert' : 'status'">{{ state.message }}</p>
  </form>
</template>

Use it from any page or from App.vue:

<script setup>
import ContactForm from './components/ContactForm.vue';
</script>

<template>
  <main>
    <h1>Contact</h1>
    <ContactForm />
  </main>
</template>

Notes on the Vue side#

  • @submit.prevent stops the browser from navigating, but only after its own constraint checks. Fields marked required or type="email" block the submit handler until they are valid, so you get basic validation for free.
  • Spreading a reactive object with ...fields gives a plain object that JSON.stringify handles directly. Resetting with Object.assign(fields, initial) keeps the same reactive object, so v-model bindings stay connected; reassigning fields would break them.
  • The honeypot is bound to false, so the JSON always contains "botcheck": false for real visitors. SilentForms treats false as empty, so only a ticked box marks a submission as spam. See Honeypot.
  • v-model.trim strips stray spaces from the name and email before they are sent. Leave the message untrimmed if line breaks at the start matter.

Without JavaScript#

If the form is rendered into server-generated or prerendered HTML and you want it to work before Vue hydrates, give the <form> a real action="https://silentforms.com/api/submit" and method="POST", add a hidden accessKey input, and give every field a name attribute. v-model does not set name, and browsers only send named fields. With JavaScript, @submit.prevent still takes over; without it, the browser posts the form and follows the redirect. See Redirect after submit.

Testing#

  1. Run npm run dev, fill in the form and submit. The button shows "Sending…", the fields clear and the email arrives.
  2. In the browser's Network tab, check that the request body includes accessKey. If the response says "Access key is required.", the variable is not prefixed with VITE_ or the dev server was not restarted.
  3. Run npm run build and npm run preview to confirm the production build behaves the same.

Next steps#