All docs
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:

  • 200 with JSON { ok: true, id: "..." } on success.
  • 422 with { ok: false, errors: { field: ["message"] } } on validation failure.
  • 429 with Retry-After header when rate-limited.

Render field-level errors by mapping errors to your template; the field names match your form's field names.

What's next