August 28, 2026 · NBForms Team
Vue Contact Form Tutorial: A Backend in Nine Lines of Script
A Vue 3 contact form using nothing but script setup and a fetch call — plus the one thing the standard version of this snippet quietly leaves out.
A Vue contact form tutorial usually spends most of its length on the form itself — labels,
validation, styling — and treats where the data goes as a footnote. Here it's the reverse: the
form is nine lines of template, the submit logic is nine lines of script, and the more useful
question is what that second nine lines actually does and doesn't handle. Both halves fit in a
single .vue file, which is worth noting on its own — nothing here needs a second component, a
composable, or a store just to submit three fields.
Photo by Rahul Mishra on Unsplash
The whole component
<template>
<p v-if="success">Thanks — we'll be in touch!</p>
<form v-else @submit.prevent="submit">
<input type="hidden" name="_token" value="YOUR_TOKEN" />
<label>Name</label>
<input type="text" name="name" required />
<label>Email</label>
<input type="email" name="email" required />
<label>Message</label>
<textarea name="message" required></textarea>
<button type="submit" :disabled="sending">
{{ sending ? 'Sending…' : 'Send' }}
</button>
</form>
</template>
<script setup>
import { ref } from 'vue'
const sending = ref(false)
const success = ref(false)
async function submit(e) {
sending.value = true
const res = await fetch('https://api.nbforms.com', {
method: 'POST',
body: new FormData(e.target),
})
success.value = res.ok
sending.value = false
}
</script>
The _token hidden field ties the request to a specific NBForms form, generated once from that
form's setup page; every other field name becomes a column in the submissions dashboard the
moment it first shows up in a request. v-if="success" swaps the entire form out for a thank-you
message once submit resolves successfully — no router, no separate success page, no extra
component.
Declarative event handling, not imperative
@submit.prevent="submit" does two things a plain addEventListener version would need two
separate lines for: it wires the handler and calls preventDefault() on the native submit event,
stopping the browser's default full-page POST, before submit ever runs. Vue's event modifiers
exist specifically to pull that kind of boilerplate out of the function body and into the
template, where it's visible next to the event it modifies rather than buried in the first line
of a handler.
Reactivity without a setter function
sending and success are refs, not state-plus-setter pairs — sending.value = true is the
entire update, with no equivalent of calling a function to schedule a re-render. Vue's reactivity
system tracks that sending.value was read in the template (via :disabled and the button's
text) and re-renders the parts that depend on it automatically the moment the value changes.
There's nothing to import beyond ref itself, and nothing to name twice the way a value and its
setter usually are.
The gap the standard snippet leaves out
Worth being honest about the exact code above: if res.ok is false, success.value simply stays
false, sending.value goes back to false, and the form reappears with no indication anything
went wrong. That's a real gap, not a simplification for the sake of the example — a rejected
submission (a missing required field the server catches, a token typo) fails silently. A version
that actually surfaces that:
const sending = ref(false)
const success = ref(false)
const error = ref(false)
async function submit(e) {
sending.value = true
error.value = false
const res = await fetch('https://api.nbforms.com', {
method: 'POST',
body: new FormData(e.target),
})
success.value = res.ok
error.value = !res.ok
sending.value = false
}
with a <p v-if="error">Something went wrong — try again.</p> alongside the form. Three refs
instead of two, and the form actually tells a visitor when a submission didn't go through instead
of quietly resetting itself.
Composition API or Options API, same request
script setup is the Composition API in its most compact form, but nothing about talking to
NBForms requires it. The same component written against the Options API looks like this instead:
<script>
export default {
data() {
return { sending: false, success: false }
},
methods: {
async submit(e) {
this.sending = true
const res = await fetch('https://api.nbforms.com', {
method: 'POST',
body: new FormData(e.target),
})
this.success = res.ok
this.sending = false
},
},
}
</script>
Same endpoint, same field names, same res.ok check — this.sending replaces sending.value as
the way to read and write the flag, and data() replaces the two ref() calls, but the request
itself doesn't know or care which API assembled the component around it. A Vue 2 project on the
Options API needs nothing from this pattern that Vue 2 doesn't already have.
Adding TypeScript
<script setup lang="ts"> changes one line in a way that's easy to miss until the type checker
catches it: e.target on a native Event is typed as EventTarget | null, not
HTMLFormElement, so new FormData(e.target) won't compile as-is under strict mode.
refs need the same small adjustment — ref<boolean>(false) instead of a bare ref(false) is
usually inferred correctly anyway, but the event target isn't:
async function submit(e: Event) {
sending.value = true
const form = e.target as HTMLFormElement
const res = await fetch('https://api.nbforms.com', {
method: 'POST',
body: new FormData(form),
})
success.value = res.ok
sending.value = false
}
The cast is safe here specifically because @submit only ever fires on the <form> element
itself, never on a child — TypeScript just has no way to know that from the event's own type.
Not the same as Nuxt
This is a plain Vue example — Vite or Vue CLI, fetch as the only option, because that's what's
available without a framework layered on top. A Nuxt project has $fetch and useFetch as
alternatives to raw fetch, and auto-imports that remove the explicit import { ref } from 'vue'
line entirely. The underlying request is identical either way; only the surrounding syntax
changes, and conflating the two leads to copy-pasting a line Nuxt would have handled
automatically, or missing one plain Vue actually needs.
One file, imported wherever it's needed
Everything above lives in a single .vue file — ContactForm.vue — with the markup, the script,
and (if styling is added) the CSS all in one place, no separate files to keep in sync. Dropping
it into a page elsewhere in the app is a plain import and a tag:
<script setup>
import ContactForm from '@/components/ContactForm.vue'
</script>
<template>
<ContactForm />
</template>
No props are required because nothing in the component depends on data from its parent — the token and field list are already inside it — so the same import works identically on a dedicated contact page, inside a modal, or in a footer, without threading configuration through each place it's used.
What it doesn't do
Same scope as any lightweight form backend: this receives a submission, filters it for spam automatically, stores it, and sends an email alert. No payment processing, no multi-step form logic, no automatic reply beyond the alert and whatever the component shows on success. A contact form doesn't need more than that; something that has to run its own logic per submission still needs real server code somewhere, Vue component or otherwise.
The Vue snippet reference has this code with no prose around it. For
the fetch-specific redirect gotcha that applies here exactly as it does in any framework using
fetch directly, see the Next.js form backend post — nothing about
that gotcha is React-specific, it follows from fetch itself, so the same advice holds for the
component above unchanged.
Frequently asked questions
Does this work with the Options API instead of script setup?
Yes — the same fetch call works inside a methods entry, reading e.target the same way; script setup just needs fewer lines to wire it up. Vue 2 projects on the Options API work identically, since none of this depends on Vue 3-only APIs.
Do I need Vuex or Pinia to track the sending/success state?
No. Two local refs are enough for a single form — a store is for state shared across components, and nothing about submitting a contact form needs to be visible outside the component itself.
Why does the button need :disabled="sending" if the form already switches away on success?
To stop a double submission during the request itself — sending only ever becomes true for the fraction of a second between clicking submit and the response coming back, and that's exactly the window a second accidental click would land in.
Is this the same as using Nuxt's useFetch or $fetch?
Not quite the same code, though the same idea — this example is for plain Vue (Vite or Vue CLI), where fetch is the only option available. A Nuxt project could use $fetch instead for the same request with slightly different syntax, covered in its own guide.
Can the form be submitted more than once, or does it lock after one success?
As written, it locks — success replaces the form permanently, with no way back without a page reload. That's fine for a one-time contact form; a form meant to accept repeat submissions from the same visitor would need a button that resets success back to false.