# A Vue contact form that sends email

A Vue 3 component that posts your contact form to an endpoint, which stores each submission and emails you. Works in Vue and Nuxt without a server route or email API.

Updated Oct 29, 2026.

Vue can bind a form, validate it and show a success message, but it can't send email from the browser. This component posts the fields to a Nisuform endpoint, which filters spam, saves the submission and emails you. It works in any Vue 3 app and in Nuxt, including a static Nuxt site.

## The component

```vue title="ContactForm.vue"
<script setup lang="ts">
import { reactive, ref } from 'vue'

const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
const shownAt = Date.now()
const form = reactive({ name: '', email: '', message: '', _gotcha: '' })
const status = ref<'idle' | 'sending' | 'sent' | 'error'>('idle')
const error = ref('')

async function submit() {
  status.value = 'sending'
  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ ...form, _elapsed: String(Date.now() - shownAt) }),
    })
    const result = await response.json()
    if (result.ok) {
      status.value = 'sent'
    } else {
      error.value = result.message
      status.value = 'error'
    }
  } catch {
    error.value = 'Something went wrong. Please try again.'
    status.value = 'error'
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks! We got your message.</p>
  <form v-else @submit.prevent="submit">
    <label>
      Name
      <input v-model="form.name" name="name" type="text" autocomplete="name" required />
    </label>
    <label>
      Email
      <input v-model="form.email" name="email" type="email" autocomplete="email" required />
    </label>
    <label>
      Message
      <textarea v-model="form.message" name="message" rows="5" required />
    </label>
    <input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" style="display: none" />
    <button type="submit" :disabled="status === 'sending'">
      {{ status === 'sending' ? 'Sending...' : 'Send' }}
    </button>
    <p v-if="status === 'error'" role="alert">{{ error }}</p>
  </form>
</template>
```

The reactive `form` object becomes the JSON body, and each key is the name its answer is saved under. Nothing resets `form` until the message goes through, so a failed submission keeps what the visitor typed.

Two fields feed the spam check. `_gotcha` is hidden, so only bots fill it in. `_elapsed` is how long the form was open, and anything sent in under two seconds is dropped.

[Create a form](https://app.nisuform.com) and paste the key from its **Setup** tab into `endpoint`. The tab also has this component with your own field names under **Code**.



## Sending files

On Pro, a submission can carry up to 5 images or PDFs. JSON can't hold files, so send `FormData` built from the form element instead:

```ts title="Submitting with files"
async function submit(event: Event) {
  const data = new FormData(event.target as HTMLFormElement)
  data.set('_elapsed', String(Date.now() - shownAt))
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { Accept: 'application/json' },
    body: data,
  })
  const result = await response.json()
  error.value = result.ok ? '' : result.message
  status.value = result.ok ? 'sent' : 'error'
}
```

Give the file input a `name`, like `<input type="file" name="attachment" />`, and leave `Content-Type` unset so the browser can add the multipart boundary.

## In Nuxt

Put the file in `components/`, or `app/components/` in Nuxt 4, and Nuxt auto-imports it as `<ContactForm />`. The submit runs in the browser, so `server/api` stays empty and `nuxt generate` gives you a static site with a working form.

If you prefer `$fetch`, remember that it throws on a rejected submission. Catch the error and show `error.data.message`. To use a different endpoint per environment, put it in `runtimeConfig.public` and read it with `useRuntimeConfig()`. The [Nuxt guide](/docs/frameworks/nuxt) has a full Nuxt version.

## Guides

- [Vue form component](https://nisuform.com/docs/frameworks/vue)
- [Nuxt forms](https://nisuform.com/docs/frameworks/nuxt)

## Frequently asked questions

### Can a Vue app send email by itself?

No. Vue runs in the browser, and browsers can't send email. The form has to reach something that can, either your own server with an email provider or a form backend endpoint that stores the submission and emails you.

### Does it work with Nuxt?

Yes, in Nuxt 3 and Nuxt 4. The form posts from the browser, so you don't need a server route, and it keeps working on a static site built with nuxt generate.

### Should I use $fetch or fetch in Nuxt?

Either works. $fetch throws on any response that isn't a 2xx, so wrap it in try and catch and read the message from error.data.message. Plain fetch returns every response, which makes the message from a rejected submission easier to show.

### Can I use VeeValidate or FormKit?

Yes. Let the library handle fields and validation, then send its values to your endpoint from its submit handler with Accept set to application/json.

### Is the form key safe in my Vue code?

Yes. It only accepts new submissions. Nobody can read your submissions or change the form with it. Add your domains to the form's allowed websites to stop other sites from posting to it.
