March 26, 20268 min read

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 node.js nodemailer resend backend
Ad 336x280

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 forSimple apps, self-hostedDeveloper-first SaaSHigh-volume transactional
Setup complexityMediumLowMedium
Free tierN/A (SMTP provider dependent)3,000 emails/month100 emails/day
HTML templatesManualReact EmailDynamic templates
DeliverabilityDepends on SMTP providerGoodExcellent
Pricing at scaleCheapest (use AWS SES)~$20/month for 50k~$20/month for 50k
Webhook supportNoYesYes
Quick decision: Building a side project? Use Resend. Need high-volume transactional email with analytics? SendGrid. Already have SMTP credentials and just need to send a few emails? Nodemailer.

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:

  1. Enable 2-Factor Authentication on your Google account
  2. Go to Security → App Passwords
  3. Generate a password for "Mail"
  4. Use that 16-character password as SMTP_PASS
For production, don't use Gmail SMTP. It's rate-limited to around 500 emails per day and Google may lock your account if they think you're sending spam. Use a dedicated service.

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 TypePurpose
SPFDeclares which servers can send email for your domain
DKIMCryptographic signature proving the email wasn't tampered with
DMARCPolicy telling receiving servers what to do with unauthenticated email
Return-PathWhere bounce notifications go
Skip this setup and your emails go straight to spam. Every provider (Resend, SendGrid, SES) walks you through adding these DNS records during setup.

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

  1. Sending from noreply@gmail.com or a domain you don't own. SPF/DKIM checks will fail. Always send from a domain you control.
  1. Including the user's password in a welcome email. Never send passwords in email. Send a login link or a "set your password" link.
  1. Not including a plain text version. Some email clients (and spam filters) prefer plain text. Always set both html and text.
  1. Using process.env.NODE_ENV to decide whether to actually send. This leads to accidentally not sending emails in staging, or accidentally sending test emails in production. Use an explicit EMAIL_ENABLED flag.
  1. Not handling bounces. Hard bounces (invalid email addresses) hurt your sender reputation. Use webhook events from your provider to remove invalid addresses.
For hands-on practice with backend patterns like email integration, authentication, and API development, check out the project-based exercises on CodeUp.
Ad 728x90