How to send email in Next.js with the App Router
Sending email from Next.js goes wrong in a specific place: the code works on your laptop and fails once it is deployed, because a laptop can open an SMTP connection and a serverless function usually cannot. This walks through the version that survives the deploy.
4 min read
The shape of the problem
Next.js gives you three places server code can run, and only two of them can send email at all.
| Where your code runs | Can it send email | Why |
|---|---|---|
| Client component | No | Your API key would ship to the browser |
| Server Action or route handler (Node runtime) | Yes | Runs on the server, key stays server-side |
| Route handler (Edge runtime) | Yes, over HTTPS only | No TCP sockets, so no SMTP client |
| Middleware | Avoid | Runs on every matched request and has a tight time budget |
The rule that follows: send over an HTTPS API, from a Server Action or a route handler, never from a component that also renders in the browser.
Store the key
Put the key in .env.local and never prefix it with NEXT_PUBLIC_. Anything carrying that prefix is inlined into the client bundle at build time and is public the moment you deploy.
EMAILSSH_API_KEY=esh_live_your_key_hereAdd the same variable in your host's project settings before you deploy. A missing key at runtime produces a 401 that reads like a code bug and is not one.
A Server Action that sends
Server Actions are the shortest path for a form. The 'use server' directive keeps the whole file off the client bundle, so the key is never exposed.
'use server';
export async function sendWelcome(formData: FormData) {
const email = String(formData.get('email') ?? '');
if (!email.includes('@')) return { ok: false, error: 'Enter an email address' };
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: 'Acme <hello@acme.com>',
to: [email],
subject: 'Welcome to Acme',
html: '<p>Your account is ready.</p>',
text: 'Your account is ready.'
})
});
if (!res.ok) {
const body = await res.text();
console.error('send failed', res.status, body);
return { ok: false, error: 'We could not send that email' };
}
const { id } = (await res.json()) as { id: string; status: string };
return { ok: true, id };
}The form is an ordinary form. No client-side fetch, no JSON handling, no loading state you have to write yourself if you use useFormStatus.
import { sendWelcome } from './actions/send-welcome';
export default function Page() {
return (
<form action={sendWelcome}>
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>
);
}Or a route handler, if something else calls you
Use a route handler when the caller is not your own form: a webhook from Stripe, a cron job, a mobile client. Set the runtime explicitly so a future dependency change cannot silently move you to Edge.
export const runtime = 'nodejs';
export async function POST(request: Request) {
const { to, subject, html } = await request.json();
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: [to], subject, html })
});
return Response.json(await res.json(), { status: res.status });
}That handler is unauthenticated. Anyone who finds the URL can send mail through your account with your reputation attached. Check a session or a shared secret before the fetch, every time.
With the SDK instead of fetch
If you would rather not hand-write headers, the client is a thin wrapper over the same endpoint and works in both runtimes because it uses fetch underneath.
npm install @emails.sh/sdk'use server';
import { Emailssh } from '@emails.sh/sdk';
const mail = new Emailssh(process.env.EMAILSSH_API_KEY!);
export async function sendReceipt(to: string, total: string) {
const { id } = await mail.send({
from: 'Acme <billing@acme.com>',
to: [to],
subject: `Your receipt for ${total}`,
html: `<p>Thanks. You were charged ${total}.</p>`,
text: `Thanks. You were charged ${total}.`
});
return id;
}Before your domain is verified you can send from onboarding@emails.sh, which is the fastest way to prove the wiring works before you touch DNS.
The four things that break after deploy
You used nodemailer with SMTP. It works locally and times out in production. The reasons are worth understanding rather than working around, and they are covered in why nodemailer does not work on Vercel.
You forgot to await. A serverless function is frozen or torn down the moment the response is returned. A floating promise does not finish. Either await the send, or hand it to a queue whose lifetime you control.
Your `from` domain is not verified. Sending as hello@acme.com before acme.com is verified returns a 422 telling you exactly that. Verify the domain, or send from the sandbox address while you build.
You put the send in a component. Any file without 'use server' that a client component imports can end up in the browser bundle, key included. Keep sending code in files that are marked server-only.
Test it without a browser
The fastest check that your key and your domain are both good is one curl call. If this returns a queued id, every failure after it is in your app code, not your account.
curl -X POST https://emails.sh/v1/emails \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "onboarding@emails.sh",
"to": ["you@example.com"],
"subject": "Wiring check",
"text": "If you are reading this, the key works."
}'{ "id": "em_2t9x4k1c7v", "status": "queued" }status: "queued" means accepted, not delivered. Fetch GET /v1/emails/em_2t9x4k1c7v for the delivery events, or subscribe a webhook so your app hears about a bounce instead of discovering it in a support ticket.
What to build next
A welcome email is the shallow end. The two flows that decide whether people can use your product at all are verification and password reset, and both have failure modes that a send call cannot fix on its own. Start with building an email verification flow that actually works, then set up authentication records with SPF, DKIM, and DMARC explained for developers.
If you got here because an assistant scaffolded the app, the same integration in one prompt is on the v0 page and the Bolt page. The full framework guide lives at Next.js integration.
Questions
- Can I send email from a Client Component?
- No. A Client Component runs in the browser, so any key it touches is readable by anyone who opens devtools. Move the send into a Server Action or a route handler and call that from the client.
- Does this work on the Edge runtime?
- Yes, as long as you send over HTTPS. The Edge runtime has
fetchbut no raw TCP sockets, so an HTTPS API works and an SMTP library does not. Setexport const runtime = 'nodejs'if you need Node APIs for something else in the same handler. - Where do I put the API key in a Next.js project?
- In
.env.localfor development and in your host's environment variables for production, without theNEXT_PUBLIC_prefix. Variables carrying that prefix are inlined into the browser bundle at build time. - Why did my email send locally but not after deploying?
- Almost always because the local path used SMTP and the deployed one cannot open the connection, or because the environment variable was never added to the hosting project. Check for a 401 first, then check the transport.
Give your agent an address it can answer from.
Create an inbox