This guide shows how to send email with Next.js using the App Router. You will send email from a Route Handler and from a Server Action, keep your API key out of the browser, build email templates and handle errors in a way users understand. The only dependency is the fetch built into Next.js.
Prerequisites
- Next.js 15 or later with the App Router (React 19, for
useActionState). - An api.email API key in
.env.localasAPI_EMAIL_KEY. Do not prefix it withNEXT_PUBLIC_: anything with that prefix is bundled into client JavaScript and visible to every visitor. - A verified sending domain. Add the two CNAME records described in the domains documentation.
A small server-only helper
Put the API call in one module that only server code imports. The server-only package makes the build fail if a client component imports it by mistake, which is cheap insurance for a secret.
// lib/email.ts
import 'server-only';
type Email = { to: string | string[]; subject: string; text: string; html?: string; idempotencyKey?: string };
export async function sendEmail({ idempotencyKey, ...email }: Email): Promise<string> {
const res = await fetch('https://api.email/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_EMAIL_KEY}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify({ from: 'Acme <[email protected]>', ...email }),
cache: 'no-store',
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body.id; // "em_…"
}
cache: 'no-store' matters in Next.js: it tells the framework never to cache or deduplicate this request, which would be wrong for something with a side effect.
Send from a Route Handler
Route Handlers suit webhooks, mobile apps and any client that calls your backend over HTTP.
// app/api/contact/route.ts
import { sendEmail } from '@/lib/email';
export async function POST(request: Request) {
const { email, message } = await request.json();
if (typeof email !== 'string' || !email.includes('@')) {
return Response.json({ error: 'Enter a valid email address.' }, { status: 400 });
}
const id = await sendEmail({
to: '[email protected]',
subject: `Contact form: ${email}`,
text: message,
});
return Response.json({ id });
}
Validate input before you send. A contact form that forwards anything to anyone is an open relay for spammers, so always fix the recipient on the server and only take the content from the user.
Send from a Server Action
Server Actions are the most direct option for forms in your own app. The function runs on the server, so it can read the API key, and the form works even before JavaScript loads. Remember that every Server Action is a public POST endpoint: check who is calling it before you send anything.
// app/invite/page.tsx
import { sendEmail } from '@/lib/email';
import { getCurrentUser } from '@/lib/auth'; // your auth library
import { inviteLimiter } from '@/lib/rate-limit'; // e.g. a Redis-backed limiter
async function invite(formData: FormData) {
'use server';
const user = await getCurrentUser();
if (!user?.canInvite) throw new Error('Not allowed');
if (!(await inviteLimiter.allow(user.id))) throw new Error('Too many invitations, try again later');
const to = String(formData.get('email'));
await sendEmail({
to,
subject: `${user.name} invited you to Acme`,
text: `Join your team on Acme: https://acme.com/join/${user.teamId}`,
idempotencyKey: `invite-${user.teamId}-${to}`,
});
}
export default function InvitePage() {
return (
<form action={invite}>
<input name="email" type="email" required />
<button type="submit">Send invite</button>
</form>
);
}
The permission check and the rate limit stop strangers from using your invite form to send email on your behalf. The idempotency key makes a double-clicked submit button harmless: the second request returns the first result instead of sending a second invitation.
Email templates
Keep templates as plain functions that return the subject, the text and the HTML for one email. They are easy to test, run anywhere on the server and keep the two versions of the message side by side. Escape anything that comes from users before you put it into HTML.
// emails/welcome.ts
const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export function welcomeEmail(name: string) {
return {
subject: 'Your Acme workspace is ready',
text: `Hi ${name},\n\nWelcome to Acme. Your workspace is ready: https://acme.com/app`,
html: `<p>Hi ${escape(name)},</p><p>Welcome to Acme. <a href="https://acme.com/app">Open your workspace</a>.</p>`,
};
}
// await sendEmail({ to: user.email, ...welcomeEmail(user.name) });
If you prefer writing templates as React components, use a library made for email, such as React Email, which renders components to email-safe HTML on the server. Email clients support far less CSS than browsers, so inline styles and simple tables still work best whichever approach you choose.
Edge and Node.js runtimes
The API is plain HTTPS, so sending works on both the Node.js and the Edge runtime. Choose Node.js when you need Node APIs, for example to read a PDF from disk for an attachment. On serverless platforms, prefer sending from the request that triggered the email and keep the call short; for slow or bulk work, hand the job to a queue or a background function.
Handling errors
Every error from the API has the shape {"error": {"code", "message", "hint"}}. Show users a friendly message, log the code and hint for yourself, and retry only rate_limited and server errors. Validation problems and domain_not_verified will not fix themselves on retry. If you retry, reuse the same idempotency key so the retry cannot create a duplicate.
To follow delivery after the request, add a webhook endpoint as a Route Handler and verify the api-email-signature header against the raw body, as described in the webhooks documentation. Read the body with await request.text() before parsing it, because the signature is computed over the exact bytes.
Show progress and errors in the form
With Server Actions, useActionState from React lets a form show a pending state and the action’s result without writing an API client. Return a small object from the action, such as { ok: true } or { error: 'Try again in a minute.' }, and render it next to the button. Keep the messages human: users don’t need to see rate_limited, but they do need to know whether to try again.
Protect public forms
Any form that sends email on behalf of anonymous visitors, such as a contact form or a “send me a link” field, will be found by bots. Fix the recipient on the server, add a rate limit per IP or per email address, and consider a challenge such as Cloudflare Turnstile for high-traffic pages. These steps protect your sending reputation as much as your inbox: a burst of abusive messages from your domain hurts the delivery of your real transactional email too.
Test without sending real email
Use a test API key in development, preview deployments and CI. Test keys run the same validation, except that the sending domain doesn’t have to be verified yet, and return the same ids and webhook events, but nothing reaches a real inbox, so preview environments can exercise invitations and password resets safely. Switching to production is a change of environment variable in your hosting settings, not of code.
Production checklist
- Set
API_EMAIL_KEYin your hosting provider’s environment settings for each environment, with a separate key per environment. - Never import the email helper from a client component;
server-onlyenforces this. - Use
cache: 'no-store'on every request that sends. - Add idempotency keys to anything a user can trigger twice.
- Publish a DMARC record such as
v=DMARC1; p=noneif your domain has none yet.