Skip to content
Working With Vue

A Vue form component

Build a Vue 3 contact form with Qubeforms using v-model and FormData. Configure your endpoint, submit reactive fields, and handle API errors and success feedback.

1. Add the external form stylesheet

Save the CSS below as a separate file in your website. HTML examples load it from /static/assets/css/form-helper.css; React and Vue import ./form-helper.css beside the component. Adjust the path to match your site and confirm the file loads before publishing. The generic .form-helper class moves the text input off-screen without the hidden attribute or display: none. Keep tabindex="-1", autocomplete="off", and aria-hidden="true". New forms use fax_number; existing forms keep their saved name. Match Settings → Delivery & spam → Honeypot field in HTML, JSON, and drafts. To rename an existing trap, update both the setting and the website together. Never use a name that also collects a real visitor answer.

.form-helper {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
    pointer-events: none;
}

2. Connect a Vue 3 form

Use this Vue 3 single-file component with the Composition API and <script setup>. Create an active Qubeforms form, copy its full public submission endpoint, and allow the origin hosting your Vue site. The example uses v-model for text fields, native browser validation, an empty default honeypot, and an accessible status message. If the form requires CAPTCHA, add its configured widget and fresh response token.

3. Create ContactForm.vue

Save this complete component as ContactForm.vue. FormData snapshots named fields before the pending state disables the controls. The timestamp starts after mounting. Failed requests leave the reactive values intact, and successful responses reset both the DOM form and its reactive text fields. No custom Content-Type header is needed for this multipart body.

<script setup>
import { onMounted, reactive, ref } from 'vue';
import './form-helper.css';

const props = defineProps({ endpoint: { type: String, required: true } });
const fields = reactive({ name: '', email: '', message: '' });
const sending = ref(false);
const feedback = ref('');
let startedAt = 0;
onMounted(() => { startedAt = Math.floor(Date.now() / 1000); });

async function submit(event) {
  if (sending.value) return;
  const form = event.currentTarget;
  const body = new FormData(form);
  body.set('_cf_started_at', String(startedAt));
  sending.value = true;
  feedback.value = 'Sending…';
  try {
    const response = await fetch(props.endpoint, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body,
    });
    const result = await response.json().catch(() => null);
    if (!response.ok || result?.ok !== true) {
      const retry = response.headers.get('Retry-After');
      feedback.value = (result?.error?.message || `Could not confirm receipt (HTTP ${response.status}).`)
        + (response.status === 429 && retry ? ` Retry after ${retry} seconds.` : '');
      return;
    }
    feedback.value = 'Your submission was received.';
    form.reset();
    Object.assign(fields, { name: '', email: '', message: '' });
    startedAt = Math.floor(Date.now() / 1000);
  } catch {
    feedback.value = 'We could not confirm receipt. Check before submitting again.';
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <form :action="endpoint" method="post" @submit.prevent="submit" :aria-busy="sending">
    <fieldset :disabled="sending">
      <legend>Contact us</legend>
      <label>Name <input name="name" v-model="fields.name" required></label>
      <label>Email <input name="email" type="email" v-model="fields.email" required></label>
      <label>Message <textarea name="message" v-model="fields.message" required></textarea></label>
      <input class="form-helper" name="fax_number" type="text" tabindex="-1" autocomplete="off" aria-hidden="true">
      <button type="submit">{{ sending ? 'Sending…' : 'Send' }}</button>
    </fieldset>
    <p role="status" aria-live="polite">{{ feedback }}</p>
  </form>
</template>

4. Pass the endpoint

Import the component and pass your real endpoint. Only the public form key is exposed. The endpoint is a string prop so the same component can be used in a plain Vue app or in Nuxt without depending on a particular environment-variable system.

<script setup>
import ContactForm from './components/ContactForm.vue';
const endpoint = 'https://api.qubeforms.io/v1/cf_pk_YOUR_KEY/submissions';
</script>

<template>
  <ContactForm :endpoint="endpoint" />
</template>

5. Configure a Vite-based Vue app

In a Vue project built with Vite, define VITE_QUBEFORMS_ENDPOINT in the customer app’s .env.local and read import.meta.env.VITE_QUBEFORMS_ENDPOINT in the parent. The value is public and replaced at build time. Restart development after local changes and build with the correct production endpoint. For Nuxt, use the runtime-config approach in Nuxt client forms instead.

VITE_QUBEFORMS_ENDPOINT=https://api.qubeforms.io/v1/cf_pk_YOUR_KEY/submissions

6. Extend the form

Add a name to each input and update the reactive initial and reset objects for additional controlled fields. File inputs should be uncontrolled; use Upload progress in Vue for a complete attachment example. For JSON-only submissions you may serialize scalar field state and set the JSON content type, but JSON does not upload file bytes. Do not post from a watcher or from page setup, which can send data without an explicit submission.

7. Check success and failures

HTTP 201 with ok: true confirms receipt, not completed email delivery. Preserve server messages as text with Vue interpolation and avoid v-html for errors. The component displays an inline confirmation; a configured _cf_redirect is returned as redirect_url when requesting JSON, so your code must choose whether to navigate. Test invalid input, offline mode, and the deployed origin. See Handling errors in Vue and Multi-step forms in Vue for extensions.

Keep private credentials private

Public form keys can appear in browser code. Read keys, management credentials, and webhook signing secrets must stay on the server.