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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
npm install nodemailer
npm install -D @types/nodemailerPut the transporter in its own server-only module so it is created once and reused, rather than rebuilt on every request:
// 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"
},
});SMTP_USER=token-login@your-domain.com
SMTP_PASS=your-token-password
MAIL_FROM="Acme <noreply@your-domain.com>"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
"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
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.
When the caller is external — a webhook or a mobile client:
// 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 });
}
}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. 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
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>
);
}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
});If you are still on the Pages Router, the same transport works from an API route:
// 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 });
}| Error | Cause 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: 535 | password: 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. |
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.
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.
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.
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.
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.
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.