Skip to content

Guide

Add a contact form to your Vue 3 app

Three refs, one handler, and a template that reads like the form it renders.

Last updated

Or skip the reading entirely.

Everything below is agent-work. Paste this to yours, then come back to a working form — you show up at the end to say "keep it."

Paste to your agent
Install the Postbag skill with `npx skills add faahim/postbag --skill postbag`, then use it to add a working contact form to this Vue app and prove a test submission was stored.

Create the Form before you have an account

No signup first — one command returns a real Form id and submit URL, valid straight into production, inside a 24-hour sandbox that stores five test messages and sends nothing. The token it prints appears once; put it somewhere safe.

Create a Form before you have an account
sandbox_json="$(npx postbag --json sandbox create \
  --name "Contact" \
  --origin "https://example.com")"

submit_url="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.submit_url')"
form_id="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.id')"
sandbox_token="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox_token')"
claim_url="$(printf '%s\n' "$sandbox_json" | jq -er '.claim_url')"

printf 'Submit URL: %s\nForm ID: %s\nClaim URL: %s\nSandbox token: %s\nReplace YOUR_POSTBAG_SUBMIT_URL in the next snippet with the Submit URL above.\n' \
  "$submit_url" "$form_id" "$claim_url" "$sandbox_token"

Write the single-file component

This is Vue at its most pleasant: v-model does the bookkeeping, @submit.prevent spares you the event plumbing, and the whole thing fits in one honest file. While a send is in flight the button says so; success swaps the form for a thank-you; failure says so and keeps the visitor's words in the boxes.

src/components/ContactForm.vue
<script setup>
import { ref } from "vue";

const email = ref("");
const message = ref("");
const status = ref("idle");

async function handleSubmit() {
  status.value = "sending";
  try {
    const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ email: email.value, message: message.value }),
    });
    status.value = res.ok ? "sent" : "error";
  } catch {
    status.value = "error";
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks! Your message has been sent.</p>
  <form v-else @submit.prevent="handleSubmit">
    <label>
      Email
      <input type="email" v-model="email" required />
    </label>
    <label>
      Message
      <textarea v-model="message" required></textarea>
    </label>
    <button type="submit" :disabled="status === 'sending'">
      {{ status === 'sending' ? 'Sending…' : 'Send' }}
    </button>
    <p v-if="status === 'error'" role="status">Something went wrong. Please try again.</p>
  </form>
</template>

Send one and watch it land

Mount it, send yourself a line, or test from the terminal. Then ask the sandbox what it holds — your message is there, stored and timestamped, before anything else happened to it.

Send one test from your terminal
curl --fail --silent --show-error -X POST "$submit_url" \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "message": "hello from the terminal" }'
See it stored
POSTBAG_SANDBOX_TOKEN="$sandbox_token" npx postbag sandbox status

Claim it when you're ready

The creation response included a claim link. Open it, sign in — Google, GitHub, or an emailed code — and the sandbox becomes a real Form in your own workspace. Same id, same submit URL: the page you just wired needs no edit. Your test messages come along, still marked as tests.

Turn on email

After the claim, connect your inbox and route the Form to it. From then on, saved first, sent second — an outage on the receiving end delays a message rather than losing it.

Connect your inbox, then route the Form to it
destination_json="$(curl --fail --silent --show-error -X POST https://postbag.dev/v1/destinations \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d '{ "type": "email", "config": { "to": ["[email protected]"] } }')"
destination_id="$(printf '%s\n' "$destination_json" | jq -er '.id')"
route_body="$(jq -n --arg form_id "$form_id" --arg destination_id "$destination_id" \
  '{ form_id: $form_id, destination_id: $destination_id }')"

curl --fail --silent --show-error -X POST https://postbag.dev/v1/routes \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d "$route_body"

The parts that bite

The origin is part of the deal
A sandbox Form only accepts browser posts from the origin you gave at creation. Building locally? Create it with your dev address (say http://localhost:4321) and add your real domain after you claim. Terminal tests carry no origin, so curl always gets through.
Refs unwrap in templates, not in fetch bodies
Inside the template, email means email.value. Inside the script it doesn't — which is why the JSON body reads email.value explicitly. Sending the ref object itself serializes to an empty object and a confusing evening.
JSON posts skip the honeypot
The hidden _gotcha input belongs to plain HTML forms. A JSON fetch shouldn't include it — filled honeypots read as bots.
Five tests, then it wants a decision
A sandbox holds five test messages of up to 16 KiB each, for 24 hours, and sends nothing anywhere. That is the rehearsal budget. Claiming makes it permanent; letting it expire costs nothing.

Questions, answered

Does this work with the Options API?
Yes — the fetch doesn't care how the component holds its state. script setup just keeps the file short.
Do I need axios?
No. The browser's fetch is plenty for one POST, and it's one less dependency to update.
Can I validate before sending?
The required attributes give you the browser's validation for free. Anything richer can wrap the fetch — Postbag stores whatever finally arrives.
What about spam without the honeypot?
JSON submissions are checked by other signals, and anything suspicious is kept and labelled rather than deleted. You can always review the record.

Give the form job to your agent.

It can build, wire and test the form before you even sign up. Claim it when it's worth keeping.