Back to blog

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.

A close-up of code on a computer screen 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.