# Build a Next.js contact form with email notifications

Build a local Next.js contact form with server validation, a fixed recipient, spam checks, rate limiting, and honest submission states.

Author: Stampwing
Canonical: https://www.stampwing.com/guides/nextjs-contact-form-email
Published: 2026-10-05
Reviewed: 2026-10-05
Category: Engineering

Run a form that preserves the visitor’s message, explains field errors, and distinguishes acceptance from uncertainty.

## Short answer

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#short-answer

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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#request-boundary

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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#run-locally

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.

Run from the extracted example folder

```sh
npm ci
npm test
npm run demo
```

## Read the key part of the example

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#example-source

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.

Local simulation · contact.mjs

```javascript
    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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#example-files

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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#recorded-experiment

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.

Recorded local simulation · expected-output.json

```json
{
  "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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#validation

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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#failure-cases

| 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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#production-setup

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.

[Understand API timeouts and duplicate prevention](https://www.stampwing.com/guides/prevent-duplicate-emails)

[Start with the simpler Next.js sending route](https://www.stampwing.com/guides/send-email-nextjs)

## Why not put the visitor’s email in From?

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#common-question

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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#coding-agent-brief

Download the example, then copy this brief into your coding agent. The same brief is included as BUILD-BRIEF.md.

Coding-agent build brief

```markdown
# 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

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#download-integrity

The ZIP contains 16,894 bytes. Compare its SHA-256 digest with this value before extracting. Downloads are free and require no signup.

SHA-256 · nextjs-contact-form-email.zip

```text
fdbaa620c44ebbd54ace40a4c9b38ee98028ab5c559a5f829446df4b783be601
```

## Sources

Section link: https://www.stampwing.com/guides/nextjs-contact-form-email#sources

- [Next.js: Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers)
- [OWASP: input validation](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html)
- [OWASP: CSRF prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)

Example download: [Download the local example](https://www.stampwing.com/downloads/nextjs-contact-form-email.zip)

## Related guides

- https://www.stampwing.com/guides/send-email-nextjs
- https://www.stampwing.com/guides/prevent-duplicate-emails
- https://www.stampwing.com/guides/test-transactional-email

Editorial policy: https://www.stampwing.com/resources/editorial
