> ## Documentation Index
> Fetch the complete documentation index at: https://formcarry.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Vue

> Vue sends a form to formcarry with one fetch call from the component.

It binds the fields to refs with `v-model`, posts them as JSON and shows formcarry's answer, and the same request carries files, a spam blocker token and validation errors.

The form's Setup page in the dashboard opens a CodeSandbox with every example on this page pointed at your endpoint. Its components put `setup()` inside `export default`; the snippets here use `<script setup>`, and the refs and the requests are the same.

## Prerequisites

Before you start, you need:

* A formcarry account. [Sign up](https://app.formcarry.com/register) is free.
* A form in the [dashboard](https://app.formcarry.com). Its endpoint is on the form's Setup page. The examples use `https://formcarry.com/s/AbC123xyz`; put yours in its place.

## The component

To send a form from Vue, bind each field to a ref with `v-model`, post them as JSON, and send `Accept: application/json` so the answer comes back as JSON:

```vue theme={null}
<script setup>
import { ref } from "vue"

const name = ref("")
const email = ref("")
const message = ref("")
const status = ref("idle")
const error = ref("")

async function submit() {
  status.value = "sending"
  error.value = ""
  try {
    const res = await fetch("https://formcarry.com/s/AbC123xyz", {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({ name: name.value, email: email.value, message: message.value }),
    })
    const data = await res.json()
    if (data.code === 200) {
      status.value = "sent"
    } else {
      status.value = "error"
      error.value = data.message
    }
  } catch (err) {
    status.value = "error"
    error.value = err.message
  }
}
</script>

<template>
  <p v-if="status === 'sent'">Thanks, we got your message.</p>
  <form v-else @submit.prevent="submit">
    <input v-model="name" placeholder="Name" />
    <input v-model="email" type="email" placeholder="Email" />
    <textarea v-model="message" placeholder="Message"></textarea>
    <button type="submit" :disabled="status === 'sending'">Send</button>
    <p v-if="error">{{ error }}</p>
  </form>
</template>
```

The keys of the JSON body are the field names formcarry stores.

The `email` key is the visitor's address, so it becomes the reply to address of your notification and the recipient of the auto response. A stored submission answers with `code: 200`. A refused one answers with the reason in `message`, see [What every form needs](/docs/what-every-form-needs).

Only a request that never reaches formcarry, such as a dropped connection, ends in the `catch`. Use `@submit.prevent` rather than `@submit`, otherwise the browser reloads the page before the answer arrives.

## Files

To send files, keep the input's `File` objects in a ref, append each to a `FormData` and leave the content type to the browser:

```vue theme={null}
<script setup>
import { ref } from "vue"

const email = ref("")
const file = ref(null)
const files = ref([])

async function submit() {
  const body = new FormData()
  body.append("email", email.value)
  if (file.value) body.append("file", file.value)
  files.value.forEach((f, i) => body.append(`files-${i}`, f))

  const res = await fetch("https://formcarry.com/s/AbC123xyz", {
    method: "POST",
    headers: { "Accept": "application/json" },
    body,
  })
  const data = await res.json()
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="email" type="email" />
    <input type="file" @change="file = $event.target.files[0]" />
    <input type="file" multiple @change="files = Array.from($event.target.files)" />
    <button type="submit">Send</button>
  </form>
</template>
```

Each `append` name is the field name formcarry stores, so a `multiple` input's files each get their own. Send `Accept` and nothing else rather than adding `Content-Type` yourself, otherwise the multipart boundary is missing and the upload fails.

To take files from a dropzone, put the `File` objects that `vue3-dropzone` passes to `onDrop` in `files`:

```vue theme={null}
<script setup>
import { ref } from "vue"
import { useDropzone } from "vue3-dropzone"

const files = ref([])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
  onDrop: (accepted) => { files.value = accepted },
})
</script>

<template>
  <div v-bind="getRootProps()">
    <input v-bind="getInputProps()" />
    <p v-if="isDragActive">Drop the files here.</p>
    <p v-else>Drop files here, or click to choose them.</p>
  </div>
</template>
```

To stay with JSON for small files, read each one with `FileReader` and send its data URL under its own key, never an array:

```vue theme={null}
<script setup>
import { ref } from "vue"

const email = ref("")
const file = ref(null)
const files = ref([])

const toDataUrl = (file) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = reject
    reader.readAsDataURL(file)
  })

async function submit() {
  const body = { email: email.value }
  if (file.value) body.attachment = await toDataUrl(file.value)
  for (const [i, f] of files.value.entries()) body[`files-${i}`] = await toDataUrl(f)

  const res = await fetch("https://formcarry.com/s/AbC123xyz", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  })
  const data = await res.json()
}
</script>
```

Use data URLs for small files. Using them with larger files bloats the request by about a third and reaches the 50 MB limit sooner.

<Note>
  Files are stored on paid plans only. Free plans store the rest of the submission without them.
</Note>

## Spam blocker

To add a challenge, render it in the component, send its token in the body under the name formcarry reads, and paste the secret key into the form's settings under Form Security:

| Challenge    | Package the demo uses     | Token field             |
| ------------ | ------------------------- | ----------------------- |
| reCAPTCHA v2 | `vue3-recaptcha2`         | `g-recaptcha-response`  |
| reCAPTCHA v3 | `vue-recaptcha-v3`        | `g-recaptcha-response`  |
| hCaptcha     | `@hcaptcha/vue3-hcaptcha` | `h-captcha-response`    |
| Turnstile    | `vue-turnstile`           | `cf-turnstile-response` |

To use reCAPTCHA v2, render the widget and keep the token from its `verify` event in a ref:

```vue theme={null}
<script setup>
import { ref } from "vue"
import VueRecaptcha from "vue3-recaptcha2"

const SITE_KEY = "your reCAPTCHA site key"
const token = ref("")
</script>

<template>
  <VueRecaptcha :sitekey="SITE_KEY" @verify="token = $event" @expire="token = ''" />
</template>
```

Clear the token on `expire` rather than keeping it, otherwise formcarry answers `403` to the stale one.

To use reCAPTCHA v3, register the plugin with your site key in `main.js`:

```js theme={null}
import { VueReCaptcha } from "vue-recaptcha-v3"

app.use(VueReCaptcha, { siteKey: "your reCAPTCHA v3 site key" })
```

Then ask for a token when the visitor submits and pass it to `send` below; there is no widget:

```vue theme={null}
<script setup>
import { ref } from "vue"
import { useReCaptcha } from "vue-recaptcha-v3"

const token = ref("")
const { executeRecaptcha, recaptchaLoaded } = useReCaptcha()

async function submit() {
  await recaptchaLoaded()
  token.value = await executeRecaptcha("form")
  await send()
}
</script>
```

To use hCaptcha, keep the token from its `verify` event:

```vue theme={null}
<script setup>
import { ref } from "vue"
import VueHcaptcha from "@hcaptcha/vue3-hcaptcha"

const SITE_KEY = "your hCaptcha site key"
const token = ref("")
</script>

<template>
  <VueHcaptcha :sitekey="SITE_KEY" @verify="token = $event" />
</template>
```

To use Turnstile, bind the token with `v-model`:

```vue theme={null}
<script setup>
import { ref } from "vue"
import VueTurnstile from "vue-turnstile"

const SITE_KEY = "your Turnstile site key"
const token = ref("")
</script>

<template>
  <VueTurnstile :site-key="SITE_KEY" v-model="token" />
</template>
```

To send the token, put it in the body under the name from the table, with a guard for an empty one:

```js theme={null}
async function send() {
  if (!token.value) {
    error.value = "Complete the challenge first."
    return
  }
  const res = await fetch("https://formcarry.com/s/AbC123xyz", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ name: name.value, email: email.value, message: message.value, "g-recaptcha-response": token.value }),
  })
  const data = await res.json()
}
```

Refuse to send while the token is empty rather than posting anyway, otherwise formcarry answers `403` for reCAPTCHA or `400` for the others and the visitor sees an error they could have avoided. Add `localhost` to the challenge's allowed domains while you test.

## Validation errors

A `422` carries `errors`, one entry per failing field, each with a `message`. To show each one next to its field, keep them in a ref keyed by field name:

```vue theme={null}
<script setup>
import { ref } from "vue"

const email = ref("")
const fieldErrors = ref({})

async function submit() {
  fieldErrors.value = {}
  const res = await fetch("https://formcarry.com/s/AbC123xyz", {
    method: "POST",
    headers: { "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({ email: email.value }),
  })
  const data = await res.json()
  if (data.code === 422) {
    const byField = {}
    for (const field in data.errors) byField[field] = data.errors[field].message
    fieldErrors.value = byField
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="email" type="email" :class="{ invalid: fieldErrors.email }" />
    <p v-if="fieldErrors.email">{{ fieldErrors.email }}</p>
    <button type="submit">Send</button>
  </form>
</template>
```

The keys of `errors` are your field names, so `fieldErrors.email` is the message for the `email` field.

To show the answer in a toast instead, register `vue-toastification` in `main.js` with `app.use(Toast)` and read `title` and `message` from the answer:

```js theme={null}
import { useToast } from "vue-toastification"

const toast = useToast()

if (data.code === 200) toast.success("Thanks, we got your message.")
else toast.error(`${data.title}: ${data.message}`)
```

Every answer carries `title` and `message`, so the same line covers every refused submission.

## What's next

* [Field validations](/docs/features/field-validations): the rules you can set per field.
* [Spam protection](/docs/features/spam-protection): the challenges, the filter and the honeypot.
* [Email notifications](/docs/features/email-notifications): the auto response the visitor gets, keyed on `email`.

Stuck? Write to [help@formcarry.com](mailto:help@formcarry.com). Include the form id.


## Related topics

- [Introduction](/docs/introduction.md)
