Vue contact form with script setup
A Vue contact form gets one thing for free that most stacks make you write by hand. @submit.prevent cancels the navigation and calls your handler, so the handler stays about sending:
<script setup>
import { ref } from "vue";
const ENDPOINT = "https://api.formroute.dev/f/site_xyz";
const status = ref("idle");
async function send(event) {
status.value = "submitting";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
body: new FormData(event.target),
});
status.value = response.ok ? "success" : "error";
} catch {
status.value = "error";
}
}
</script>
<template>
<p v-if="status === 'success'">Thanks, we got it.</p>
<form v-else @submit.prevent="send">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button :disabled="status === 'submitting'">
{{ status === "submitting" ? "Sending…" : "Send" }}
</button>
<p v-if="status === 'error'" role="alert">Something went wrong. Try again.</p>
</form>
</template>
One ref, no v-model anywhere. new FormData(event.target) reads the fields out of the DOM at submit time, keyed by their name attribute.
Vue 3 is now the only major framework that still ships event modifiers. Svelte removed them in version 5, which is why a Svelte contact form copied from an older tutorial silently stops preventing anything.
When v-model earns its place
v-model is the most pleasant thing in Vue, which is exactly why it ends up on fields that never needed it. Bind a field when something else has to react while the user types:
<script setup>
import { ref, computed } from "vue";
const message = ref("");
const tooShort = computed(() => message.value.length > 0 && message.value.length < 10);
</script>
<template>
<textarea name="message" v-model="message" maxlength="500" required></textarea>
<p v-if="tooShort">A few more words would help.</p>
<small>{{ message.length }} / 500</small>
</template>
A computed is the right tool for validation state: it recalculates only when its dependency changes, and it reads as the rule it encodes. Prefer it to a watch that writes into another ref.
Note the field keeps its name, so FormData still picks it up. Binding a value and submitting the form are independent decisions, and a field with no live feedback attached costs a ref and buys nothing.
Disable the button, do not hide the form
The failure case is where hand-rolled forms go wrong. If a request fails and you unmount the form or reset the fields, the visitor retypes everything, and most of them leave instead.
Keep the form mounted on error, which is what the v-if / v-else above does: it swaps the form out on success only. With unbound fields you get the rest for free, because the DOM still holds what they typed. The React version has the full four-state breakdown, and it applies here unchanged.
What Nuxt changes
On Nuxt with server-side rendering you have a server, so you could route the submission through server/api/contact.post.ts. Ask what that hop is for first. Hiding the endpoint is not a reason, because a form endpoint is public by design and already sits in the HTML of the page. Transforming the payload is a reason, and there is usually no payload to transform.
On a statically generated Nuxt site the question does not arise: there is no server in the output, and the form posts to the endpoint directly.
| Setup | Server available | What the form does |
|---|---|---|
| Nuxt with SSR | Yes | Can forward through a server route, if there is a reason |
Nuxt, nuxi generate |
No | Posts straight to the endpoint |
| Vue without Nuxt | No | Posts straight to the endpoint |
The computed above and required are both courtesies to the honest visitor: they vanish for anything that is not your component, so the rules have to hold at the endpoint instead. What a form backend does with them covers that half.
The template is not the hard part
Everything above gets a clean payload out of the browser in about twenty lines. The part that decides whether a contact form works is what happens next: whether the message survives spam filtering, whether it reaches someone, and whether you can find it again in a month.
FormRoute is what picks the payload up on the other side. It is not open to public signups yet. Ask for an invite.