Create the Form before you have an account
One command, no signup. It returns a real Form id and submit URL — the same ones you'll ship — plus a 24-hour sandbox that stores up to five test messages and sends nothing. The token in the response appears exactly once; save it.
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" 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 one component you actually need
This is a client component because it holds a little state — which field says what, whether we're mid-send. The directive at the top is the only Next-specific line in the file.
Import it from any server component page, app/contact/page.jsx included. No route handler, no server action, no environment variable: the browser posts straight to Postbag, and your Next app stays a purely front-of-house concern.
"use client";
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>
);
} "use client";
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
Run next dev, open the page, send yourself something. Or do it from the terminal. Then ask the sandbox what it holds — your message is in there, stored before anything else was allowed to happen to it.
curl --fail --silent --show-error -X POST "$submit_url" \
-H "content-type: application/json" \
-d '{ "email": "[email protected]", "message": "hello from the terminal" }' curl --fail --silent --show-error -X POST "$submit_url" \
-H "content-type: application/json" \
-d '{ "email": "[email protected]", "message": "hello from the terminal" }' POSTBAG_SANDBOX_TOKEN="$sandbox_token" npx postbag sandbox status 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
Claiming unlocks sending. Connect your inbox, route the Form to it, and every new message is saved the moment it arrives, then delivered. If delivery fails, the message doesn't go down with it — it waits, and Postbag retries.
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" 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"