How to Add Email Sending to Your Node.js App 2026
TL;DR
Resend for new projects; Nodemailer for existing SMTP infrastructure. Resend (~400K weekly downloads) is the modern email API — excellent DX, native React Email support, 100 emails/day free. Nodemailer (~16M downloads) is the battle-tested SMTP library — works with any email provider. For transactional emails at scale (>100K/month), evaluate SendGrid, Postmark, or AWS SES. Use React Email for template rendering regardless of transport.
Key Takeaways
- Resend: ~400K downloads — modern API, React Email native, free tier
- Nodemailer: ~16M downloads — SMTP/IMAP universal, works with any provider
- React Email — component-based templates that render to HTML (use with either)
- Free tiers: Resend 100/day, Mailgun 100/day, SendGrid 100/day
- Always test email rendering — Outlook breaks most CSS; use Litmus or Email on Acid
Why Email Sending Is Harder Than It Looks
Adding email to a Node.js app sounds trivial until you run into deliverability issues. Your emails land in spam, Outlook renders your CSS as plain text, Gmail clips messages over 102KB, and rate limits bite you at the worst possible moment.
The libraries and services covered here each solve a different part of this problem. Nodemailer is a transport layer — it handles the SMTP protocol but nothing about deliverability. Resend is a full email API service that handles SMTP, domain authentication, and delivery analytics. React Email is a template system that works with either. Understanding what each piece does helps you assemble the right stack for your project.
Option 1: Resend (Modern API)
Resend launched in 2023 and quickly became the default recommendation for new Node.js and Next.js projects. It has a clean TypeScript SDK, native React Email support, and a dashboard for delivery analytics.
npm install resend
Get an API key from resend.com and verify a sending domain (required for production; onboarding@resend.dev works for testing).
// lib/email.ts — Resend setup
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY!);
// Send a simple HTML email
export async function sendEmail({
to,
subject,
html,
}: {
to: string;
subject: string;
html: string;
}) {
const { data, error } = await resend.emails.send({
from: 'noreply@yourdomain.com', // Must be a verified domain
to,
subject,
html,
});
if (error) throw new Error(error.message);
return data;
}
The real power of Resend is passing a React component directly — it renders the JSX to HTML server-side before sending:
// With React Email templates — Resend renders JSX automatically
import { Resend } from 'resend';
import { WelcomeEmail } from './emails/welcome';
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send({
from: 'noreply@yourdomain.com',
to: user.email,
subject: 'Welcome to our app!',
react: <WelcomeEmail userName={user.name} verificationUrl={url} />,
});
For sending to multiple recipients at once, Resend has a batch.send method:
// Resend batch sending — up to 100 emails per API call
const emailList = users.map((user) => ({
from: 'noreply@yourdomain.com',
to: user.email,
subject: 'Monthly Newsletter',
react: <NewsletterEmail user={user} />,
}));
const { data, error } = await resend.batch.send(emailList);
Resend Webhooks
Resend fires webhook events for delivery, bounces, spam complaints, and unsubscribes. Set up a webhook endpoint to track delivery status:
// app/api/webhooks/resend/route.ts (Next.js App Router)
import { Webhook } from 'svix';
import { headers } from 'next/headers';
export async function POST(req: Request) {
const payload = await req.text();
const headersList = headers();
const svixHeaders = {
'svix-id': headersList.get('svix-id') ?? '',
'svix-timestamp': headersList.get('svix-timestamp') ?? '',
'svix-signature': headersList.get('svix-signature') ?? '',
};
// Verify webhook signature
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);
const event = wh.verify(payload, svixHeaders) as {
type: string;
data: { email_id: string; to: string[] };
};
switch (event.type) {
case 'email.delivered':
await db.emailLog.update({
where: { resendId: event.data.email_id },
data: { status: 'delivered' },
});
break;
case 'email.bounced':
// Mark email as invalid, stop future sends
await db.user.update({
where: { email: event.data.to[0] },
data: { emailBounced: true },
});
break;
case 'email.complained':
// Unsubscribe the user from marketing emails
await db.user.update({
where: { email: event.data.to[0] },
data: { marketingOptOut: true },
});
break;
}
return new Response('OK');
}
Option 2: Nodemailer (Universal SMTP)
Nodemailer (~16M weekly downloads) has been the standard email library for Node.js since 2010. It doesn't provide an email server — it connects to any SMTP server. That means it works with Gmail, your company's Exchange server, Postmark, SendGrid, AWS SES, or any other SMTP endpoint.
npm install nodemailer
npm install -D @types/nodemailer
// lib/mailer.ts — configure your SMTP transport
import nodemailer from 'nodemailer';
// Gmail (use an App Password, not your main Google account password)
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_APP_PASSWORD, // 16-char app password from Google
},
});
// Generic SMTP — works with Postmark, Mailgun, SendGrid, etc.
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, // 'smtp.postmarkapp.com'
port: parseInt(process.env.SMTP_PORT!), // 587 (STARTTLS) or 465 (SSL)
secure: process.env.SMTP_PORT === '465',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// AWS SES via SMTP credentials
const transporter = nodemailer.createTransport({
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.AWS_SES_SMTP_USERNAME,
pass: process.env.AWS_SES_SMTP_PASSWORD,
},
});
// Verify the connection works on startup
await transporter.verify();
console.log('SMTP connection verified');
Sending an email with Nodemailer:
// Sending with Nodemailer + React Email template
import { render } from '@react-email/render';
import { WelcomeEmail } from './emails/welcome';
async function sendWelcomeEmail(user: {
name: string;
email: string;
verificationUrl: string;
}) {
// Render the React Email component to an HTML string
const html = await render(
<WelcomeEmail
userName={user.name}
verificationUrl={user.verificationUrl}
userEmail={user.email}
/>
);
const info = await transporter.sendMail({
from: '"My App" <noreply@myapp.com>',
to: user.email,
subject: `Welcome to My App, ${user.name}!`,
html,
// Always include a plain text fallback
text: `Welcome ${user.name}! Verify your email: ${user.verificationUrl}`,
});
console.log('Message sent:', info.messageId);
return info;
}
Option 3: SendGrid for High Volume
When you're sending more than 100K emails/month, Resend's pricing becomes significant and SendGrid's mature analytics and IP reputation management start to matter.
npm install @sendgrid/mail
// lib/sendgrid.ts
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export async function sendTransactionalEmail({
to,
subject,
html,
text,
}: {
to: string;
subject: string;
html: string;
text: string;
}) {
await sgMail.send({
to,
from: {
email: 'noreply@yourdomain.com',
name: 'Your App',
},
subject,
html,
text,
trackingSettings: {
clickTracking: { enable: true },
openTracking: { enable: true },
},
// SendGrid categories for analytics segmentation
categories: ['transactional', 'welcome'],
});
}
SendGrid's advantage over Resend at high volume is IP warming, dedicated IP addresses (for critical transactional email), and a more mature analytics dashboard. The DX is more verbose though — no native React Email support, and the API is less ergonomic than Resend.
React Email: Templates That Actually Work
React Email lets you write email templates as React components. It compiles them to HTML that is compatible with Outlook, Gmail, Apple Mail, and other clients — handling the CSS inlining and table-based layout that email clients require.
npm install react-email @react-email/components
// emails/welcome.tsx — a React Email template
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Preview,
Img,
Hr,
Link,
} from '@react-email/components';
interface WelcomeEmailProps {
userName: string;
verificationUrl: string;
userEmail: string;
}
export function WelcomeEmail({ userName, verificationUrl, userEmail }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to My App — verify your email to get started</Preview>
<Body style={{ fontFamily: 'Arial, sans-serif', backgroundColor: '#f6f9fc' }}>
<Container style={{ maxWidth: '600px', margin: '0 auto', padding: '40px 20px' }}>
<Img
src="https://myapp.com/logo.png"
alt="My App"
width={120}
height={40}
/>
<Section>
<Text style={{ fontSize: '24px', fontWeight: 'bold', color: '#1a1a1a' }}>
Welcome, {userName}!
</Text>
<Text style={{ color: '#555', lineHeight: '1.6' }}>
Thanks for signing up. Click the button below to verify your email address.
</Text>
<Button
href={verificationUrl}
style={{
backgroundColor: '#0070f3',
color: '#fff',
padding: '12px 24px',
borderRadius: '4px',
display: 'inline-block',
}}
>
Verify Email
</Button>
<Text style={{ color: '#888', fontSize: '13px' }}>
Or copy this link: <Link href={verificationUrl}>{verificationUrl}</Link>
</Text>
</Section>
<Hr />
<Text style={{ color: '#999', fontSize: '12px' }}>
This email was sent to {userEmail}. If you didn't sign up, you can safely ignore this.
</Text>
</Container>
</Body>
</Html>
);
}
Run the React Email dev server to preview your templates live:
npx react-email dev
# Opens localhost:3000 — shows all templates in /emails directory
React Email handles all the email quirks: it inlines styles (required for Outlook), uses table-based layout where needed, and provides components for common patterns (Button, Link, Hr, Img) that are pre-tested across major clients.
Development: Preview Without Sending
// Development: Use Nodemailer's Ethereal test account
// No real emails sent — everything captured in a preview inbox
const testAccount = await nodemailer.createTestAccount();
const testTransporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
const info = await testTransporter.sendMail({ ... });
console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
// https://ethereal.email/message/xyz — view the email in browser
For environment-based switching:
// lib/email.ts — pick transport based on environment
function createEmailTransport() {
if (process.env.NODE_ENV === 'test') {
// Nodemailer memory transport — captures emails in-process for unit tests
return nodemailer.createTransport({ jsonTransport: true });
}
if (process.env.NODE_ENV === 'development') {
// Ethereal — real SMTP preview server
return createEtherealTransport();
}
// Production: Resend, SendGrid, etc.
return productionTransport;
}
Production Best Practices
1. Never send email synchronously in request handlers. Email delivery can take seconds and SMTP connections can fail. Use a background queue:
// In your API route — enqueue, don't send inline
await emailQueue.add('welcome', { userId: user.id });
// Worker process
emailQueue.process('welcome', async (job) => {
const user = await db.user.findUnique({ where: { id: job.data.userId } });
await sendWelcomeEmail(user);
});
2. Handle errors without failing the user's request:
const { data, error } = await resend.emails.send({ ... });
if (error) {
// Log to your error tracker (Sentry, etc.)
logger.error('Email send failed', { error, to: user.email });
// Don't throw — email failure shouldn't break sign-up
}
3. Set up SPF, DKIM, and DMARC. These DNS records authenticate your domain and are required for good deliverability. Without them, your emails land in spam:
SPF: TXT record: "v=spf1 include:resend.com ~all"
DKIM: CNAME record pointing to your provider's key
DMARC: TXT _dmarc record: "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com"
Resend and SendGrid walk you through this setup in their dashboards.
4. Include an unsubscribe link in marketing emails. CAN-SPAM (US) and GDPR (EU) require it. Transactional emails (receipts, verification) are exempt, but newsletters and promotional emails are not. The simplest implementation is a unique token per user stored in your database — clicking the link marks marketingOptOut: true and you filter those users out of future sends.
5. Monitor bounce rates and complaint rates. Most sending providers surface these metrics in their dashboards. A bounce rate above 2% signals that your list has stale addresses and will hurt your sending reputation. A complaint rate above 0.1% (one in a thousand recipients marking your email as spam) triggers automatic flagging by Gmail and Outlook. Clean your list regularly and honor unsubscribes within 10 business days as required by CAN-SPAM.
Email Deliverability: The Technical Foundation
The SPF, DKIM, and DMARC records mentioned in the production checklist are not just recommendations — without them, most email providers will either reject your messages or deliver them to spam. Understanding what each record does helps you debug deliverability issues when they occur.
SPF (Sender Policy Framework) is a DNS TXT record on your domain that lists which mail servers are authorized to send email from your domain. When a receiving mail server gets a message claiming to be from noreply@yourapp.com, it looks up your domain's SPF record and checks whether the sending server's IP address is on the list. If it is, the message passes SPF. If it isn't — for example, because an attacker is trying to spoof your domain — the message fails. For Resend, your SPF record looks like: v=spf1 include:amazonses.com ~all (Resend uses Amazon SES under the hood). For SendGrid: v=spf1 include:sendgrid.net ~all. The ~all means "soft fail" — messages from unlisted servers are marked suspicious but not rejected. -all means "hard fail" — messages from unlisted servers are rejected.
DKIM (DomainKeys Identified Mail) adds a cryptographic signature to outgoing emails. Your email provider generates a public/private key pair; the private key signs outgoing messages, and the public key is published in your DNS as a CNAME or TXT record. Receiving servers use the public key to verify that the message wasn't tampered with in transit. DKIM is what prevents man-in-the-middle modification of email content. Every major sending provider (Resend, SendGrid, Postmark, Mailgun) walks you through adding the CNAME record in their onboarding flow.
DMARC (Domain-based Message Authentication, Reporting & Conformance) ties SPF and DKIM together and tells receiving servers what to do when a message fails both checks. A p=none policy means "do nothing, just report." A p=quarantine policy means "send to spam." A p=reject policy means "reject the message." Start with p=none and a rua reporting address to monitor failures before tightening the policy. Most deliverability issues are caught during the p=none monitoring phase.
The combination of SPF, DKIM, and DMARC is required to reliably reach Gmail, Outlook, and Apple Mail. Google and Yahoo tightened their requirements in 2024 to require DKIM and DMARC for domains sending more than 5,000 messages/day. Setting these records up on day one is significantly easier than debugging deliverability issues after launch.
Testing Emails in Development and CI
Email testing has three distinct contexts: development (you want to see the rendered email), CI (you want to verify the email is triggered with correct data), and cross-client testing (you want to verify Outlook and Gmail render it correctly).
For development, Nodemailer's Ethereal service or React Email's dev server (npx react-email dev) are the go-to options. Ethereal captures outgoing SMTP messages and provides a preview URL without sending anything to real inboxes. React Email's dev server renders templates in a browser with live reload, letting you iterate on templates quickly.
For CI, the goal is to verify that the right email was sent with the right data — not to render it in an email client. Test this by mocking your email function at the module level and asserting it was called with the expected arguments:
// Vitest / Jest — mock the email module
vi.mock('@/lib/email', () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue({ id: 'test-id' }),
}));
import { sendWelcomeEmail } from '@/lib/email';
test('sends welcome email after signup', async () => {
await signUpUser({ email: 'alice@test.com', name: 'Alice' });
expect(sendWelcomeEmail).toHaveBeenCalledWith({
to: 'alice@test.com',
name: 'Alice',
verificationUrl: expect.stringContaining('/verify?token='),
});
});
For cross-client rendering tests, Litmus and Email on Acid provide screenshots of your template rendered in 50+ email clients. This is worth doing for any template sent to a large audience — Outlook's rendering engine is notoriously different from Gmail's, and CSS that looks correct in a browser may display completely wrong in Outlook. The most common culprits are: background images (Outlook ignores them without VML fallback), border-radius (not supported in Outlook 2016), and flexbox/grid layout (not supported in any Outlook version — use table-based layout via React Email's components).
Queue-Based Email Architecture
Sending email synchronously inside a request handler is the most common email reliability mistake. If the email API call fails, times out, or rate limits mid-request, the user's request either returns an error (confusing) or silently drops the email (wrong). At any meaningful volume, you also need retry logic, rate limiting, and priority queuing — concerns that don't belong in a request handler.
The right architecture is to enqueue email jobs and process them asynchronously in a worker. BullMQ (Redis-backed) is the standard choice for Node.js job queues:
// lib/email-queue.ts
import { Queue, Worker } from 'bullmq';
import { sendWelcomeEmail, sendPasswordResetEmail } from './email';
type EmailJob =
| { type: 'welcome'; userId: string }
| { type: 'password-reset'; userId: string; token: string };
export const emailQueue = new Queue<EmailJob>('emails', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
},
});
// Worker (run as a separate process or serverless function)
export const emailWorker = new Worker<EmailJob>(
'emails',
async (job) => {
if (job.data.type === 'welcome') {
const user = await db.user.findUnique({ where: { id: job.data.userId } });
await sendWelcomeEmail(user);
} else if (job.data.type === 'password-reset') {
const user = await db.user.findUnique({ where: { id: job.data.userId } });
await sendPasswordResetEmail(user, job.data.token);
}
},
{ connection: { host: process.env.REDIS_HOST, port: 6379 } }
);
In your API route, enqueue instead of sending inline:
// Fast — doesn't wait for email delivery
await emailQueue.add('send', { type: 'welcome', userId: user.id });
return NextResponse.json({ success: true });
This architecture gives you automatic retries (with exponential backoff), rate limiting across your entire email volume, visibility into failures via BullMQ's dashboard, and the ability to prioritize transactional emails over marketing emails. For simpler needs without Redis, Inngest provides a managed job queue that works natively with Vercel and Next.js Server Actions.
Package Health
| Package | Weekly Downloads | Maintained | TypeScript | Best For |
|---|---|---|---|---|
nodemailer | ~16M | Active | Via @types | SMTP, self-hosted |
resend | ~400K | Active (2023) | Native | New projects, DX-first |
@sendgrid/mail | ~2.5M | Active | Partial | High-volume, enterprise |
react-email | ~300K | Active | Native | Email templates |
@react-email/components | ~300K | Active | Native | Email UI primitives |
Provider Comparison
| Provider | Free Tier | Best For |
|---|---|---|
| Resend | 100/day, 3K/month | New projects, DX-first |
| SendGrid | 100/day | Enterprise, detailed analytics |
| Postmark | 100/month | Transactional, high deliverability |
| Mailgun | 100/day | Developers, API-first |
| AWS SES | $0.10/1K | High volume, AWS stack |
| Nodemailer | N/A (needs SMTP) | Self-hosted, any provider |
When to Choose
Choose Resend when:
- Starting a new project and DX matters
- You want native React Email support without extra rendering steps
- Your volume fits the free tier (< 100 emails/day) or early pricing
- You want delivery webhooks and analytics without building them yourself
Choose Nodemailer when:
- You already have an SMTP server or contract with a provider
- You need to work with corporate email infrastructure (Exchange, internal relay)
- You're self-hosting everything and can't use a third-party API
- Budget constraints make a free SMTP relay attractive
Choose SendGrid or Postmark when:
- Sending more than 100K emails/month
- You need dedicated sending IPs for reputation isolation
- Advanced analytics (click tracking, open rates, A/B subject lines) are requirements
- You have a dedicated email team managing deliverability
Related: Resend vs SendGrid: Email API Comparison, nodemailer package health, How to Set Up Drizzle ORM with Next.js
See the live comparison
View resend vs. sendgrid on PkgPulse →