Docs / Next.js

Send email in Next.js over SMTP

Next.js has no mail layer, so sending email means calling an SMTP client from server-side code — a Server Action, a Route Handler or an API route. This guide wires Nodemailer up to Postwing for the App Router, covers React Email templates, and explains the runtime constraint that trips most people up.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Install Nodemailer

bash
npm install nodemailer
npm install -D @types/nodemailer

Create the transport

Put the transporter in its own server-only module so it is created once and reused, rather than rebuilt on every request:

lib/mailer.ts
// lib/mailer.ts
import nodemailer from "nodemailer";

// Module scope: one transport reused across invocations, not one per request.
export const transporter = nodemailer.createTransport({
  host: "smtp.postwing.app",
  port: 587,
  secure: false,        // false on 587 — STARTTLS is negotiated
  requireTLS: true,
  auth: {
    user: process.env.SMTP_USER!,
    pass: process.env.SMTP_PASS!,   // "pass", not "password"
  },
});
.env.local
SMTP_USER=token-login@your-domain.com
SMTP_PASS=your-token-password
MAIL_FROM="Acme <noreply@your-domain.com>"
That prefix inlines the value into the JavaScript bundle shipped to every visitor. An SMTP token exposed that way lets anyone send mail as your domain. Keep these variables server-only.

Send from a Server Action

For a form in your own app this is the shortest path — no API route, no client-side fetch, and it works before JavaScript loads:

app/contact/actions.ts
// app/contact/actions.ts
"use server";

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

export async function sendContactEmail(formData: FormData) {
  const email = String(formData.get("email"));
  const message = String(formData.get("message"));

  await transporter.sendMail({
    from: process.env.MAIL_FROM,
    to: "sales@your-domain.com",
    replyTo: email,            // From stays on your domain; replies reach the visitor
    subject: "New contact form submission",
    text: message,
  });

  return { ok: true };
}
app/contact/page.tsx
// app/contact/page.tsx
import { sendContactEmail } from "./actions";

export default function ContactPage() {
  return (
    <form action={sendContactEmail}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

Note replyTo: the from address stays on your verified domain, which is what DKIM and SPF are published for, while replies still reach the visitor. Putting their address in from is what makes contact-form mail fail DMARC.

Send from a Route Handler

When the caller is external — a webhook or a mobile client:

app/api/send/route.ts
// app/api/send/route.ts
import { NextResponse } from "next/server";
import { transporter } from "@/lib/mailer";

// Nodemailer opens a TCP socket — it cannot run on the Edge runtime.
export const runtime = "nodejs";

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

  try {
    const info = await transporter.sendMail({
      from: process.env.MAIL_FROM,
      to,
      subject,
      text,
    });
    return NextResponse.json({ ok: true, messageId: info.messageId });
  } catch (error) {
    console.error("Email failed", error);
    return NextResponse.json({ ok: false }, { status: 502 });
  }
}
Nodemailer opens a raw TCP socket, which the Edge runtime does not allow — only fetch is available there. Declare export const runtime = "nodejs" on any handler that sends email, or use the REST API over HTTPS instead, which works on both.

HTML emails with React Email

Hand-writing table-based email HTML is miserable. React Email lets you build the message as components and render it to a string:

emails/order-confirmed.tsx
// emails/order-confirmed.tsx
import { Html, Head, Body, Container, Heading, Text } from "@react-email/components";

export default function OrderConfirmed({ orderId }: { orderId: number }) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: "sans-serif" }}>
        <Container>
          <Heading>Order #{orderId} confirmed</Heading>
          <Text>Your order ships tomorrow.</Text>
        </Container>
      </Body>
    </Html>
  );
}
javascript
import { render, toPlainText } from "@react-email/render";
import OrderConfirmed from "@/emails/order-confirmed";

const html = await render(<OrderConfirmed orderId={order.id} />);

await transporter.sendMail({
  from: process.env.MAIL_FROM,
  to: order.email,
  subject: `Order #${order.id} confirmed`,
  html,
  text: toPlainText(html),   // always include the plain-text part
});

Pages Router

If you are still on the Pages Router, the same transport works from an API route:

pages/api/send.ts
// pages/api/send.ts — Pages Router equivalent
import type { NextApiRequest, NextApiResponse } from "next";
import { transporter } from "@/lib/mailer";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") return res.status(405).end();

  await transporter.sendMail({
    from: process.env.MAIL_FROM,
    to: req.body.to,
    subject: req.body.subject,
    text: req.body.text,
  });

  res.json({ ok: true });
}

Troubleshooting

ErrorCause and fix
The edge runtime does not support Node.js 'net' module Add export const runtime = "nodejs" to the handler.
Module not found: Can't resolve 'dns' at build time The mailer was imported into a Client Component. Add import "server-only" to catch it early.
EAUTH — Invalid login: 535password: used instead of pass:, or wrong token credentials.
ETIMEDOUT after deploying Platform blocks outbound SMTP. Try 8587, or switch to the REST API.
process.env.SMTP_USER is undefined in production The variable exists in .env.local only — add it to the deployment environment.
Function times out on a cold start SMTP handshake plus cold start exceeds the limit. Send from a queue or a background job.

Frequently asked questions

Can I send email from the Edge runtime in Next.js?

No. Nodemailer opens a raw TCP socket, and the Edge runtime only allows fetch. Add export const runtime = 'nodejs' to any Route Handler that sends email. Server Actions already run on the Node runtime unless you have opted the whole route into Edge.

Should I send email from a Server Action or a Route Handler?

Use a Server Action when the trigger is a form in your own app — you get progressive enhancement and no API surface to secure. Use a Route Handler when something external calls it: a webhook, a mobile client, or a third-party service.

Why does Nodemailer fail to build or bundle in Next.js?

It was imported into a Client Component. Anything importing lib/mailer must be server-only — keep it out of files marked 'use client', and add import 'server-only' to the module so a mistaken import fails at build time instead of leaking your credentials into the browser bundle.

Why are my SMTP environment variables undefined in production?

Variables without the NEXT_PUBLIC_ prefix are only available on the server, which is correct here — never prefix mail credentials. If they are undefined server-side too, they were not added to the deployment environment, only to the local .env.local file.

How do I write HTML emails in Next.js?

React Email lets you build the message as React components and render them to an HTML string with @react-email/render. Pass that string as html and its plain-text rendering as text. It keeps email templates in the same language and component model as the rest of the app.

Why does sending work locally but time out when deployed?

Many serverless platforms block outbound SMTP ports, and some block them entirely. Try the high ports 8587 or 8465 first. If the platform blocks all outbound SMTP, use the REST API over HTTPS instead — it is a plain fetch and works on any runtime, including Edge.

Next steps