Skip to content

Guide

Add a contact form to Next.js without an API route

You were about to write app/api/contact/route.ts. You can close that file.

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

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.

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 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.

app/contact/ContactForm.jsx
"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.

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

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.

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 directive earns its keep
Without "use client" at the top, the App Router treats the file as a server component and useState throws at build time. It's the only Next-specific thing here — the rest is plain React.
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 belongs to plain HTML forms. A fetch that sends JSON shouldn't include it — spam checks for JSON submissions use other signals, and a filled honeypot would flag you.
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.

Or clone the working example.

If you'd rather clone a working example than write the component by hand, this Next.js app is already wired — drop in your submit URL and send a test.

Clone this working example

Questions, answered

Why not a server action?
You can use one — Postbag is just an HTTP endpoint. But a server action means your server handles the message before Postbag stores it. Posting from the browser keeps your app stateless.
Does this work in the Pages Router?
Yes. Drop the directive and it's an ordinary React component — the fetch doesn't care which router rendered the page.
Do I need CORS configuration?
No. Postbag answers cross-origin posts for the origins your Form allows, which is why the origin you register matters.
What shows up in my inbox?
Each new message, with the visitor's address set as reply-to — press Reply and you're answering the person, not a no-reply robot.

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.