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-modelto onereactiveobject, - posts JSON with
fetchand 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_HEREVite 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.preventstops the browser from navigating, but only after its own constraint checks. Fields markedrequiredortype="email"block the submit handler until they are valid, so you get basic validation for free.- Spreading a
reactiveobject with...fieldsgives a plain object thatJSON.stringifyhandles directly. Resetting withObject.assign(fields, initial)keeps the same reactive object, sov-modelbindings stay connected; reassigningfieldswould break them. - The honeypot is bound to
false, so the JSON always contains"botcheck": falsefor real visitors. SilentForms treatsfalseas empty, so only a ticked box marks a submission as spam. See Honeypot. v-model.trimstrips 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#
- Run
npm run dev, fill in the form and submit. The button shows "Sending…", the fields clear and the email arrives. - 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 withVITE_or the dev server was not restarted. - Run
npm run buildandnpm run previewto confirm the production build behaves the same.
Next steps#
- Prefer to render the form with Nuxt? See Nuxt.
- Control the email subject line: Email subject.
- Accept submissions from your domain only: Restrict to your domain.