---
title: How to send email in Next.js with the App Router
metaTitle: How to send email in Next.js (App Router, 2026)
description: A complete Next.js App Router email setup: a Server Action, a route handler, where the API key lives, and the runtime mistakes that break it in production.
date: 2026-07-28
author: emails.sh
tags: Tutorials
---

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.

## 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.

```bash
EMAILSSH_API_KEY=esh_live_your_key_here
```

Add 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.

```ts caption="app/actions/send-welcome.ts"
'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`.

```ts caption="app/page.tsx"
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.

```ts caption="app/api/send/route.ts"
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.

```bash
npm install @emails.sh/sdk
```

```ts caption="app/actions/send-receipt.ts"
'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](/blog/nodemailer-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.

```bash
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."
  }'
```

```json
{ "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](/blog/email-verification-flow), then set up authentication records with [SPF, DKIM, and DMARC explained for developers](/blog/spf-dkim-dmarc-explained).

If you got here because an assistant scaffolded the app, the same integration in one prompt is on [the v0 page](/for/v0) and [the Bolt page](/for/bolt). The full framework guide lives at [Next.js integration](/with/next.js).

## 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 `fetch` but no raw TCP sockets, so an HTTPS API works and an SMTP library does not. Set `export 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.local` for development and in your host's environment variables for production, without the `NEXT_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.

## Related

- [Why nodemailer does not work on Vercel](/blog/nodemailer-vercel)
- [Building an email verification flow that actually works](/blog/email-verification-flow)
- [SPF, DKIM, and DMARC explained for developers](/blog/spf-dkim-dmarc-explained)
- [Next.js integration guide](/with/next.js)
- [Send email from v0](/for/v0)
- [Send email from Bolt](/for/bolt)
