FormData beats v-model here
A contact form does not need per-keystroke reactivity. Reading the form element once on submit means no refs to declare, no reset logic, and native required validation you get for free from the browser.
integrations · framework
A contact form is the one piece of a Vue app that has no business being a component with state. It has three fields, it runs once, and then the user leaves. Yet the usual advice is to stand up an Express route or a serverless function whose entire job is to read three strings and forward them to an email API.
Point the form at a mailcontact endpoint instead. You can do it with no JavaScript at all, or bind @submit.prevent and post the FormData yourself if you want to keep the user on the page and show your own success state. Either way there is no server in your project.
recommended setup
Add this HTML to your site, or use the form builder. Replace the form link with yours. The hosted script injects the hidden check, handles sending and automatic verification, and preserves the draft on errors. You control the HTML and CSS. Free forms display a visible “Processed by mailcontact.app” link; Pro and Plus do not. Feedback follows the browser’s language; set data-mailcontact-language="fr" on the form to force French.
<form action="https://mailcontact.app/api/forms/your-form-id" method="POST" data-mailcontact-form>
<fieldset>
<legend>Contact us</legend>
<label>
Name (optional)
<input name="name" type="text" autocomplete="name" maxlength="200" />
</label>
<label>
Email
<input name="email" type="email" autocomplete="email" maxlength="320" required />
</label>
<label>
Message
<textarea name="message" rows="5" maxlength="5000" required></textarea>
</label>
<noscript>
<div aria-hidden="true" style="position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;">
<label>Website
<input name="company_website" type="text" tabindex="-1" autocomplete="off" />
</label>
</div>
</noscript>
<button type="submit">Send message</button>
</fieldset>
<p data-contact-status role="status" aria-live="polite" aria-atomic="true"></p>
</form>
<script defer src="https://mailcontact.app/forms/v1.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>For a component with its own submit handler, load the same script and use MailContact.prepare(form) and MailContact.send() instead of the automatic marker. See the custom interface reference for loading, feedback and Free attribution.
the snippet
This example posts directly to the API. Keep the required company_website field present and empty. Native posts open JSON on success or a verification page when needed. Custom scripts without verification support send suspicious messages to Spam for review; strong spam is also held there without notifications or quota usage. A success response confirms reception, including review, or a silent discard of a filled check. See spam protection for the full behavior.
<script setup>
import { ref } from "vue";
const sent = ref(false);
const sending = ref(false);
async function submit(event) {
sending.value = true;
await fetch("https://mailcontact.app/api/forms/your-form-id", {
method: "POST",
body: new FormData(event.target),
});
sending.value = false;
sent.value = true;
}
</script>
<template>
<p v-if="sent">Thanks. We'll be in touch.</p>
<form v-else @submit.prevent="submit">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="[email protected]" required />
<textarea name="message" placeholder="What's on your mind?" required />
<input name="company_website" type="text" tabindex="-1" autocomplete="off"
style="position:absolute;left:-10000px" aria-hidden="true" />
<button :disabled="sending">{{ sending ? "Sending..." : "Send" }}</button>
</form>
</template>how it works
One inbox per project. You get a form endpoint and a project email address immediately, before you write any component.
Paste the block above and swap the endpoint id. It uses new FormData(event.target), so the field names in your template are the field names that arrive. No v-model wiring, no reactive object to keep in sync.
Add a Discord, Slack, email, or webhook channel. Delivered messages reach you where you already are, instead of sitting in a database you have to remember to check.
why mailcontact
A contact form does not need per-keystroke reactivity. Reading the form element once on submit means no refs to declare, no reset logic, and native required validation you get for free from the browser.
No SSR, no Nitro, no adapter. This is the same code whether you are on Vite, Vue CLI, or a Vue island inside another site, because the only runtime dependency is fetch.
Rolling your own means choosing where messages go, then building a screen to read them. An inbox per project is that screen, and it also takes email at [email protected].
faq
Not on its own. Browsers cannot speak SMTP, and any mail API key you put in a Vue component ships to the browser for anyone to read and abuse. The form has to post to a server somewhere; the question is only whether it is a server you maintain. Posting to an endpoint means it is not.
No. Drop the script block and give the form a plain action attribute pointing at your endpoint and it works with zero JavaScript, which is a good fit for a mostly static marketing page. Use @submit.prevent when you want to stay on the page and render your own success state.
Keep the browser's. required, type="email" and pattern all still apply because this is a real form element, not a synthetic one. For custom messages, check event.target.checkValidity() before the fetch and render your own copy from there.
Yes. Nothing here depends on script setup or the Composition API: it is a submit handler that calls fetch with a FormData. Move submit into methods and sent into data and the same code runs on the Options API, including Vue 2 with the same DOM event.
related
Forms and a real email address, together, with notifications where you live. Start with a free account.
Create your free formNo credit card needed. Free includes one project and 50 form messages per month.