How to Send Email From Next.js (Route Handlers and Server Actions)
You send email from Next.js by calling your email provider from server-side code only: a route handler (app/api/.../route.ts) for anything an external caller or curl should reach, or a server action ('use server') for form submissions and mutations triggered by your own UI. The API key lives in an environment variable that never reaches the browser, and the client component only ever talks to your own endpoint. This guide covers both paths with runnable code, plus the parts most tutorials skip: idempotency, retries, and how to test before real users see anything.
Why email sending belongs on the server
The constraint is simple and unforgiving: anything prefixed with NEXT_PUBLIC_ is inlined into the JavaScript bundle and readable by anyone who opens DevTools. A provider API key in a client component is a public key, and a leaked sending key means someone else can send mail as your domain.
Next.js gives you two server-side entry points in the App Router:
| Route handler | Server action | |
|---|---|---|
| File | app/api/send/route.ts |
Any file with 'use server' |
| Called from | fetch(), curl, webhooks, external services |
Your own components and forms |
| Returns | Response (JSON, status codes) |
A serializable value |
| Best for | Public endpoints, provider webhooks, anything you want to test from a terminal | Form submits, button clicks, mutations |
If you are on the Pages Router, the equivalent is pages/api/send.ts with req and res. The logic is nearly identical; the App Router examples below map over without much effort.
One rule applies to both: the email call happens on the server, and the browser only ever sees your own endpoint.
Not sure your current tool is the right fit? Compare the Best Email Marketing Platforms by price, features and free plans for your exact list size.
Prerequisites
- Node 18.17 or newer (20+ recommended) for Next.js 14 and 15.
- A Next.js project using the App Router.
npx create-next-app@latestwith the App Router option is enough. - A verified sending domain. Do not skip this step — sending from an unverified domain gets you rejected or filtered. Mailerflow handles own-domain sending with guided DKIM, SPF and DMARC setup and automatic verification, which removes most of the guesswork.
- An API key from your provider. For Mailerflow, create it in the dashboard and scope it to transactional sending only.
Install your provider's SDK. For Mailerflow, the package name and import path are listed in the developer docs; the snippets below use a generic yourProvider client so the structure holds regardless of which SDK you install. Check the developer docs for the SDK version you install, since method signatures can shift between majors.
npm install your-provider-sdk
Setup and authentication
Create .env.local in the project root:
EMAIL_API_KEY=your_live_key_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM=hello@yourdomain.com
Confirm .env.local is in .gitignore — Next.js adds it by default, but check. Then create a single shared module so the key and configuration live in one place:
// lib/email.ts
import { YourProvider } from 'your-provider-sdk';
if (!process.env.EMAIL_API_KEY) {
throw new Error('EMAIL_API_KEY is not set');
}
export const emailClient = new YourProvider({
apiKey: process.env.EMAIL_API_KEY,
});
export const FROM_ADDRESS = process.env.EMAIL_FROM!;
Two things matter here. First, scope your key. A key that can only send transactional email has a bounded blast radius if it leaks; a key that can also read and edit contacts does not. Mailerflow's REST API uses scoped keys for exactly this reason. Second, never import this module from a client component. If you do, Next.js will either error at build time or, worse, bundle the key. If you need the send function in a component, call a route handler or a server action instead.
Sending your first email from a route handler
Here is a complete, runnable route handler. The emailClient.send(...) call is illustrative — substitute the method your provider's SDK exposes (for Mailerflow, see the OpenAPI spec and code samples in the developer docs):
// app/api/send/route.ts
import { NextResponse } from 'next/server';
import { emailClient, FROM_ADDRESS } from '@/lib/email';
export async function POST(request: Request) {
let body: { to?: string; subject?: string; html?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { to, subject, html } = body;
if (!to || !subject || !html) {
return NextResponse.json(
{ error: 'to, subject and html are required' },
{ status: 400 }
);
}
try {
const result = await emailClient.send({
from: FROM_ADDRESS,
to,
subject,
html,
});
return NextResponse.json({ id: result.id }, { status: 200 });
} catch (error) {
console.error('Send failed', error);
return NextResponse.json({ error: 'Send failed' }, { status: 502 });
}
}
Test it without leaving the terminal:
curl -X POST http://localhost:3000/api/send \
-H 'Content-Type: application/json' \
-d '{"to":"you@example.com","subject":"Hello","html":"<p>It works.</p>"}'
Inline HTML in a route handler gets unmaintainable fast. Once the copy changes, you are editing TypeScript and redeploying. Use a template with variables instead:
await emailClient.send({
from: FROM_ADDRESS,
to,
template: 'order-confirmation',
variables: {
customerName: 'Sam',
orderId: 'A-10482',
total: '$48.00',
},
});
Mailerflow's transactional email runs on a high-priority lane and supports templates and variables, so the design lives in the editor and the code only passes data. If you would rather not hand-write the HTML at all, the AI email builder takes a description and returns a complete responsive design, subject lines and preview text — useful when you need a template before you have a designer.
Sending from a server action
Server actions suit form submissions. Here is a complete example with a typed result instead of a thrown error:
// app/contact/actions.ts
'use server';
import { emailClient, FROM_ADDRESS } from '@/lib/email';
export type SendResult =
| { ok: true }
| { ok: false; error: string };
export async function sendContactMessage(
_prev: SendResult | null,
formData: FormData
): Promise<SendResult> {
const email = String(formData.get('email') ?? '').trim();
const message = String(formData.get('message') ?? '').trim();
if (!email.includes('@') || message.length < 10) {
return { ok: false, error: 'Please provide a valid email and a longer message.' };
}
try {
await emailClient.send({
from: FROM_ADDRESS,
to: 'team@yourdomain.com',
replyTo: email,
template: 'contact-form',
variables: { email, message },
});
return { ok: true };
} catch (error) {
console.error('Contact send failed', error);
return { ok: false, error: 'Something went wrong. Please try again.' };
}
}
And the form, using useActionState:
// app/contact/contact-form.tsx
'use client';
import { useActionState } from 'react';
import { sendContactMessage, type SendResult } from './actions';
const initialState: SendResult | null = null;
export function ContactForm() {
const [state, formAction, pending] = useActionState(sendContactMessage, initialState);
return (
<form action={formAction}>
<input name="email" type="email" required />
<textarea name="message" required minLength={10} />
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send'}
</button>
{state && !state.ok && <p role="alert">{state.error}</p>}
{state?.ok && <p>Thanks — we will get back to you.</p>}
</form>
);
}
Two gotchas. Server actions run on the server but are invoked from the client, so validate on the server regardless of what the form does — the client-side required and minLength are conveniences, not security. And because the form uses action={formAction}, it degrades gracefully: with JavaScript disabled it still submits.
Handling errors, retries and idempotency
This is where a tutorial becomes production code. The failure modes you will actually hit:
- Network timeouts — the request never completed, or you never saw the response.
- 429 rate limits — you are sending too fast.
- 5xx from the provider — transient, worth retrying.
- 4xx validation errors — your payload is wrong; retrying will fail identically.
- The dangerous one — a timeout where the email did send. Your retry sends a second copy.
Idempotency keys solve the last one. Generate a stable key per logical send — not per attempt — and pass it with the request. The provider deduplicates: the same key twice returns the original result instead of sending again. Mailerflow's REST API supports idempotency keys for exactly this.
// lib/send-with-retry.ts
import { emailClient, FROM_ADDRESS } from './email';
type SendArgs = {
to: string;
template: string;
variables: Record<string, unknown>;
idempotencyKey: string;
};
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
function statusOf(error: unknown): number | null {
if (typeof error === 'object' && error !== null && 'status' in error) {
const status = (error as { status?: unknown }).status;
return typeof status === 'number' ? status : null;
}
return null;
}
export async function sendWithRetry(args: SendArgs, maxAttempts = 4) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await emailClient.send({
from: FROM_ADDRESS,
to: args.to,
template: args.template,
variables: args.variables,
idempotencyKey: args.idempotencyKey,
});
} catch (error) {
const status = statusOf(error);
const isNetworkError = status === null;
const isRetryable = isNetworkError || RETRYABLE.has(status!);
const isLastAttempt = attempt === maxAttempts - 1;
if (!isRetryable || isLastAttempt) throw error;
const backoff = 2 ** attempt * 250 + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, backoff));
}
}
};
Call it with a key derived from the thing that should only happen once:
await sendWithRetry({
to: user.email,
template: 'order-confirmation',
variables: { orderId: order.id },
idempotencyKey: `order-confirmation:${order.id}`,
});
Note the rule in the code: retry only on 429, 5xx, and network errors. A 422 means your payload is wrong and four attempts will produce four identical failures.
Testing before you ship
Work up a ladder rather than jumping straight to real sends.
- Unit test the payload builder. Extract the object you pass to the SDK into a pure function and assert on it. No network, fast, catches most mistakes.
- Integration test the route handler with a mocked SDK. Mock the module, not
fetch:
// app/api/send/route.test.ts
import { describe, it, expect, vi } from 'vitest';
import { POST } from './route';
vi.mock('@/lib/email', () => ({
FROM_ADDRESS: 'hello@example.com',
emailClient: {
send: vi.fn().mockResolvedValue({ id: 'msg_123' }),
},
}));
describe('POST /api/send', () => {
it('rejects a missing subject', async () => {
const request = new Request('http://localhost/api/send', {
method: 'POST',
body: JSON.stringify({ to: 'a@b.com', html: '<p>hi</p>' }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});
- Send a real message to an internal address on your own domain, then check the headers and the spam score.
- Use a test domain or sandbox if your provider offers one, so test sends never touch real inboxes.
After that, the thing that tells you what actually happened is delivery data. Mailerflow has signed real-time webhooks with retries and delivery logs, so a bounce or a complaint shows up somewhere you can query rather than disappearing. Verify SPF and DKIM before you send to real users — an unverified domain is a common reason a technically correct integration still lands in spam.
Complete example: a contact form that sends and confirms
Here is the whole thing wired together. File tree:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
api/
send/
route.ts
lib/
email.ts
send-with-retry.ts
The action validates, sends a notification to your team, and sends a confirmation to the user. Both sends are idempotent, keyed on a submission id generated once per submit:
// app/contact/actions.ts
'use server';
import { randomUUID } from 'node:crypto';
import { sendWithRetry } from '@/lib/send-with-retry';
export type SendResult = { ok: true } | { ok: false; error: string };
export async function submitContact(
_prev: SendResult | null,
formData: FormData
): Promise<SendResult> {
const email = String(formData.get('email') ?? '').trim();
const message = String(formData.get('message') ?? '').trim();
if (!email.includes('@') || message.length < 10) {
return { ok: false, error: 'Please provide a valid email and a longer message.' };
}
const submissionId = randomUUID();
try {
await sendWithRetry({
to: 'team@yourdomain.com',
template: 'contact-form',
variables: { email, message, submissionId },
idempotencyKey: `contact-team:${submissionId}`,
});
await sendWithRetry({
to: email,
template: 'contact-confirmation',
variables: { submissionId },
idempotencyKey: `contact-user:${submissionId}`,
});
return { ok: true };
} catch (error) {
console.error('Contact submission failed', error);
return { ok: false, error: 'Something went wrong. Please try again.' };
}
}
Swap sendWithRetry for your own provider's client and the rest of the structure holds.
Troubleshooting
process.env.EMAIL_API_KEY is undefined. Either .env.local is missing or the variable is being read in a client component. Restart the dev server after editing .env.local; Next.js does not hot-reload it.
Emails land in spam. The sending domain is not verified, or DMARC is missing. Finish DKIM, SPF and DMARC setup before sending to real users.
401 from the API. The key is scoped to the wrong permission, or there is a trailing space or newline in the environment variable. Log the key length, never the key.
Duplicate emails. A retry ran without an idempotency key, or the key was generated per attempt instead of per logical send.
Build error about server-only code. A client component imported lib/email.ts. Move the call behind a route handler or a server action.
Timeouts on serverless functions. The default max duration may be shorter than your retry budget. Either raise it or reduce maxAttempts.
FAQ
Can I send email directly from a client component? No. Any key in a client component is public. Call your own route handler or server action and keep the provider key on the server.
Do I need a separate backend? Not for most projects. Next.js route handlers and server actions run on the server, so a separate service is only worth it when you need long-running jobs or shared logic across multiple apps.
How do I keep the API key out of the bundle? Store it in .env.local without the NEXT_PUBLIC_ prefix, read it only in server code, and never import that module from a file with 'use client' at the top.
What is an idempotency key and when do I need one? It is a stable identifier for a logical send that lets the provider deduplicate retries. Use one whenever a send might be retried — which is any send that matters.
Should I use a route handler or a server action? Route handler if external callers, webhooks or curl need to reach it. Server action if it is only triggered by your own UI.
How do I send to multiple recipients? Pass an array to to, or use cc and bcc. For anything beyond a handful of people, send individually so each recipient gets their own tracking and unsubscribe context.
If you want to try this end to end, the Mailerflow REST API gives you scoped keys, idempotency keys and an OpenAPI spec to generate a client from. Start free.


