Skip to content

Guide

Add a contact form to your Nuxt site

Drop one file in components/ and Nuxt does the introductions. No server route required.

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 Nuxt site and prove a test submission was stored.

Create the Form before you have an account

One command, no account: a real Form id and submit URL — the ones you'll keep — inside a 24-hour sandbox that stores up to five test messages and sends nothing anywhere. Save the token from the response; it isn't shown again.

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"

Add the component — Nuxt finds it

Put the file in components/ and it's globally available as ContactForm, no import line anywhere. The explicit ref import from vue is deliberate: Nuxt would auto-import it too, but the file stays honest about what it uses and survives being copied into a plain Vue project.

There's no server/api handler in this guide on purpose. The browser posts straight to Postbag, so your Nitro server never holds a visitor's message it could drop.

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

Use the form in dev, or send from the terminal. Then read the sandbox back: your message is stored with its arrival time, which is the receipt that matters.

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

Claim, connect your inbox, route the Form to it. Every new message is saved on arrival and sent right after — and a flaky receiving inbox becomes Postbag's problem to retry, not your visitor's problem to notice.

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.
SSR renders it, the browser runs it
Nuxt server-renders the markup, but the fetch only ever runs on click, in the browser. No ClientOnly wrapper, no process.client check — a submit handler is client-side by nature.
JSON posts skip the honeypot
The hidden _gotcha input is a plain-HTML-form device. Leave it out of JSON fetches — filling a honeypot is how bots get caught, not a thing to imitate.
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

Should I proxy through a Nitro server route instead?
You can, but you'd be adding a hop that can fail while the browser could have posted straight to the place that stores the message.
Does this work with nuxt generate?
Yes. A fully static Nuxt site posts to Postbag exactly the same way — that's rather the point of not needing your own server.
Why import ref if Nuxt auto-imports it?
Habit worth keeping: the file works when copied anywhere Vue does, and nobody has to remember what Nuxt quietly provides.
Can several pages share the form?
Yes — auto-registration means any page can render ContactForm, and they all post to the same Form.

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.