How to Send Email from Next.js in 2026: Route Handlers, Server Actions, and the Pitfalls
Every Next.js app eventually sends email — signup confirmations, magic links, contact forms, receipts. The App Router gives you three good places to do it and several tempting-but-wrong ones. Here are the patterns that hold up, using plain fetch against an email API (the examples use MailBlastr's /emails endpoint; the shapes port to any provider).
Rule zero: server-side only
Your email API key authorizes sending as your domain. It must live in server-only environment variables — MAILBLASTR_API_KEY, never NEXT_PUBLIC_* — and be used only from route handlers, server actions, or other server code. A "contact form that emails from the client" is a stolen key in waiting.
Pattern 1: route handler (API endpoint)
The classic shape — a POST /api/contact your client calls:
// app/api/contact/route.ts
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { name, email, message } = await req.json();
if (!email || !message) {
return NextResponse.json({ error: 'missing fields' }, { status: 422 });
}
const res = await fetch('https://www.mailblastr.com/api/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAILBLASTR_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
domain: 'acme.com',
from: 'Acme Contact <contact@acme.com>',
to: ['support@acme.com'],
reply_to: email,
subject: `Contact form: ${name}`,
text: message,
}),
});
if (!res.ok) return NextResponse.json({ error: 'send failed' }, { status: 502 });
return NextResponse.json({ ok: true });
}
Notes that save debugging time: set reply_to to the visitor (so support replies go to them, not to your own domain), validate before sending, and rate-limit public forms — an unprotected contact endpoint becomes someone's spam cannon.
Pattern 2: server action (forms without an API route)
// app/signup/actions.ts
'use server';
export async function signup(formData: FormData) {
const user = await createUser(formData);
await fetch('https://www.mailblastr.com/api/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAILBLASTR_API_KEY}`,
'Content-Type': 'application/json',
// Business-event key: retries and double-submits can't double-send.
'Idempotency-Key': `welcome-${user.id}`,
},
body: JSON.stringify({
domain: 'acme.com',
from: 'Acme <hello@acme.com>',
to: [user.email],
subject: 'Welcome to Acme',
html: `<p>You're in, ${user.name}.</p>`,
}),
});
redirect('/onboarding');
}
The important line is the idempotency key derived from the business event: welcome-${user.id} means the welcome email for this user can only ever send once, no matter how many times the action retries.
Pattern 3: after() — don't block the response
A welcome email should not add 300ms to signup. Next 15's after() runs work once the response has streamed:
import { after } from 'next/server';
export async function signup(formData: FormData) {
const user = await createUser(formData);
after(() => sendWelcomeEmail(user)); // fires post-response
redirect('/onboarding');
}
Use it for nice-to-have mail (welcomes, notifications). Keep critical sends — password resets, magic links — in the request path, where a failure can surface to the user as "try again" instead of silently never arriving.
The pitfalls checklist
- SMTP libraries in serverless. Connection setup per invocation, blocked ports on some hosts, pooling that assumes a persistent process. The API-vs-SMTP breakdown covers why one HTTPS call is the right fit.
- Dev-mode double sends. Strict Mode double-invokes effects; never send from
useEffect. Sends belong in actions/handlers, and idempotency keys make accidental repeats harmless. - Unverified domains. The API will 422 until your sending domain passes DKIM/SPF/DMARC verification — do that before writing code, it's the only step with DNS propagation wait.
- No delivery feedback. Fire-and-forget works until a user says "I never got the reset link." Wire bounce and delivery webhooks to a route handler so your app knows.
- Templates in string literals. Fine for day one; move to managed templates or a React Email pipeline when marketing wants to edit copy without a deploy.
The complete working setup — env, domain verification, first send, webhooks — is in the Next.js quickstart, and the free tier (3,000 emails/month) covers a side project's entire email volume without a card.
Frequently asked questions
Can I send email directly from a Next.js client component?
No — and any setup that appears to is leaking credentials. Email must be sent from server code (route handler, server action, or server-side function), where your API key lives in environment variables the browser never sees. A client component should call your server endpoint, which then calls the email API.
Should I use SMTP (nodemailer) or an email API in Next.js?
An HTTPS API. Serverless functions pay SMTP's multi-round-trip connection setup on every cold invocation, some platforms block SMTP ports outright, and nodemailer's connection pooling assumes a long-lived process that serverless doesn't give you. One HTTPS request fits the model exactly.
How do I send email without slowing down the page response?
Use next/server's after() (or waitUntil on the edge runtime) to run the send after the response streams — the user's signup completes instantly and the welcome email goes out milliseconds later. For anything critical (password resets), keep it in the request path so you can surface failures.
Why did my email send twice?
Retries — yours, React's, or the platform's. Networks time out after the send succeeded, users double-click submit, and dev-mode double-invocation can fire effects twice. Pass an Idempotency-Key derived from the business event (like `signup-{userId}`) so the provider deduplicates; retries then return the original result instead of sending again.
Does this work on the Edge runtime?
Yes — an HTTPS email API is fetch-based, so it works from Edge route handlers and middleware-adjacent code where Node APIs (and SMTP libraries) don't. Keep the API key in server env and the call pattern is identical.