How to Add Email Sending to Your Node.js App
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.
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 →