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:
<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…":
<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:
<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:
<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.