Sending email from Next.js with Resend: the production-ready guide
A hardened, copy-paste walkthrough for wiring Resend into a Next.js App Router project — including input validation, HTML escaping, reply-to handling, rate limiting, domain verification, and audience subscriptions.
Sending email from a Next.js app is one of those tasks that looks trivial until it hits production. The happy path is a single fetch call. The actual path includes input validation, HTML escaping, rate limiting, domain verification, reply-to handling, proper status codes, and error messages that don't leak implementation details. Skip any of those and you end up with either a silently-dropping contact form, an open spam relay, or an XSS injection in your own inbox.
This guide is the hardened version — the exact pattern we ship on every Next.js site we build. It covers the contact form, the newsletter signup, and the pieces around them that the Resend quickstart leaves out.
Why Resend (and when not to use it)
Resend is the developer-first email API that replaced SendGrid and Mailgun as the default choice for Next.js projects around 2023. Its appeal is simple: a clean SDK, React Email support, a generous free tier, and a dashboard that does not make you want to cry. For transactional email — contact forms, order receipts, password resets, newsletter broadcasts — it is hard to beat.
It is not the right tool for every use case:
- Cold outreach and marketing sequences — use a dedicated sales tool (Apollo, Instantly, Woodpecker)
- High-volume transactional email with complex IP warmup needs — Postmark or SES are still strong options
- Inbound email processing — Resend does outbound only; use SendGrid inbound or Postmark for receiving
For everything else, Resend is the lowest-friction path from zero to production-ready email in Next.js.
The 5-minute setup
- Create a Resend account at resend.com and grab an API key
- Install the SDK: npm install resend
- Add RESEND_API_KEY to your .env.local (never commit it)
- Verify your sending domain in the Resend dashboard
- Deploy the route handler below
The hardened route handler
Here is the full contact form route we ship. Every line is there for a reason — I'll break it down after.
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { Resend } from "resend";
const escapeHtml = (input: string) =>
input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const LIMITS = {
name: 120,
email: 160,
subject: 120,
message: 5000,
};
export async function POST(req: Request) {
const apiKey = process.env.RESEND_API_KEY;
const toAddress = process.env.CONTACT_EMAIL ?? "[email protected]";
const fromAddress =
process.env.CONTACT_FROM ?? "Your Brand <[email protected]>";
if (!apiKey) {
console.error("contact: RESEND_API_KEY is not set");
return NextResponse.json(
{ error: "Email service not configured" },
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { name, email, subject, message } = (body ?? {}) as Record<string, unknown>;
if (
typeof name !== "string" ||
typeof email !== "string" ||
typeof message !== "string" ||
!name.trim() ||
!email.trim() ||
!message.trim()
) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
if (
name.length > LIMITS.name ||
email.length > LIMITS.email ||
(typeof subject === "string" && subject.length > LIMITS.subject) ||
message.length > LIMITS.message
) {
return NextResponse.json({ error: "Field too long" }, { status: 400 });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
}
const safeName = escapeHtml(name.trim());
const safeEmail = escapeHtml(email.trim());
const safeSubject = escapeHtml(
typeof subject === "string" && subject.trim() ? subject.trim() : "No subject",
);
const safeMessage = escapeHtml(message.trim()).replace(/\n/g, "<br />");
const resend = new Resend(apiKey);
try {
const { data, error } = await resend.emails.send({
from: fromAddress,
to: [toAddress],
replyTo: email.trim(),
subject: `New inquiry: ${safeSubject}`,
html: `
<div style="font-family: -apple-system, sans-serif; padding: 24px; color: #1a1a1a;">
<h2 style="color: #0ea5e9;">New inquiry</h2>
<p><strong>Name:</strong> ${safeName}</p>
<p><strong>Email:</strong> ${safeEmail}</p>
<p><strong>Subject:</strong> ${safeSubject}</p>
<hr />
<p style="white-space: pre-wrap; line-height: 1.6;">${safeMessage}</p>
</div>
`,
});
if (error) {
console.error("contact: Resend error", error);
return NextResponse.json({ error: "Failed to send" }, { status: 502 });
}
return NextResponse.json({ success: true, id: data?.id });
} catch (err) {
console.error("contact: unexpected error", err);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}Why each piece matters
Fail fast on missing config
A common mistake is shipping a fallback like new Resend(process.env.RESEND_API_KEY || 're_stub'). That makes the form silently succeed in development and silently fail in production when the real key is missing. Explicit 503 when the key is absent means you see the problem in logs immediately and users get a clear error instead of thinking their message went through.
HTML escaping on every user field
This is the most commonly missed security detail in contact-form tutorials. If a malicious visitor types <script>...</script> in the message field and you interpolate it straight into the HTML email template, you've just XSS'd yourself. Every time you read the inbox, the script runs in your email client. Escaping user input before it touches the HTML template closes that hole.
replyTo so you can actually reply
The email is sent from your own verified domain, but the person who filled out the form is the visitor. Setting replyTo to the visitor's email means you can hit Reply in your mail client and it just works. Without this, you end up copy-pasting email addresses from the message body every time.
Proper status codes
- 400 — bad client input (missing fields, invalid email, field too long)
- 502 — upstream service (Resend) returned an error
- 503 — our own misconfiguration (missing API key)
- 500 — unexpected code path; check logs
The frontend can render different messages based on status. 503 means 'there's a config problem on our end' — the user can't fix it, so tell them so. 400 means 'your input isn't valid' — show the user exactly what to fix.
The client-side form
The client side is simpler. It posts JSON, handles three states, and resets on success.
"use client";
import { useState } from "react";
type Status = "idle" | "loading" | "success" | "error";
export function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
const form = e.currentTarget;
const formData = new FormData(form);
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.get("name"),
email: formData.get("email"),
subject: formData.get("subject"),
message: formData.get("message"),
}),
});
if (!res.ok) throw new Error("Failed to send");
setStatus("success");
form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" required />
<input name="email" type="email" autoComplete="email" required />
<input name="subject" type="text" />
<textarea name="message" required />
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Sending..." : "Send message"}
</button>
{status === "success" && <p>Thanks — we'll get back to you soon.</p>}
{status === "error" && <p>Something went wrong. Please try again.</p>}
</form>
);
}Rate limiting (the piece everyone skips)
The hardened route above still has one hole: a bot can hit it 10,000 times a minute. If you're on Vercel, the easiest fix is Upstash's Redis-backed rate limiter. Install @upstash/ratelimit @upstash/redis and add this at the top of your route:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "10 m"),
analytics: true,
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 },
);
}
// ...rest of the handler
}Five submissions per IP per 10 minutes stops every casual bot without affecting real users. You can tune the numbers to your traffic.
Newsletter signups with Resend Audiences
For a newsletter form, you don't want to send an email every time someone signs up — you want to add them to a list. Resend Audiences is the built-in primitive for this, and it's one API call away from your contact route.
// app/api/newsletter/route.ts
import { NextResponse } from "next/server";
import { Resend } from "resend";
export async function POST(req: Request) {
const apiKey = process.env.RESEND_API_KEY;
const audienceId = process.env.RESEND_AUDIENCE_ID;
if (!apiKey || !audienceId) {
return NextResponse.json(
{ error: "Newsletter not configured" },
{ status: 503 },
);
}
const { email } = await req.json();
if (typeof email !== "string" || !email.includes("@")) {
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
}
const resend = new Resend(apiKey);
const { error } = await resend.contacts.create({
email: email.trim(),
unsubscribed: false,
audienceId,
});
if (error) {
console.error("newsletter: Resend error", error);
return NextResponse.json({ error: "Failed to subscribe" }, { status: 502 });
}
return NextResponse.json({ success: true });
}Create the audience in the Resend dashboard, copy the audience ID into RESEND_AUDIENCE_ID, and every signup lands in your list — ready to send a broadcast from the dashboard or a scheduled job.
Server Actions instead of a route handler
If you're using Next.js 14+ and your contact form is inside the same app (not consumed by a third-party client), Server Actions let you skip the entire API route. Fewer files, better type safety, no HTTP layer between the form and the function.
// app/actions/send-contact.ts
"use server";
import { Resend } from "resend";
export async function sendContact(formData: FormData) {
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
if (typeof name !== "string" || typeof email !== "string" || typeof message !== "string") {
return { error: "Invalid input" };
}
// ...same validation and escaping as before
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send({ /* ... */ });
return { success: true };
}In your form, you pass the action straight to the form element: <form action={sendContact}>. No fetch, no JSON, no manual status handling — useFormStatus and useActionState give you loading and error states for free. This is what we use by default on new Next.js projects.
Domain verification: don't skip this
Until you verify the domain you're sending from, Resend restricts delivery to the email on your account. That means your contact form works for you in testing and silently fails for everyone else. To verify, go to Resend → Domains → Add Domain, add the TXT, MX, and DKIM records to your DNS, and wait 5–30 minutes for propagation.
Two practical tips:
- Use a dedicated subdomain like mail.yoursite.com to isolate sending reputation from your main domain
- Enable DMARC alignment and start with a permissive policy (p=none) for the first few weeks, then tighten
The short version
Install Resend, write a route handler with validation and HTML escaping, set replyTo to the visitor's email, return proper status codes, add rate limiting, verify your domain, and ship. The path from blank repo to production-ready email in Next.js is a couple of hours if you copy the patterns above. Skip any of the pieces and you'll end up either debugging silent failures or explaining to a client why their contact form is sending XSS payloads to their inbox.
Frequently asked questions
Is Resend free for a small Next.js project?
Yes. Resend's free tier covers 3,000 emails per month and one verified domain at the time of writing — more than enough for a typical contact form and newsletter on a small site. You only start paying when you need higher volume, more domains, or advanced features like dedicated IPs.
Should I use a route handler or a Server Action for my contact form?
If the form lives in the same Next.js app and doesn't need to be consumed by a third-party client, Server Actions are simpler — fewer files, better types, no HTTP layer. If you need a public API the form talks to, or you're supporting older React versions, stick with a route handler. Both are secure because both run on the server.
Why does my contact form work locally but fail on Vercel?
The most common cause is missing environment variables on the Vercel side. Environment variables in .env.local are never deployed — you have to add them manually in your Vercel project settings under Environment Variables. The second most common cause is sending from an unverified Resend domain, which only delivers to your own account email.
How do I prevent spam on a Next.js contact form without breaking accessibility?
Combine IP-based rate limiting (Upstash or Vercel KV), a honeypot field that real users never see, and optional reCAPTCHA v3 or Turnstile for heavier traffic. Avoid puzzle-based CAPTCHAs — they break screen readers and cut conversion rates. Rate limiting alone usually handles 95% of bot traffic on small sites.