3 min read
Vue recipe
Vue 3 with <script setup>. Plain fetch, no extra libraries needed.
Basic form
<script setup lang="ts">
import { reactive, ref } from 'vue';
const form = reactive({ email: '', message: '' });
const status = ref<'idle' | 'sending' | 'ok' | 'error'>('idle');
const error = ref<string | null>(null);
async function submit() {
status.value = 'sending';
error.value = null;
try {
const r = await fetch(import.meta.env.VITE_FORMSPRING_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(form),
});
if (!r.ok) {
throw new Error((await r.json())?.message ?? `HTTP ${r.status}`);
}
status.value = 'ok';
form.email = '';
form.message = '';
} catch (e: any) {
status.value = 'error';
error.value = e.message;
}
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="form.email" type="email" required placeholder="Email" />
<textarea v-model="form.message" required placeholder="Message" />
<button :disabled="status === 'sending'">
{{ status === 'sending' ? 'Sending…' : 'Send' }}
</button>
<p v-if="status === 'ok'">Thanks — we'll be in touch.</p>
<p v-else-if="status === 'error'" class="err">{{ error }}</p>
</form>
</template>
File uploads with FormData
For file fields, switch from JSON to multipart. Don't set Content-Type — the browser sets the correct boundary automatically.
<script setup lang="ts">
import { ref } from 'vue';
const file = ref<File | null>(null);
const status = ref<'idle' | 'sending' | 'ok' | 'error'>('idle');
function onFile(e: Event) {
const input = e.target as HTMLInputElement;
file.value = input.files?.[0] ?? null;
}
async function submit(e: Event) {
status.value = 'sending';
const fd = new FormData(e.target as HTMLFormElement);
// fd already includes file inputs and text inputs
const r = await fetch(import.meta.env.VITE_FORMSPRING_URL, {
method: 'POST',
body: fd,
});
status.value = r.ok ? 'ok' : 'error';
}
</script>
<template>
<form @submit.prevent="submit">
<input name="email" type="email" required />
<textarea name="message" required />
<input name="attachment" type="file" @change="onFile" />
<button :disabled="status === 'sending'">Send</button>
</form>
</template>
Reusable composable
Wrap submission state for reuse across multiple forms:
// src/composables/useFormspring.ts
import { ref } from 'vue';
export function useFormspring(endpoint: string) {
const status = ref<'idle' | 'sending' | 'ok' | 'error'>('idle');
const error = ref<string | null>(null);
async function submit(payload: Record<string, unknown> | FormData) {
status.value = 'sending';
error.value = null;
const init: RequestInit = { method: 'POST' };
if (payload instanceof FormData) {
init.body = payload;
} else {
init.headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
init.body = JSON.stringify(payload);
}
try {
const r = await fetch(endpoint, init);
if (!r.ok) {
throw new Error(`HTTP ${r.status}`);
}
status.value = 'ok';
} catch (e: any) {
status.value = 'error';
error.value = e.message;
}
}
return { status, error, submit };
}
<script setup lang="ts">
import { useFormspring } from '@/composables/useFormspring';
const { status, error, submit } = useFormspring(import.meta.env.VITE_FORMSPRING_URL);
</script>
Error states
Formspring returns:
200with JSON{ ok: true, id: "..." }on success.422with{ ok: false, errors: { field: ["message"] } }on validation failure.429withRetry-Afterheader when rate-limited.
Render field-level errors by mapping errors to your template; the field names match your form's field names.