Guide · 8 min

Send email from Next.js over SMTP

A route handler, a server action, and the runtime setting that quietly breaks SMTP on the edge.

Next.js needs no email SDK — Nodemailer over plain SMTP is enough. The one thing that catches everyone is the runtime: on the edge runtime there is no TCP, so no SMTP, and the error never says so.

The four settings

Wherever the send ends up running, it needs the same four values. Create them under SMTP credentials — one per app, so you can revoke one without breaking the others.

.env
SMTP_HOST=smtp.email4vibecoder.com
SMTP_PORT=587
SMTP_USER=your-username
SMTP_PASS=your-password
MAIL_FROM="Your App <[email protected]>"

MAIL_FROM has to be on a domain you have verified in Domains. That is what lets us DKIM-sign the message; send from an unverified domain and the message is rejected rather than delivered unsigned.

The one line everybody misses

Next.js can run your server code on two different runtimes. The edge runtime is a trimmed-down environment without Node's net module — so it cannot open a TCP socket, and SMTP cannot work there. Nodemailer will either fail to bundle or fail at runtime with a module-not-found error for something you never imported.

app/api/send/route.ts
export const runtime = 'nodejs';

Put that in any route handler that sends mail. It is the default today, but it is also the first thing a deployment platform or a stray config file will change for you, and the resulting error never mentions SMTP. The same applies to middleware, which is always edge — never send email from middleware.

A route handler that sends

app/api/send/route.ts
import nodemailer from 'nodemailer';
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';

// Module scope, so a warm lambda reuses the connection instead of
// opening a new one for every message.
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

export async function POST(request: Request) {
  const { to, subject, html } = await request.json();

  try {
    await transporter.sendMail({ from: process.env.MAIL_FROM, to, subject, html });
    return NextResponse.json({ ok: true });
  } catch (err) {
    console.error('send failed', err);
    return NextResponse.json({ error: 'Could not send email' }, { status: 502 });
  }
}

Two details worth keeping. The transporter is created once at module scope — a new one per request opens a fresh connection and authenticates again every time. And the real error is logged while the response stays vague: SMTP replies sometimes quote the recipient address back, which you do not want to hand to an anonymous caller.

Or a server action

If the send is the result of a form submission, a server action is tidier — there is no endpoint to guard, because the function is only reachable through the form:

app/actions.ts
'use server';

import { transporter } from '@/lib/mailer';

export async function subscribe(formData: FormData) {
  const email = String(formData.get('email') ?? '');
  if (!email.includes('@')) return { error: 'Enter a valid email address' };

  await transporter.sendMail({
    from: process.env.MAIL_FROM,
    to: email,
    subject: 'Welcome aboard',
    html: '<p>Thanks for signing up.</p>',
  });

  return { ok: true };
}

Environment variables

Keep the SMTP values server-only. In Next.js that means never prefixing them with NEXT_PUBLIC_ — that prefix is what inlines a value into the browser bundle, and an SMTP password there is a credential given away.

.env.local
SMTP_HOST=smtp.email4vibecoder.com
SMTP_USER=your-username
SMTP_PASS=your-password
MAIL_FROM="Your App <[email protected]>"

Add the same four to your hosting provider's environment settings and redeploy — .env.local is local only, by design.

Check that it actually sent

Send one message to yourself, then open Activity. Every message we accept is listed there with the receiving server's own reply, so you can tell the difference between three very different failures: the code never ran, we rejected it, or the recipient's server did.

If it was accepted and delivered but landed in spam, that is a separate problem with a separate fix — see why emails go to spam.

Common failures

What you seeWhat it means
Module not found: net / dns / tlsThe code is being bundled for the edge runtime. Add export const runtime = 'nodejs'.
Works in dev, fails in productionEnvironment variables missing on the host, or a different runtime in production.
Function timeout on a serverless hostA transporter created per request, or a blocked port. Reuse one at module scope.
Emails arrive twice in developmentReact strict mode double-invokes effects. Send from the server, not from an effect in a client component.