Contact form for Vue.js websites
Composition API, Options API, Nuxt, Vite — every Vue setup works with one tiny single-file component. Submit a form, get email notifications, dashboard analytics, and webhooks. No backend, no SDK, no $30/mo plan.
What your Vue contact form actually looks like.
Drop-in form backend with spam filtering, signed webhooks, and a real submissions dashboard. The same code in this preview is what you copy into your Vue project — no SDK, no plugin, no PHP.
- ✓1,000 submissions per month, free forever
- ✓Honeypot + AI spam classifier on every plan
- ✓Signed webhooks to Slack, Discord, your server
Ship a Vue contact form without a backend.
No SDK, no PHP, no plugin. Your form posts standard FormData to one URL — submissions land in your inbox.
Get your free access key
Verify your email and your access key is generated instantly. Free for 1,000 submissions per month, forever.
By signing up, you agree to our terms and privacy policy.
Drop in the Vue code
Copy the Vue snippet on the right and paste it into your project. Replace YOUR_ACCESS_KEY with the key from step 1.
Submissions land in your inbox
Hits your dashboard and email in seconds. Forward to Slack, Discord, Sheets, Notion, or any signed webhook URL.
Try it now — no signup, no key.
This is a styled HTML preview of what your Vue form will look like. Submitting opens a confirmation, no real request is sent.
Your Vue form posts FormData to /api/submit. Splitforms validates the access key, runs the spam classifier, and forwards the parsed submission to your inbox plus the dashboard.
- →14ms median round-trip from the edge.
- →Honeypot + classifier, no CAPTCHA.
- →Per-domain key locking out of the box.
{
"access_key": "sk_live_4f9a_••••",
"name": "Maya Iyer",
"email": "maya@studio71.co",
"message": "…"
}How to ship this without regrets.
Five rules that make the difference between a form that works in the demo and a form that survives launch traffic.
- 01
Use `<script setup>` syntax for the contact form. Half the boilerplate, full reactivity, no `defineExpose` needed.
- 02
Type your status ref: `const status = ref<'idle' | 'loading' | 'ok' | 'err'>('idle')`. TypeScript catches typos in your template's v-if blocks before they ship.
- 03
Set the access key via `import.meta.env.VITE_SPLITFORMS_KEY` (Vite) or `useRuntimeConfig().public.splitformsKey` (Nuxt) — never hardcode the literal string in your component file.
- 04
After success, call `e.target.reset()` then nextTick() before showing the success message — gives Vue a frame to update v-models so the success state isn't visually fighting the form clear.
- 05
Add `:aria-busy="status === 'loading'"` on the form for screen readers — Vue evaluates this on every reactive update for free.
What bites people who skip the docs.
Worth a 60-second skim before you ship to production. Each one has caused a Vue support ticket at least once.
ref() doesn't auto-unwrap inside a template handler
If you write status === 'loading' inside <script setup> it works because Vue auto-unwraps refs in templates — but inside a function you need status.value. Mixing the two is the #1 cause of "why isn't my button disabling?" bugs in Vue contact forms.
v-model and FormData read different sources
If you bind inputs with v-model="name" to a ref and then build new FormData(e.target), FormData reads the DOM — which Vue keeps in sync — so it works. But if you bind a computed value and the input doesn't have a name attribute, FormData drops it silently. Always set name="…" on every input.
@submit fires before .prevent unless you spell it right
Vue's event modifier syntax is @submit.prevent="onSubmit". People often write @submit="onSubmit" and then forget to call e.preventDefault() inside the handler — the page reloads and the fetch is cancelled mid-flight.
Vite env vars need the VITE_ prefix or they're undefined at build
Reading import.meta.env.SPLITFORMS_KEY returns undefined unless you rename it to VITE_SPLITFORMS_KEY (Vite) or expose it via runtimeConfig (Nuxt). Vite intentionally hides anything without the prefix to prevent leaking server secrets.
Vue 2 needs vue-fetch or axios — there's no built-in fetch wrapper
Modern browsers all support fetch, but if you support IE11 (Vue 2 territory), you need a polyfill or axios. Splitforms only requires a POST with FormData — any client lib works.
reactive() unwraps inputs but breaks if you destructure the form object
If you wrap your form fields in const form = reactive({ name: '', email: '', message: '' }) and then do const { name, email } = form to shorten template references, you lose reactivity — the destructured locals are plain primitives, not refs. The submit handler reads stale values and you get empty fields in the splitforms inbox. Use toRefs(form) when destructuring, or skip destructuring and reference form.name directly. Same trap exists for props passed from a parent.
How Vue handles forms without splitforms.
The shape of the problem before splitforms enters the picture — and the gap it fills for Vue specifically.
Vue ships nothing form-related beyond v-model for two-way binding. To deliver a submission anywhere, you write the same backend stack as React: an Express/Nitro/Hono route, an email provider, a database, spam filtering. Nuxt narrows the gap with /server/api routes, but you're still operating the route. Pinia/Vuex don't help — they're for client state, not network. The Vue ecosystem has FormKit and VeeValidate for validation UX, but neither delivers submissions. Splitforms slots in as the missing endpoint: any Vue 2 / Vue 3 / Nuxt setup posts a FormData to one URL, gets { success: true } back, done.
Two ways to ship splitforms on Vue.
Pick the pattern that matches your constraints — JS budget, key-exposure tolerance, server-side opacity. Both produce the same result.
Pattern A — `<script setup>` with status ref
Modern Vue 3 SFC, half the boilerplate of Options API, full reactivity. status is a single ref<'idle' | 'loading' | 'ok' | 'err'>.
Pattern B — composable for reuse across forms
Wrap the splitforms POST in a useSplitforms() composable. Multiple forms (contact, newsletter, demo) share one implementation. Test once, ship everywhere.
Shipping Vue + splitforms to production.
Host-specific gotchas, env-var conventions, and the boring-but-load-bearing details for putting this on the public internet.
Vue + Vite produces a static bundle that deploys anywhere. For Nuxt, every Nitro preset (Vercel, Netlify, Node, Cloudflare, static, AWS Lambda) works identically because the form posts client-side. On Cloudflare Pages with the Nuxt Cloudflare preset, avoid proxying through /server/api — the fetch round-trip eats the 10ms CPU budget on the free tier; post directly to splitforms. Vite-only (non-Nuxt) projects must use VITE_SPLITFORMS_KEY or env vars are silently undefined client-side. Nuxt uses runtimeConfig.public.splitformsKey. Both inline the value into the bundle, so domain-lock the key in the splitforms dashboard.
splitforms vs native vue.
What you get for free vs what you build, pay for, or do without.
Options API variant for Vue 2 / Nuxt 2 / legacy code
If you're maintaining a Vue 2 codebase or prefer the Options API, the same flow works without `<script setup>` — just move the logic into `data()` and `methods`.
Things developers ask before they integrate.
Direct answers, no marketing fluff. Missing one? Email hello@splitforms.com.
Ship your Vue contact form in 60 seconds.
1,000 free submissions per month. No credit card. Lock the access key to your domains, paste the snippet, watch submissions land in your inbox.
