Submit the form to a server route, validate it there, and choose the sender and destination on the backend. Put the visitor’s validated address in Reply-To. Keep your success message tied to the last confirmed state: accepting a request is not proof that an email arrived.
Before you start #
- For
- Developers building and operating application email.
- Bring
- Node.js 22+, npm, and a browser. The starter pins Next.js 16.3.8 and React 19.3.0.
- Scope
- The download runs locally with synthetic data. All email delivery is simulated; no provider credentials or live sends are needed.
Keep the browser away from your sending credentials #
A contact form is a public input surface. If its payload can choose a destination, sender, or provider URL, a convenient example can become an unwanted mail relay. Accept only the visitor details and message that the form needs. Derive the actual envelope in server code.
The download always uses website@example.test as From and support@example.test as its destination. These are synthetic values. A submitted to field cannot override them. Reply-To accepts one address and rejects whitespace and header separators. The message stays plain text; adding HTML later requires context-appropriate escaping.
The form keeps the visitor’s text after errors, disables the submit button while a request is pending, and announces the result through an accessible status or alert. Nothing in the UI claims inbox delivery.
Run the form and its failure cases #
Unzip the example into an empty directory. Run npm ci, npm test, and npm run demo. For the browser form, run npm run dev and open http://127.0.0.1:3037. Use that exact origin: the handler deliberately does not infer trust from an arbitrary Host or forwarded header.
The route is available only in development. Set LAB_TRANSPORT_MODE=timeout or LAB_TRANSPORT_MODE=rejected before starting the development server to compare failure messages. The simulator has no SMTP client or provider API call. All sample data remains local.
npm ci
npm test
npm run demoRead the key part of the example #
This excerpt comes from contact.mjs, lines 57–90, in the download. It shows the central decision; run the complete package with the commands above.
const errors = {};
if (
typeof data.name !== "string" ||
!data.name.trim() ||
data.name.length > 100 ||
/[\r\n]/.test(data.name)
)
errors.name = "Enter a name of up to 100 characters.";
if (
typeof data.email !== "string" ||
data.email.length > 254 ||
!/^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(data.email)
)
errors.email = "Enter one valid email address.";
if (
typeof data.message !== "string" ||
data.message.trim().length < 10 ||
data.message.length > 4000
)
errors.message = "Write a message between 10 and 4,000 characters.";
if (Object.keys(errors).length)
return json(
400,
"Check the highlighted fields. Your text is still here.",
{ errors },
);
const envelope = {
from: "website@example.test",
to: "support@example.test",
replyTo: data.email,
subject: "Website contact request",
text: `${data.name.trim()} wrote:\n\n${data.message.trim()}`,
};Find the files you’ll change #
Pinned dependencies: next 16.3.8, react 19.3.0, react-dom 19.3.0. Install with npm ci so the lockfile controls the resolved versions.
| File | Purpose |
|---|---|
| contact.mjs | The example behavior shown in this article. |
| test.mjs | Acceptance checks and synthetic failure cases. |
| demo.mjs / expected-output.json | A repeatable local experiment and its recorded result. |
| README.md | Setup commands, expected behavior and production boundaries. |
| BUILD-BRIEF.md | The coding-agent brief below. |
| package.json / package-lock.json | Pinned dependencies and runnable commands. |
| LICENSE | MIT license for adapting this example. |
Compare the recorded local result #
Captured from this package’s demo command on October 5, 2026. These results use synthetic fixtures and simulated delivery; they do not measure a live provider or inbox placement.
{
"message": "The local simulator accepted your message. No email was sent.",
"simulated": true,
"outcome": "accepted",
"envelope": {
"from": "website@example.test",
"to": "support@example.test",
"replyTo": "morgan@example.test",
"subject": "Website contact request",
"text": "Morgan wrote:\n\nCould you explain your project limits?"
}
}Validate before constructing the notification #
HTML input constraints help the visitor, but a direct HTTP client can bypass them. The handler separately checks the JSON body size, name, address, message length, and honeypot. Invalid input returns a specific correction instead of creating a job.
A honeypot is a useful signal, not an abuse guarantee. This lab also uses one bounded in-memory bucket for the entire process: five attempts per minute. That makes the behavior easy to reproduce without collecting IP addresses. It will reset on restart and does not coordinate across replicas.
Read the result before offering a retry #
| Result | Meaning | Next step |
|---|---|---|
| 400 / 413 | Input or body size failed validation. | Keep the text and correct the field. |
| 429 | The local process used its minute allowance. | Wait for the next window. |
| 503, rejected | The simulator explicitly rejected the request. | Fix the transport before another attempt. |
| 503, uncertain | The simulated outcome is unknown. | Reconcile the original request; do not blindly resubmit. |
| 202 | The local simulator accepted the request. | Show acceptance, not delivery. |
Connect this pattern to a production form #
Start by replacing the development-only route with your application’s authorized public contact endpoint. Define an exact origin and CSRF policy, constrain recipients on the backend, and put shared rate limiting and your anti-abuse controls in front of job creation. Do not trust a forwarded IP unless your infrastructure removes untrusted copies.
Persist a logical submission ID and its content before acknowledging a queued job. Use a worker and a provider adapter with server-side credentials. A durable submission identity is particularly important when the browser loses a response: this small lab does not implement retry-safe contact submissions.
Configure the provider’s verified From domain and use its documented Reply-To field. Run separate rendering and delivery checks before launch. The local tests prove validation and UI behavior, not DNS authentication, recipient mailbox placement, or production capacity.
Why not put the visitor’s email in From? #
Your server should send from a domain you control and have verified. Put the visitor’s validated address in Reply-To so replies reach them without impersonating their domain.
Use with your coding agent #
Download the example, then copy this brief into your coding agent. The same brief is included as BUILD-BRIEF.md.
# Build a Next.js contact form with email notifications — coding-agent brief
## Objective
Build an accessible contact form whose server controls the email envelope and reports validation, rate-limit and uncertain outcomes.
## Read first
Read README.md, package.json, contact.mjs, test.mjs and demo.mjs. Keep dependency versions pinned to package-lock.json. Use Node.js 22+.
## Dependencies
next 16.3.8, react 19.3.0, react-dom 19.3.0. Install with npm ci and preserve the lockfile.
## Work
Start at `createContactHandler` in contact.mjs. Run the existing synthetic fixtures before changing behavior. Preserve the guide's original scenario and add a regression check for each changed failure case.
## Constraints
All email is simulated. Do not add provider credentials, send email, deploy services, or use the application's database. Preserve unknown outcomes instead of claiming delivery. Do not turn the local demonstration into production authentication or a public mail relay. Explain the production setup separately.
## Verification
Run `npm ci`, `npm test`, and `npm run demo`.
## Acceptance
Keep the fixed recipient and Reply-To validation. Test all field errors, spam, 429 with Retry-After, stalled bodies and uncertain transport. Verify focus moves to the first invalid field while all text remains.
No real tokens, signing secrets, or recipient data appear in logs. All fixtures use synthetic values.Check the download #
The ZIP contains 16,894 bytes. Compare its SHA-256 digest with this value before extracting. Downloads are free and require no signup.
fdbaa620c44ebbd54ace40a4c9b38ee98028ab5c559a5f829446df4b783be601Sources and further reading
Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.