Skip to content

Vue ​

Every example on this page is a self-contained Vue <script setup lang="ts"> component using fetch, with no BootForm SDK to install. They're written in TypeScript, as in Vite's default Vue template; for plain JavaScript, drop lang="ts" and the type annotations. Jump to just the one you need from the outline on the right, or read top to bottom for the full picture.

Basic submission ​

The plain HTML case works unchanged inside a template, with no reactive state needed at all:

vue
<template>
  <form action="https://f.bootform.com/{form_id}" method="POST">
    <!-- Required: BootForm's spam trap. Keep this hidden field and leave it empty. -->
    <input type="text" name="honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message"></textarea>
    <button type="submit">Send</button>
  </form>
</template>

The hidden honeypot input is BootForm's spam trap. New forms use the name honeypot; if you renamed yours, use the name in your form's snippet (in the dashboard, or from bootform_get_snippet over MCP). See Spam protection.

AJAX submission ​

Intercept the submit, post with fetch, and track status with ref() instead of navigating away. FormData reads the fields straight from the form, so no v-model is needed. The try/catch covers a failed network request, so the button never stays stuck on "Sending…":

vue
<script setup lang="ts">
import { ref } from 'vue'

const status = ref<'idle' | 'sending' | 'sent' | 'error'>('idle')
const error = ref<string | null>(null)

async function handleSubmit(event: SubmitEvent) {
  const form = event.target as HTMLFormElement
  status.value = 'sending'

  try {
    const response = await fetch(form.action, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: new FormData(form),
    })
    const result = await response.json()

    if (result.ok) {
      form.reset()
      status.value = 'sent'
    } else {
      error.value = result.error
      status.value = 'error'
    }
  } catch {
    error.value = 'Could not send. Check your connection and try again.'
    status.value = 'error'
  }
}
</script>

<template>
  <form action="https://f.bootform.com/{form_id}" method="POST" @submit.prevent="handleSubmit">
    <!-- Required: BootForm's spam trap. Keep this hidden field and leave it empty. -->
    <input type="text" name="honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message"></textarea>
    <button type="submit" :disabled="status === 'sending'">
      {{ status === 'sending' ? 'Sending…' : 'Send' }}
    </button>
    <p v-if="status === 'sent'" role="status">Thanks, your message was sent.</p>
    <p v-if="status === 'error'" role="alert">{{ error }}</p>
  </form>
</template>

File upload ​

FormData picks up a file input the same way as text fields; nothing Vue-specific needed beyond the state you're already tracking above:

vue
<script setup lang="ts">
import { ref } from 'vue'

const status = ref<'idle' | 'sending' | 'sent' | 'error'>('idle')
const error = ref<string | null>(null)

async function handleSubmit(event: SubmitEvent) {
  const form = event.target as HTMLFormElement
  status.value = 'sending'

  try {
    const response = await fetch(form.action, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: new FormData(form), // includes the selected file automatically
    })
    const result = await response.json()
    error.value = result.ok ? null : result.error
    status.value = result.ok ? 'sent' : 'error'
  } catch {
    error.value = 'Could not send. Check your connection and try again.'
    status.value = 'error'
  }
}
</script>

<template>
  <form action="https://f.bootform.com/{form_id}" method="POST" @submit.prevent="handleSubmit">
    <!-- Required: BootForm's spam trap. Keep this hidden field and leave it empty. -->
    <input type="text" name="honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
    <label for="resume">Resume</label>
    <input id="resume" name="resume" type="file" required />
    <button type="submit" :disabled="status === 'sending'">
      {{ status === 'sending' ? 'Sending…' : 'Send' }}
    </button>
    <p v-if="status === 'sent'" role="status">Thanks, your file was sent.</p>
    <p v-if="status === 'error'" role="alert">{{ error }}</p>
  </form>
</template>

A file upload requires the form to be claimed first, or you get 422 FORM_NOT_CLAIMED. See Building your form.

Moderated content feed ​

Load a form's approved submissions on mount, and let visitors react. The feed endpoint is public and unauthenticated, no API key involved. A form without a feed (unclaimed, below Pro, or a mistyped id) answers with an error status instead, so the component checks response.ok and keeps the list empty:

vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

interface FeedItem {
  id: string
  fields: Record<string, string>
  reactions: { up: number; down: number }
}

const props = defineProps<{ formId: string }>()
const items = ref<FeedItem[]>([])

async function loadFeed() {
  const response = await fetch(`https://f.bootform.com/forms/${props.formId}/feed?per_page=20`)
  if (!response.ok) return // no feed for this form: unclaimed, below Pro, or a wrong id
  const { items: fetched } = await response.json()
  items.value = fetched
}

async function react(submissionId: string, type: 'up' | 'down') {
  await fetch(`https://f.bootform.com/forms/${props.formId}/feed/${submissionId}/react`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ type }),
  })
  loadFeed() // refetch to show the updated count
}

onMounted(loadFeed)
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <p>{{ item.fields.message }}</p>
      <button @click="react(item.id, 'up')">👍 {{ item.reactions.up }}</button>
      <button @click="react(item.id, 'down')">👎 {{ item.reactions.down }}</button>
    </li>
  </ul>
</template>

See Moderated content feed for the report endpoint, pagination, and the hosted-widget alternative if you'd rather not write this yourself.