Vue contact form
One single-file component, one ref for the request state, and no v-model anywhere. Vue does not need to bind every field to reactive state to submit a form — FormData reads the whole thing off the event target.
Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.
<script setup>
import { ref } from "vue";
const status = ref("idle");
const error = ref("");
async function submit(event) {
status.value = "sending";
try {
const res = await fetch("https://submit.formpost.ai/YOUR_FORM_ID", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target),
});
const json = await res.json();
if (json.success) {
event.target.reset();
status.value = "sent";
} else {
error.value = json.message;
status.value = "error";
}
} catch {
error.value = "Could not reach the server. Please try again.";
status.value = "error";
}
}
</script>
<template>
<!-- .prevent is what keeps the browser from navigating away -->
<form @submit.prevent="submit">
<h2>Contact us</h2>
<label for="name">Name</label>
<input id="name" name="name" required />
<label for="email">Email</label>
<input id="email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="4" required />
<button :disabled="status === 'sending'">
{{ status === "sending" ? "Sending..." : "Send message" }}
</button>
<p role="status" aria-live="polite" :class="status === 'error' ? 'bad' : 'ok'">
<span v-if="status === 'sent'">Thanks — we will be in touch.</span>
<span v-else-if="status === 'error'">{{ error }}</span>
</p>
</form>
</template>body {
margin: 0;
padding: 32px 16px;
background: #fbfbfc;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #18181b;
}
form {
display: flex;
flex-direction: column;
max-width: 420px;
margin: 0 auto;
}
h2 {
margin: 0 0 18px;
font-size: 20px;
}
label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: #3f3f46;
}
input,
textarea {
box-sizing: border-box;
font: inherit;
margin-bottom: 16px;
padding: 10px 12px;
color: #18181b;
background: #fff;
border: 1px solid #d4d4d8;
border-radius: 8px;
}
input:focus,
textarea:focus {
border-color: #2563eb;
outline: 2px solid rgba(37, 99, 235, 0.2);
}
textarea {
resize: vertical;
}
button {
padding: 11px 16px;
font: inherit;
font-weight: 600;
color: #fff;
background: #2563eb;
border: 0;
border-radius: 8px;
cursor: pointer;
}
button:disabled {
background: #93b4f5;
cursor: default;
}
/* The status line, shared by all three JavaScript versions. Keyed on the role
rather than a class, so it styles the element that is already announcing
itself to a screen reader. */
[role="status"] {
margin: 14px 0 0;
font-size: 14px;
text-align: center;
}
[role="status"]:empty {
display: none;
}
[role="status"].ok {
color: #16a34a;
}
[role="status"].bad {
color: #dc2626;
}submit.prevent is the whole trick
The .prevent modifier calls preventDefault() for you, which is what stops the browser navigating away. Without it the form posts natively and your handler's fetch races a page unload it is going to lose.
<form @submit.prevent="submit">Skip v-model on a contact form
Binding each input to a ref means three refs, three bindings and a reactive update on every keystroke, all to reconstruct at submit time what the DOM already had. new FormData(event.target) gives you the same object with none of that. Use v-model when a value has to drive something else on the page while it is being typed.
Nuxt needs no changes
This component works as-is in a Nuxt page or a Nuxt component. There is no server route to add and no runtime config to wire up — the submission goes straight from the browser to the endpoint, which is also why it works on a statically generated Nuxt site.
Announce the result
The status paragraph carries role="status" and aria-live="polite" so the outcome is read out without moving focus. Vue's conditional rendering swaps the text inside a node that is always present — if you v-if the whole paragraph away instead, some screen readers will miss the announcement because the live region did not exist when the change happened.
Questions about this contact form
- Do I need v-model for a Vue contact form?
- No. FormData reads the values off the form element at submit time. v-model earns its place when a field's value has to drive something live — a character count, a dependent dropdown — not for collecting values you only need once.
- Does this work in Nuxt?
- Unchanged. Drop the component into a page; there is no server route to add. It also works on a statically generated Nuxt site, since the browser posts directly to the endpoint.
- Why does my page still navigate away on submit?
- The .prevent modifier is missing, or you bound to @click on the button instead of @submit on the form. Bind the form's submit event — that is what fires for the Enter key too.
- Vue 2 or the Options API?
- The same code works — move the function into methods, swap the ref for a data property, and keep @submit.prevent exactly as it is. Only the setup syntax differs.
Related examples
React contact form
One component, four states, no form library and no field-by-field state.
JavaScript contact form
Vanilla fetch with a real status message and proper error handling.
HTML contact form
Pure HTML that sends email. No JavaScript, no PHP, nothing to install.
Or go back to all 21 contact form examples.
Give this form an endpoint
Formpost takes the submission, emails it to you and keeps a searchable copy. Free for 250 messages a month, with unlimited forms and no card.