How to Build a Secure Contact Form in Next.js 16 with Server Actions, Zod and Nodemailer
Build a secure Next.js contact form with Server Actions, Zod 4 validation, accessible errors, Nodemailer SMTP, a honeypot, rate limiting and safe HTML email.
By Harsh Thummar 14–17 min read

A Next.js contact form looks like the easiest feature on a website. Three inputs, a button, and an email lands in your inbox.
In practice, it is one of the few places where anonymous strangers can send data straight to your server. Without care, it becomes a spam cannon, an HTML injection vector, or a form that says "Message sent!" while the email silently fails.
In this guide, I'll build the contact form I actually trust in production: a Next.js 16 Server Action, a shared Zod 4 schema, an accessible form built with useActionState, Nodemailer over SMTP, a honeypot, rate limiting, and HTML escaping. You'll also see where each layer falls short and what to do about it.
Server Action or Route Handler?
Next.js gives you two server entry points for a form: a Server Action (a function marked 'use server') or a Route Handler (app/api/contact/route.ts). Both work. They suit different situations.
When a Server Action fits
- The form only lives inside your own Next.js app.
- You want the form to submit even before JavaScript loads (progressive enhancement).
- You want typed input and output without writing
fetchcalls and JSON parsing. - You want the framework's built-in checks: Server Actions only accept
POST, compare theOriginheader to theHost, and cap request bodies at 1MB by default.
When a Route Handler fits
- Other clients need the endpoint too, such as a mobile app, a static site, or a third-party integration.
- You want precise HTTP semantics, like a
429status with aRetry-Afterheader. - You are receiving webhooks or anything not driven by a React form.
The contact form on this site actually uses a Route Handler, because it is called with fetch and returns proper status codes. For a new Next.js 16 project where the form is the only client, I'd pick a Server Action. The security rules are identical either way.
A Server Action is still a public POST endpoint. Anyone can call it without touching your UI, so validate, rate limit, and escape inside the action itself.
If you're not sure where the server/client boundary sits, my guide on React Server Components covers it in detail.
Project Structure
app/
└── contact/
├── page.tsx # Server Component that renders the form
├── actions.ts # 'use server' — the Server Action
└── contact-form.tsx # 'use client' — form UI with useActionState
lib/
├── contact-schema.ts # Zod 4 schema shared by server and client
├── email.ts # Nodemailer transport (server-only)
└── rate-limit.ts # Simple in-memory limiterStep 1: One Zod 4 Schema as the Source of Truth
Validation must happen on the server. The client can help with hints, but it can always be bypassed.
The trick is to put the schema and its limits in one file, so the server validation, the TypeScript types, and the maxLength attributes on your inputs never drift apart.
// lib/contact-schema.ts
import { z } from "zod";
export const LIMITS = {
name: 100,
email: 254,
subject: 150,
message: 5000,
} as const;
export const contactSchema = z.object({
name: z
.string()
.trim()
.min(2, "Enter your name.")
.max(LIMITS.name, `Keep your name under ${LIMITS.name} characters.`),
email: z
.string()
.trim()
.max(LIMITS.email)
.pipe(z.email("Enter a valid email address.")),
subject: z.string().trim().max(LIMITS.subject).optional().default(""),
message: z
.string()
.trim()
.min(10, "Your message should be at least 10 characters.")
.max(LIMITS.message, `Keep your message under ${LIMITS.message} characters.`),
// Honeypot: hidden from people, so any value means a bot filled it in.
website: z.string().max(200).optional().default(""),
});
export type ContactInput = z.infer<typeof contactSchema>;
export type ContactField = keyof ContactInput;A few Zod 4 details worth knowing:
z.email()is now a top-level format schema. The olderz.string().email()still works but is deprecated.- I use
z.string().trim().pipe(z.email(...))so whitespace is trimmed before the email format check runs. - Errors are read from
error.issues(the old.errorsalias is gone), andz.flattenError(error).fieldErrorsgives you a{ field: string[] }map that is perfect for forms.
If you want more on keeping types aligned across the stack, see TypeScript with React and Node.js.
Step 2: Sending Email with Nodemailer over SMTP
Install the dependencies:
npm install nodemailer zod server-only
npm install -D @types/nodemailerCredentials go in environment variables. Locally that means .env.local, which Next.js loads automatically and which must stay out of Git:
# .env.local (never commit this file)
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_USER=you@example.com
SMTP_PASS=your-smtp-or-app-password
CONTACT_TO=you@example.comIn production, set the same variables in your hosting provider's dashboard. Never prefix them with NEXT_PUBLIC_, because that inlines the value into the browser bundle.
Now the mailer:
// lib/email.ts
import "server-only";
import nodemailer from "nodemailer";
/** Escape user input before it goes into the HTML body. */
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
type SendResult = { sent: true } | { sent: false; reason: string };
export async function sendContactEmail(input: {
name: string;
email: string;
subject: string;
message: string;
}): Promise<SendResult> {
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO } = process.env;
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS || !CONTACT_TO) {
return { sent: false, reason: "SMTP is not configured" };
}
const port = Number(SMTP_PORT ?? 465);
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port,
secure: port === 465, // 465 = implicit TLS, 587 = STARTTLS
auth: { user: SMTP_USER, pass: SMTP_PASS },
});
// Strip line breaks so user text can never add extra headers.
const subject = (input.subject || `New message from ${input.name}`).replace(/[\r\n]+/g, " ");
try {
await transporter.sendMail({
from: `"Website Contact Form" <${SMTP_USER}>`, // fixed sender, never the visitor
to: CONTACT_TO,
replyTo: input.email,
subject: `[Contact] ${subject}`,
text: `Name: ${input.name}\nEmail: ${input.email}\n\n${input.message}`,
html: `
<p><strong>Name:</strong> ${escapeHtml(input.name)}</p>
<p><strong>Email:</strong> ${escapeHtml(input.email)}</p>
<p><strong>Subject:</strong> ${escapeHtml(subject)}</p>
<p style="white-space: pre-wrap">${escapeHtml(input.message)}</p>
`,
});
return { sent: true };
} catch (error) {
console.error("Contact email failed:", error);
return { sent: false, reason: "SMTP send failed" };
}
}Three security decisions are baked in here:
- The
fromaddress is fixed. The visitor's address goes inreplyTo, so you can hit Reply, but nobody can make mail appear to come from your domain with their chosen name. - Every user value is escaped before it enters the HTML. Without that, someone can submit
<a href="https://evil.example">Click to verify</a>and it renders as a real link in your inbox. import "server-only"makes the build fail if a Client Component ever imports this file, so your SMTP code can't leak into the browser bundle.
If you use Gmail, you'll need an App Password (which requires 2-Step Verification) rather than your normal account password.
Step 3: A Simple Rate Limiter (and Its Limits)
A honeypot stops dumb bots. A rate limiter stops a single client from flooding your inbox or burning through your SMTP quota.
Here is a minimal fixed-window limiter:
// lib/rate-limit.ts
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
export function rateLimit(key: string, limit: number, windowMs: number) {
const now = Date.now();
// Occasionally drop expired entries so the map can't grow forever.
if (buckets.size > 500) {
for (const [k, b] of buckets) if (b.resetAt <= now) buckets.delete(k);
}
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, retryAfterSeconds: 0 };
}
bucket.count += 1;
if (bucket.count > limit) {
return { allowed: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true, retryAfterSeconds: 0 };
}Be honest about what this does. The Map lives in one server process:
- On serverless platforms, every instance has its own counters, and instances are recycled. A determined attacker spread across instances gets more attempts.
- A deploy or cold start resets everything.
- The client IP usually comes from
x-forwarded-for, which is only trustworthy when your hosting platform sets it.
For a personal site, this still blocks the common case of one client hammering the form. For anything bigger, move the counters to a shared store such as Redis (for example Upstash), using an atomic increment with an expiry. I cover the same idea for login endpoints in How to Build a Secure REST API with Node.js.
Step 4: The Server Action
With useActionState, the action receives the previous state as its first argument and the FormData as its second. It returns the next state, which React hands back to the form.
// app/contact/actions.ts
"use server";
import { headers } from "next/headers";
import { z } from "zod";
import { contactSchema, type ContactField } from "@/lib/contact-schema";
import { sendContactEmail } from "@/lib/email";
import { rateLimit } from "@/lib/rate-limit";
export type ContactState = {
status: "idle" | "success" | "error";
message: string;
fieldErrors: Partial<Record<ContactField, string[]>>;
values: { name?: string; email?: string; subject?: string; message?: string };
};
const SUCCESS = "Thanks! Your message has been sent. I'll reply soon.";
export async function sendContact(
_prev: ContactState,
formData: FormData
): Promise<ContactState> {
const raw = {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
subject: String(formData.get("subject") ?? ""),
message: String(formData.get("message") ?? ""),
website: String(formData.get("website") ?? ""),
};
// Echo back what the user typed (but never the honeypot).
const { website: _hp, ...values } = raw;
const parsed = contactSchema.safeParse(raw);
if (!parsed.success) {
return {
status: "error",
message: "Please fix the highlighted fields.",
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
// Honeypot tripped: pretend it worked so the bot learns nothing.
if (parsed.data.website !== "") {
return { status: "success", message: SUCCESS, fieldErrors: {}, values: {} };
}
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() || h.get("x-real-ip") || "unknown";
const limit = rateLimit(`contact:${ip}`, 3, 10 * 60 * 1000);
if (!limit.allowed) {
const minutes = Math.ceil(limit.retryAfterSeconds / 60);
return {
status: "error",
message: `Too many messages. Please try again in ${minutes} minute(s).`,
fieldErrors: {},
values,
};
}
const { website: _ignored, ...submission } = parsed.data;
const result = await sendContactEmail(submission);
// Only report success once the SMTP server has accepted the message.
if (!result.sent) {
return {
status: "error",
message: "Your message could not be sent right now. Please try again in a moment.",
fieldErrors: {},
values,
};
}
return { status: "success", message: SUCCESS, fieldErrors: {}, values: {} };
}Notice the order: validate, check the honeypot, rate limit, then send. I validate first so a typo doesn't count against someone's three attempts, and I rate limit before the expensive SMTP call.
Also notice the return value is shaped for the UI. It never includes stack traces or SMTP error text, because whatever an action returns is serialized to the client.
Step 5: The Accessible Form with useActionState
useActionState (from react) returns [state, formAction, pending]. You pass formAction to the form's action prop. For a deeper tour of this hook, see React 19 Hooks Explained.
// app/contact/contact-form.tsx
"use client";
import { useActionState, useEffect, useRef } from "react";
import { sendContact, type ContactState } from "./actions";
import { LIMITS } from "@/lib/contact-schema";
const initialState: ContactState = { status: "idle", message: "", fieldErrors: {}, values: {} };
const FIELD_ORDER = ["name", "email", "subject", "message"] as const;
type Field = (typeof FIELD_ORDER)[number];
export function ContactForm() {
const [state, formAction, pending] = useActionState(sendContact, initialState);
const formRef = useRef<HTMLFormElement>(null);
// After each response, move focus to the first invalid field.
useEffect(() => {
const first = FIELD_ORDER.find((field) => state.fieldErrors[field]?.length);
if (first) {
formRef.current?.querySelector<HTMLElement>(`[name="${first}"]`)?.focus();
}
}, [state]);
const fieldProps = (name: Field) => {
const error = state.fieldErrors[name]?.[0];
return {
id: name,
name,
defaultValue: state.values[name] ?? "",
maxLength: LIMITS[name],
"aria-invalid": error ? true : undefined,
"aria-describedby": error ? `${name}-error` : undefined,
};
};
const errorText = (name: Field) => {
const error = state.fieldErrors[name]?.[0];
return error ? <p id={`${name}-error`} className="text-sm text-red-700">{error}</p> : null;
};
return (
<form ref={formRef} action={formAction} noValidate className="flex flex-col gap-5">
{/* Honeypot: off-screen, skipped by keyboard and screen readers */}
<div aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden">
<label htmlFor="website">Leave this field empty</label>
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div>
<label htmlFor="name">Name *</label>
<input type="text" autoComplete="name" aria-required="true" {...fieldProps("name")} />
{errorText("name")}
</div>
<div>
<label htmlFor="email">Email *</label>
<input type="email" autoComplete="email" aria-required="true" {...fieldProps("email")} />
{errorText("email")}
</div>
<div>
<label htmlFor="subject">Subject</label>
<input type="text" {...fieldProps("subject")} />
{errorText("subject")}
</div>
<div>
<label htmlFor="message">Message *</label>
<textarea rows={6} aria-required="true" {...fieldProps("message")} />
{errorText("message")}
</div>
<button type="submit" disabled={pending}>
{pending ? "Sending…" : "Send message"}
</button>
{/* Polite live region: announced without interrupting the user */}
<p role="status" aria-live="polite">
{pending ? "Sending your message…" : state.message}
</p>
</form>
);
}And the page stays a Server Component:
// app/contact/page.tsx
import { ContactForm } from "./contact-form";
export default function ContactPage() {
return (
<main>
<h1>Get in touch</h1>
<ContactForm />
</main>
);
}What makes this form accessible
- Real
<label>elements tied to inputs withhtmlFor/id. aria-invalidmarks broken fields, andaria-describedbypoints to the error text, so screen readers read the message when the field gets focus.- Focus moves to the first invalid field after a failed submit, so keyboard and screen reader users land exactly where they need to act.
- A polite live region (
role="status") announces "Sending…", success, or a general error without stealing focus. - The honeypot is
aria-hiddenandtabIndex={-1}, so real people, including assistive technology users, never meet it.
Keeping user input on error
React 19 resets uncontrolled form fields after a form action completes. That's great on success, but painful on error: imagine typing a long message, mistyping your email, and losing everything.
The fix is already in the code. The action returns values, and each input uses defaultValue={state.values[name]}. On error, the fields come back with what the user typed. On success, values is empty, so the form clears.
Why noValidate?
Native required bubbles look different in every browser and don't match your server messages. With noValidate, the server is the single source of truth, and the shared LIMITS still give you maxLength hints on the client. If you want instant client-side feedback, you can run contactSchema.safeParse in an onBlur handler too, since the schema is plain TypeScript.
Because the form uses a Server Action, it also submits before hydration finishes. That's one of the quiet advantages over a fetch-based form.
Common Next.js Contact Form Mistakes
- Showing success before the email is sent. If the SMTP call fails and you still say "Message sent!", the lead is gone forever. Return success only after
sendMailresolves. - Clearing the form on failure. Users won't retype a long message. Echo their input back.
- Trusting client-side validation. Server Actions are public POST endpoints. Always validate on the server.
- Interpolating raw input into HTML email. Escape every value, every time.
- Using the visitor's email as
from. Many SMTP providers reject it, and it enables spoofing. Use a fixed sender plusreplyTo. - Committing
.envfiles or usingNEXT_PUBLIC_for secrets. Keep SMTP credentials server-side only. - Returning raw error objects. Log details on the server; return a short, friendly message to the client.
- Relying on in-memory rate limiting at scale. It's a speed bump, not a wall, on serverless. Use a shared store when traffic grows.
- A visible or focusable honeypot. If keyboard users can tab into it, real people will fill it and their messages will be silently dropped.
Production Contact Form Checklist
- One Zod 4 schema validates every field on the server with
safeParse - Field-level errors returned with
z.flattenErrorand shown next to each input aria-invalidandaria-describedbyset on invalid fields- Focus moves to the first invalid field after a failed submit
- A polite live region announces pending, success, and error states
- User input is preserved when submission fails
- SMTP credentials stored in environment variables, never in Git
- Fixed
fromaddress, visitor email inreplyTo - All user input escaped in the HTML email body
- Line breaks stripped from anything used in a header
- Hidden, non-focusable honeypot field
- Rate limiting in place, with a shared store planned for scale
- Success shown only after the email is confirmed sent
lib/email.tsimportsserver-only
My Recommendation
For most Next.js 16 sites, start exactly here: a Server Action, a shared Zod schema, Nodemailer over SMTP, a honeypot, and an in-memory limiter. It's small, it's readable, and it covers the real-world threats a contact form faces.
Then upgrade the weakest layer when you need to. Move rate limiting to Redis once you have real traffic, and consider a transactional email provider if deliverability becomes a problem.
If your contact form is the main conversion point on a content-driven site, like the SEO-focused platform in my NutriLeft case study, it's worth treating as a core feature rather than an afterthought. Pair it with a fast page from the Next.js 16 performance guide and you lose fewer leads on both ends.
Related Next.js Security and Forms Guides
- Next.js 16 Authentication →Secure login, sessions, and protected routes with server-side checks.
- React 19 Hooks Explained →How useActionState and useOptimistic simplify forms and mutations.
- How to Build a Secure REST API with Node.js, Express and MongoDB →Validation, JWT auth, and rate limiting on a Node.js backend.
- React Server Components Explained →Where server code runs in Next.js 16 and how to keep secrets out of the browser.
- npm Supply Chain Security →Protecting the dependencies your forms and mailers rely on.
Frequently Asked Questions
Should I use a Server Action or an API route for a Next.js contact form?
Use a Server Action when the form only lives in your Next.js app; it gives you progressive enhancement and less code. Use a Route Handler when other clients need the endpoint or you need exact HTTP status codes like 429.
Do I still need server-side validation if I validate on the client?
Yes. Server Actions are reachable by direct POST requests, so client-side checks can be skipped entirely. Server-side Zod validation is the only validation you can trust.
How do I show Zod errors next to each field?
Call z.flattenError(result.error).fieldErrors in Zod 4 to get a map of field names to message arrays. Return it from the action and render the first message under each input.
Why does my form clear after a failed submission?
React 19 resets uncontrolled form fields after a form action completes. Return the submitted values from your action and use them as each input's defaultValue so they reappear on error.
Is a honeypot enough to stop spam?
A honeypot stops many simple bots, but not targeted ones. Combine it with server-side validation and rate limiting, and add a CAPTCHA only if spam still gets through.
Can I use Gmail with Nodemailer?
Yes, through Gmail's SMTP server with an App Password, which requires 2-Step Verification on the account. For higher volume or better deliverability, a dedicated transactional email service is usually a better fit.
Conclusion
A secure Next.js contact form is not about one clever trick. It is a handful of small, boring layers: server-side Zod validation, escaped email HTML, fixed senders, secrets in environment variables, a honeypot, rate limiting, and honest success messages.
With a Server Action and useActionState, all of that fits in a few short files, and the form stays accessible and forgiving for real people.
Build it once like this, and your contact form becomes a reliable lead channel instead of a spam inbox. For the next step, protect the rest of your app with Next.js 16 authentication.