Send Emails with Node.js — Nodemailer, Resend, and SendGrid
Compare and implement email sending in Node.js with Nodemailer, Resend, and SendGrid. SMTP setup, API-based sending, templates, and production pitfalls.
Email is deceptively simple. sendEmail(to, subject, body) — how hard can it be? Then you discover SMTP authentication, SPF records, DKIM signing, bounce handling, spam filters, HTML rendering inconsistencies across 50+ email clients, and the fact that Gmail might just decide to put your perfectly legitimate email in the spam folder.
This guide covers three approaches to sending email from Node.js, when to use each, and the production pitfalls that will bite you.
Which Service Should You Use?
| Nodemailer (SMTP) | Resend (API) | SendGrid (API) | |
|---|---|---|---|
| Best for | Simple apps, self-hosted | Developer-first SaaS | High-volume transactional |
| Setup complexity | Medium | Low | Medium |
| Free tier | N/A (SMTP provider dependent) | 3,000 emails/month | 100 emails/day |
| HTML templates | Manual | React Email | Dynamic templates |
| Deliverability | Depends on SMTP provider | Good | Excellent |
| Pricing at scale | Cheapest (use AWS SES) | ~$20/month for 50k | ~$20/month for 50k |
| Webhook support | No | Yes | Yes |
Method 1: Nodemailer (SMTP)
Nodemailer is the oldest and most flexible option. It speaks SMTP, which means it works with any email provider: Gmail, Outlook, AWS SES, Mailgun, or your own mail server.
npm install nodemailer
Basic Setup
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false, // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, // App password, NOT your Gmail password
},
});
async function sendEmail({ to, subject, html, text }) {
const info = await transporter.sendMail({
from: '"My App" <noreply@myapp.com>',
to,
subject,
text, // Plain text fallback
html, // HTML version
});
console.log("Message sent:", info.messageId);
return info;
}
Gmail-Specific Gotcha
Gmail doesn't let you use your regular password for SMTP. You need an App Password:
- Enable 2-Factor Authentication on your Google account
- Go to Security → App Passwords
- Generate a password for "Mail"
- Use that 16-character password as
SMTP_PASS
Using AWS SES with Nodemailer
AWS SES is the cheapest option for volume: $0.10 per 1,000 emails.
const transporter = nodemailer.createTransport({
host: "email-smtp.us-east-1.amazonaws.com",
port: 587,
secure: false,
auth: {
user: process.env.AWS_SES_SMTP_USER,
pass: process.env.AWS_SES_SMTP_PASS,
},
});
SES requires you to verify your sending domain first. In sandbox mode, you can only send to verified email addresses. Request production access through the AWS console.
HTML Email Template
function welcomeEmail(name) {
return
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #333; font-size: 24px;">Welcome, ${name}!</h1>
<p style="color: #666; line-height: 1.6;">
Thanks for signing up. Here's what you can do next:
</p>
<a href="https://myapp.com/getting-started"
style="display: inline-block; background: #0070f3; color: white;
padding: 12px 24px; border-radius: 6px; text-decoration: none;
font-weight: 600; margin: 16px 0;">
Get Started
</a>
<p style="color: #999; font-size: 12px; margin-top: 32px;">
You're receiving this because you signed up at myapp.com
</p>
</body>
</html>
;
}
HTML email development is stuck in 2005. You can't use flexbox, grid, or modern CSS. Everything is inline styles and tables. That's just reality. Libraries like MJML and React Email help, but the output is still table-based HTML.
Method 2: Resend (API-Based)
Resend is built by developers, for developers. The API is clean, it supports React Email for templates, and setup takes about two minutes.
npm install resend
const { Resend } = require("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
async function sendEmail({ to, subject, html }) {
const { data, error } = await resend.emails.send({
from: "My App <noreply@myapp.com>",
to,
subject,
html,
});
if (error) {
throw new Error(Resend error: ${error.message});
}
return data;
}
React Email Templates
The killer feature of Resend's ecosystem is React Email. You write email templates as React components:
npm install @react-email/components
// emails/welcome.jsx
import {
Html, Head, Body, Container, Section,
Text, Button, Heading, Hr
} from "@react-email/components";
export function WelcomeEmail({ name, loginUrl }) {
return (
<Html>
<Head />
<Body style={{ fontFamily: "-apple-system, sans-serif", background: "#f6f6f6" }}>
<Container style={{ background: "#fff", padding: "40px", borderRadius: "8px" }}>
<Heading style={{ fontSize: "24px" }}>
Welcome, {name}!
</Heading>
<Text style={{ color: "#666", lineHeight: "1.6" }}>
Your account is ready. Click below to log in and start exploring.
</Text>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={loginUrl}
style={{
background: "#0070f3",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
fontWeight: "600",
}}
>
Log In to Your Account
</Button>
</Section>
<Hr />
<Text style={{ color: "#999", fontSize: "12px" }}>
If you didn't create this account, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
Then render it server-side:
import { render } from "@react-email/render";
import { WelcomeEmail } from "../emails/welcome";
const html = await render(
WelcomeEmail({ name: "Alex", loginUrl: "https://myapp.com/login" })
);
await resend.emails.send({
from: "My App <noreply@myapp.com>",
to: "alex@example.com",
subject: "Welcome to My App",
html,
});
Method 3: SendGrid
SendGrid is the enterprise choice. More configuration options, better analytics, and a dynamic template engine.
npm install @sendgrid/mail
const sgMail = require("@sendgrid/mail");
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
async function sendEmail({ to, subject, html, text }) {
const msg = {
to,
from: { email: "noreply@myapp.com", name: "My App" },
subject,
text,
html,
};
try {
await sgMail.send(msg);
} catch (err) {
console.error("SendGrid error:", err.response?.body || err.message);
throw err;
}
}
Dynamic Templates
SendGrid lets you design templates in their web UI and reference them by ID:
await sgMail.send({
to: "user@example.com",
from: "noreply@myapp.com",
templateId: "d-xxxxxxxxxxxx",
dynamicTemplateData: {
name: "Alex",
resetUrl: "https://myapp.com/reset?token=abc123",
},
});
Non-technical team members can edit templates in the SendGrid dashboard without touching code.
Production Essentials
Domain Authentication (Required for Deliverability)
Every email provider requires you to verify your sending domain with DNS records:
| Record Type | Purpose |
|---|---|
| SPF | Declares which servers can send email for your domain |
| DKIM | Cryptographic signature proving the email wasn't tampered with |
| DMARC | Policy telling receiving servers what to do with unauthenticated email |
| Return-Path | Where bounce notifications go |
Retry Logic
Email sending can fail for transient reasons. Add retry logic:
async function sendWithRetry(emailFn, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await emailFn();
} catch (err) {
if (attempt === maxRetries) throw err;
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await new Promise((r) => setTimeout(r, delay));
console.warn(Email send attempt ${attempt} failed, retrying...);
}
}
}
// Usage
await sendWithRetry(() =>
resend.emails.send({
from: "noreply@myapp.com",
to: "user@example.com",
subject: "Your receipt",
html: receiptHtml,
})
);
Queue Critical Emails
For important emails (password resets, receipts, verification links), don't send them inline with your API request. Use a queue:
// Simple in-memory queue (use BullMQ or similar for production)
const emailQueue = [];
function queueEmail(emailData) {
emailQueue.push(emailData);
}
// Process queue
setInterval(async () => {
if (emailQueue.length === 0) return;
const email = emailQueue.shift();
try {
await sendEmail(email);
} catch (err) {
console.error("Failed to send queued email:", err);
emailQueue.push(email); // Re-queue on failure
}
}, 1000);
For production, use a proper job queue like BullMQ with Redis. In-memory queues lose everything on process restart.
Testing Without Sending Real Emails
Use Ethereal (built into Nodemailer) for development:
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
const info = await transporter.sendMail({ / ... / });
console.log("Preview URL:", nodemailer.getTestMessageUrl(info));
// Opens a web page showing the sent email
Common Mistakes
- Sending from
noreply@gmail.comor a domain you don't own. SPF/DKIM checks will fail. Always send from a domain you control.
- Including the user's password in a welcome email. Never send passwords in email. Send a login link or a "set your password" link.
- Not including a plain text version. Some email clients (and spam filters) prefer plain text. Always set both
htmlandtext.
- Using
process.env.NODE_ENVto decide whether to actually send. This leads to accidentally not sending emails in staging, or accidentally sending test emails in production. Use an explicitEMAIL_ENABLEDflag.
- Not handling bounces. Hard bounces (invalid email addresses) hurt your sender reputation. Use webhook events from your provider to remove invalid addresses.