Skip to content

Guide

Add a contact form to a React app without a backend

Your React app is static files on a CDN. It can still have a real contact form.

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

Create the Form before you have an account

One command stands in for the backend you were told you'd need. It returns a Form id and a submit URL that carry straight through to production, wrapped in a 24-hour sandbox: five test messages, safely stored, nothing sent. The printed token shows up once — keep it.

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 component

One component, three pieces of state, one fetch. While the send is in flight the button politely says so; on success the form steps aside for a confirmation; on failure it says something went wrong and lets the visitor try again — their words still in the boxes, not eaten.

src/ContactForm.jsx
import { useState } from "react";

export default function ContactForm() {
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");
  const [status, setStatus] = useState("idle");

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

  if (status === "sent") {
    return <p role="status">Thanks! Your message has been sent.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
      </label>
      <label>
        Message
        <textarea value={message} onChange={(e) => setMessage(e.target.value)} required></textarea>
      </label>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
      {status === "error" && <p role="status">Something went wrong. Please try again.</p>}
    </form>
  );
}

Send one and watch it land

Render it, type something kind to yourself, press Send. Or test from the terminal. Either way, ask the sandbox what it holds: your message, stored with a timestamp, before anything else got a say.

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

Once claimed, connect your inbox and route the Form to it. New messages are saved first and sent second, which is the order you want when your email provider picks a bad afternoon.

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.
JSON posts skip the honeypot
The hidden _gotcha field is for plain HTML forms. Don't add it to a JSON fetch — a filled honeypot reads as a bot, which is a strange thing to do to yourself.
Keep the failure path honest
The component leaves the visitor's text in place on error so they can retry. Resist the urge to clear the form on submit — clearing belongs after res.ok, never before.
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 need any npm package?
No. fetch is built into every browser you support, and the component above is the whole integration.
Will it work with server-side rendering?
Yes. The fetch runs on click, in the browser. Frameworks that render React on the server (Next, Remix) just need the component marked as client code where required.
Can I add more fields?
Yes — add inputs and include them in the JSON body. Postbag stores what arrives; extra answers stay with the message rather than being dropped.
What if the visitor is offline?
The fetch fails, the form shows its error line, and their text stays put. Nothing reaches Postbag until the send actually succeeds.

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.