Back to Blog

How to Send Emails in Node.js (2026 Guide)

14 min read

Every Node.js app eventually needs to send emails. Password resets, welcome messages, invoices, notifications. Most tutorials show you Nodemailer with Gmail SMTP and call it a day. That works for testing. It doesn't work when you have real users.

This guide covers the practical ways to send emails from Node.js in production: dedicated email APIs, proper error handling, HTML templates, and scaling beyond transactional sends. All examples work with Express, Fastify, or plain Node.js scripts.

Nodemailer vs API-Based Providers

Nodemailer is the classic Node.js email library. It talks SMTP directly, which means you need to manage SMTP credentials, handle connection pooling, deal with TLS, and worry about deliverability yourself.

API-based providers (Sequenzy, Resend, SendGrid) handle all of that. You make an HTTP call, they handle delivery, retries, bounce processing, and reputation management. For production apps, this is almost always what you want.

// Nodemailer: you manage SMTP, TLS, pooling, retries
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
  host: "smtp.gmail.com",
  port: 587,
  auth: { user: "you@gmail.com", pass: "app-specific-password" },
});
 
// API provider: one HTTP call, they handle the rest
import Sequenzy from "sequenzy";
const sequenzy = new Sequenzy();
await sequenzy.transactional.send({ to, subject, body: html });

Use Nodemailer if you need to talk to a specific SMTP server (internal mail server, self-hosted infrastructure). Use an API provider for everything else.

Pick a Provider

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Has built-in retries and native Stripe integration. If you're building a SaaS product, this saves you from stitching together multiple tools.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.

Install and Configure

Terminal
npm install sequenzy
Terminal
npm install resend
Terminal
npm install @sendgrid/mail

Add your API key to .env:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Create a shared email client:

src/lib/email.ts
import Sequenzy from "sequenzy";

// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();
src/lib/email.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
src/lib/email.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

export { sgMail };

Send Your First Email

The simplest case. Plain text, no template, just verifying your setup works.

src/send.ts
import { sequenzy } from "./lib/email";

const result = await sequenzy.transactional.send({
to: "user@example.com",
subject: "Hello from Node.js",
body: "<p>Your app is sending emails.</p>",
});

console.log("Sent:", result.jobId);
src/send.ts
import { resend } from "./lib/email";

const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: "user@example.com",
subject: "Hello from Node.js",
html: "<p>Your app is sending emails.</p>",
});

if (error) {
console.error("Failed:", error.message);
} else {
console.log("Sent:", data?.id);
}
src/send.ts
import { sgMail } from "./lib/email";

try {
await sgMail.send({
  to: "user@example.com",
  from: "noreply@yourdomain.com",
  subject: "Hello from Node.js",
  html: "<p>Your app is sending emails.</p>",
});
console.log("Sent successfully");
} catch (err) {
console.error("Failed:", err instanceof Error ? err.message : err);
}

Run it:

npx tsx src/send.ts

Send from an Express Route

Most Node.js apps use Express. Here's how to wire up an email endpoint.

src/routes/email.ts
import { Router } from "express";
import { sequenzy } from "../lib/email";

const router = Router();

router.post("/send-welcome", async (req, res) => {
const { email, name } = req.body;

if (!email || !name) {
  return res.status(400).json({ error: "email and name are required" });
}

try {
  const result = await sequenzy.transactional.send({
    to: email,
    subject: `Welcome, ${name}`,
    body: `
      <h1>Welcome to our app, ${name}</h1>
      <p>Your account is ready. Start exploring.</p>
    `,
  });

  res.json({ jobId: result.jobId });
} catch (err) {
  console.error("Email send failed:", err);
  res.status(500).json({ error: "Failed to send email" });
}
});

export default router;
src/routes/email.ts
import { Router } from "express";
import { resend } from "../lib/email";

const router = Router();

router.post("/send-welcome", async (req, res) => {
const { email, name } = req.body;

if (!email || !name) {
  return res.status(400).json({ error: "email and name are required" });
}

const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: email,
  subject: `Welcome, ${name}`,
  html: `
    <h1>Welcome to our app, ${name}</h1>
    <p>Your account is ready. Start exploring.</p>
  `,
});

if (error) {
  return res.status(500).json({ error: error.message });
}

res.json({ id: data?.id });
});

export default router;
src/routes/email.ts
import { Router } from "express";
import { sgMail } from "../lib/email";

const router = Router();

router.post("/send-welcome", async (req, res) => {
const { email, name } = req.body;

if (!email || !name) {
  return res.status(400).json({ error: "email and name are required" });
}

try {
  await sgMail.send({
    to: email,
    from: "noreply@yourdomain.com",
    subject: `Welcome, ${name}`,
    html: `
      <h1>Welcome to our app, ${name}</h1>
      <p>Your account is ready. Start exploring.</p>
    `,
  });

  res.json({ sent: true });
} catch (err) {
  console.error("Email send failed:", err);
  res.status(500).json({ error: "Failed to send email" });
}
});

export default router;

Mount the router in your app:

// src/app.ts
import express from "express";
import emailRouter from "./routes/email";
 
const app = express();
app.use(express.json());
app.use("/api/email", emailRouter);
 
app.listen(3000, () => console.log("Server running on port 3000"));

Test it:

curl -X POST http://localhost:3000/api/email/send-welcome \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "name": "Jane"}'

Send from Fastify

If you're using Fastify instead of Express, the pattern is nearly identical.

src/routes/email.ts
import type { FastifyInstance } from "fastify";
import { sequenzy } from "../lib/email";

export async function emailRoutes(app: FastifyInstance) {
app.post<{ Body: { email: string; name: string } }>(
  "/send-welcome",
  async (request, reply) => {
    const { email, name } = request.body;

    const result = await sequenzy.transactional.send({
      to: email,
      subject: `Welcome, ${name}`,
      body: `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
    });

    return { jobId: result.jobId };
  }
);
}
src/routes/email.ts
import type { FastifyInstance } from "fastify";
import { resend } from "../lib/email";

export async function emailRoutes(app: FastifyInstance) {
app.post<{ Body: { email: string; name: string } }>(
  "/send-welcome",
  async (request, reply) => {
    const { email, name } = request.body;

    const { data, error } = await resend.emails.send({
      from: "Your App <noreply@yourdomain.com>",
      to: email,
      subject: `Welcome, ${name}`,
      html: `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
    });

    if (error) {
      return reply.status(500).send({ error: error.message });
    }

    return { id: data?.id };
  }
);
}
src/routes/email.ts
import type { FastifyInstance } from "fastify";
import { sgMail } from "../lib/email";

export async function emailRoutes(app: FastifyInstance) {
app.post<{ Body: { email: string; name: string } }>(
  "/send-welcome",
  async (request, reply) => {
    const { email, name } = request.body;

    try {
      await sgMail.send({
        to: email,
        from: "noreply@yourdomain.com",
        subject: `Welcome, ${name}`,
        html: `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
      });

      return { sent: true };
    } catch (err) {
      const message = err instanceof Error ? err.message : "Send failed";
      return reply.status(500).send({ error: message });
    }
  }
);
}

HTML Email Templates

Raw HTML strings work for simple emails, but they get painful fast. Here are two approaches that scale.

Option 1: Template Functions

The simplest approach. Just functions that return HTML strings.

// src/templates/welcome.ts
interface WelcomeTemplateParams {
  name: string;
  loginUrl: string;
}
 
export function welcomeTemplate({ name, loginUrl }: WelcomeTemplateParams): string {
  return `
    <!DOCTYPE html>
    <html>
      <body style="font-family: sans-serif; background: #f6f9fc; padding: 40px 0;">
        <div style="max-width: 480px; margin: 0 auto; background: #fff; padding: 40px; border-radius: 8px;">
          <h1 style="font-size: 24px; margin-bottom: 16px;">Welcome, ${name}</h1>
          <p style="font-size: 16px; line-height: 1.6; color: #374151;">
            Your account is ready. Click below to get started.
          </p>
          <a href="${loginUrl}"
             style="display:inline-block; background:#f97316; color:#fff; padding:12px 24px; border-radius:6px; text-decoration:none; font-weight:600; margin-top:16px;">
            Go to Dashboard
          </a>
        </div>
      </body>
    </html>
  `;
}

Then use it:

src/send-welcome.ts
import { sequenzy } from "./lib/email";
import { welcomeTemplate } from "./templates/welcome";

await sequenzy.transactional.send({
to: "user@example.com",
subject: "Welcome to our app",
body: welcomeTemplate({
  name: "Jane",
  loginUrl: "https://app.yoursite.com/login",
}),
});
src/send-welcome.ts
import { resend } from "./lib/email";
import { welcomeTemplate } from "./templates/welcome";

await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: "user@example.com",
subject: "Welcome to our app",
html: welcomeTemplate({
  name: "Jane",
  loginUrl: "https://app.yoursite.com/login",
}),
});
src/send-welcome.ts
import { sgMail } from "./lib/email";
import { welcomeTemplate } from "./templates/welcome";

await sgMail.send({
to: "user@example.com",
from: "noreply@yourdomain.com",
subject: "Welcome to our app",
html: welcomeTemplate({
  name: "Jane",
  loginUrl: "https://app.yoursite.com/login",
}),
});

Option 2: React Email (if you're already in the React ecosystem)

If your team knows React, you can use React Email to build templates as JSX components. This works in any Node.js project, not just React frontends.

npm install @react-email/components react react-dom
// emails/welcome.tsx
import { Body, Container, Head, Heading, Html, Link, Preview, Text } from "@react-email/components";
 
interface WelcomeEmailProps {
  name: string;
  loginUrl: string;
}
 
export default function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to the team, {name}</Preview>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container style={{ backgroundColor: "#ffffff", padding: "40px", borderRadius: "8px", margin: "40px auto", maxWidth: "480px" }}>
          <Heading style={{ fontSize: "24px", marginBottom: "16px" }}>Welcome, {name}</Heading>
          <Text style={{ fontSize: "16px", lineHeight: "1.6", color: "#374151" }}>
            Your account is ready. Click below to get started.
          </Text>
          <Link href={loginUrl} style={{ display: "inline-block", backgroundColor: "#f97316", color: "#ffffff", padding: "12px 24px", borderRadius: "6px", textDecoration: "none", fontWeight: "600", marginTop: "16px" }}>
            Go to Dashboard
          </Link>
        </Container>
      </Body>
    </Html>
  );
}

Render and send:

import { render } from "@react-email/components";
import WelcomeEmail from "../emails/welcome";
 
const html = await render(WelcomeEmail({ name: "Jane", loginUrl: "https://app.yoursite.com" }));
// Then pass `html` to your provider's send function

Sending Emails in Background Jobs

Don't send emails in your request handler if you can avoid it. Email API calls add 100-500ms of latency to every request. Use a job queue instead.

With BullMQ (Redis-based)

npm install bullmq ioredis
// src/jobs/email-queue.ts
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
 
const connection = new IORedis(process.env.REDIS_URL!);
 
export const emailQueue = new Queue("emails", { connection });
 
// Worker processes jobs in the background
new Worker(
  "emails",
  async (job) => {
    const { to, subject, body } = job.data;
 
    // Use your provider here
    const { sequenzy } = await import("../lib/email");
    await sequenzy.transactional.send({ to, subject, body });
  },
  { connection, concurrency: 5 }
);

Queue emails from your route handler:

// src/routes/auth.ts
import { emailQueue } from "../jobs/email-queue";
 
router.post("/signup", async (req, res) => {
  const user = await createUser(req.body);
 
  // Queue the email instead of sending inline
  await emailQueue.add("welcome", {
    to: user.email,
    subject: `Welcome, ${user.name}`,
    body: welcomeTemplate({ name: user.name, loginUrl: "https://app.yoursite.com" }),
  });
 
  res.json({ user });
});

This keeps your signup response fast (~50ms) regardless of how long the email API takes.

Error Handling

Emails fail. Networks time out. Rate limits hit. Here's how to handle it properly.

Sequenzy's SDK has built-in retries (2 retries with exponential backoff). For Resend and SendGrid, you'll want retry logic yourself.

src/lib/send-safe.ts
import Sequenzy from "sequenzy";
import { sequenzy } from "./email";

export async function sendEmailSafe(params: {
to: string;
subject: string;
body: string;
}) {
try {
  return await sequenzy.transactional.send(params);
} catch (err) {
  if (err instanceof Sequenzy.RateLimitError) {
    console.error("Rate limited, backing off");
  } else if (err instanceof Sequenzy.AuthenticationError) {
    console.error("Bad API key - check SEQUENZY_API_KEY");
  } else {
    console.error("Email send failed:", err);
  }
  throw err;
}
}
src/lib/send-safe.ts
import { resend } from "./email";

export async function sendEmailSafe(params: {
to: string;
subject: string;
html: string;
}) {
const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  ...params,
});

if (error) {
  console.error("Email failed:", { to: params.to, error: error.message });
  throw new Error(error.message);
}

return data;
}
src/lib/send-safe.ts
import { sgMail } from "./email";

export async function sendEmailSafe(params: {
to: string;
subject: string;
html: string;
}) {
try {
  const [response] = await sgMail.send({
    from: "noreply@yourdomain.com",
    ...params,
  });
  return { id: response.headers["x-message-id"] };
} catch (err: unknown) {
  const error = err as { response?: { body?: unknown }; message?: string };
  console.error("Email failed:", {
    to: params.to,
    error: error.response?.body ?? error.message,
  });
  throw new Error("Email send failed");
}
}

Add a generic retry wrapper for providers without built-in retries:

// src/lib/retry.ts
export async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw new Error("Unreachable");
}
 
// Usage
await withRetry(() => sendEmailSafe({ to, subject, html }));

Common SaaS Email Patterns

Password Reset

src/emails/password-reset.ts
import { sequenzy } from "../lib/email";

export async function sendPasswordResetEmail(email: string, token: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;

await sequenzy.transactional.send({
  to: email,
  subject: "Reset your password",
  body: `
    <h2>Password Reset</h2>
    <p>Click below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}
src/emails/password-reset.ts
import { resend } from "../lib/email";

export async function sendPasswordResetEmail(email: string, token: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;

await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: email,
  subject: "Reset your password",
  html: `
    <h2>Password Reset</h2>
    <p>Click below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}
src/emails/password-reset.ts
import { sgMail } from "../lib/email";

export async function sendPasswordResetEmail(email: string, token: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;

await sgMail.send({
  to: email,
  from: "noreply@yourdomain.com",
  subject: "Reset your password",
  html: `
    <h2>Password Reset</h2>
    <p>Click below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}

Stripe Webhook Handler

Send emails when Stripe events fire. This works the same whether you're using Express, Fastify, or raw Node.js HTTP.

src/routes/stripe-webhook.ts
import { Router } from "express";
import Stripe from "stripe";
import { sequenzy } from "../lib/email";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();

router.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["stripe-signature"]!;
const event = stripe.webhooks.constructEvent(
  req.body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!
);

if (event.type === "checkout.session.completed") {
  const session = event.data.object as Stripe.Checkout.Session;

  await sequenzy.transactional.send({
    to: session.customer_email!,
    subject: "Payment confirmed",
    body: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
  });

  // Also add them as a subscriber for marketing
  await sequenzy.subscribers.create({
    email: session.customer_email!,
    tags: ["customer", "stripe"],
  });
}

res.json({ received: true });
});

export default router;
src/routes/stripe-webhook.ts
import { Router } from "express";
import Stripe from "stripe";
import { resend } from "../lib/email";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();

router.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["stripe-signature"]!;
const event = stripe.webhooks.constructEvent(
  req.body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!
);

if (event.type === "checkout.session.completed") {
  const session = event.data.object as Stripe.Checkout.Session;

  await resend.emails.send({
    from: "Your App <noreply@yourdomain.com>",
    to: session.customer_email!,
    subject: "Payment confirmed",
    html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
  });
}

res.json({ received: true });
});

export default router;
src/routes/stripe-webhook.ts
import { Router } from "express";
import Stripe from "stripe";
import { sgMail } from "../lib/email";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();

router.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["stripe-signature"]!;
const event = stripe.webhooks.constructEvent(
  req.body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!
);

if (event.type === "checkout.session.completed") {
  const session = event.data.object as Stripe.Checkout.Session;

  await sgMail.send({
    to: session.customer_email!,
    from: "noreply@yourdomain.com",
    subject: "Payment confirmed",
    html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
  });
}

res.json({ received: true });
});

export default router;

Going to Production

1. Verify Your Domain

Every provider requires DNS records (SPF, DKIM, DMARC) to verify you own the domain you're sending from. Without this, your emails go to spam. Set this up in your provider's dashboard before sending to real users.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.

3. Rate Limit Email Endpoints

Add rate limiting so a bug or bot can't blast thousands of emails:

// Simple in-memory rate limiter
const emailsSent = new Map<string, number[]>();
 
function canSendEmail(to: string, maxPerHour = 10): boolean {
  const now = Date.now();
  const hourAgo = now - 60 * 60 * 1000;
  const timestamps = (emailsSent.get(to) ?? []).filter((t) => t > hourAgo);
 
  if (timestamps.length >= maxPerHour) return false;
 
  timestamps.push(now);
  emailsSent.set(to, timestamps);
  return true;
}

4. Don't Block the Event Loop

If you're sending bulk emails, do it in batches with delays. Never fire 10,000 API calls in a tight loop.

async function sendBulk(emails: Array<{ to: string; subject: string; body: string }>) {
  const batchSize = 10;
 
  for (let i = 0; i < emails.length; i += batchSize) {
    const batch = emails.slice(i, i + batchSize);
    await Promise.all(batch.map((e) => sequenzy.transactional.send(e)));
 
    // Small delay between batches
    if (i + batchSize < emails.length) {
      await new Promise((resolve) => setTimeout(resolve, 200));
    }
  }
}

Beyond Transactional: Marketing and Automation

Once your app is sending transactional emails, you'll want to add:

  • Onboarding sequences that drip emails over a user's first week
  • Marketing campaigns to announce features
  • Lifecycle automation based on user behavior
  • Engagement tracking to see opens and clicks

Most teams wire together a transactional provider with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and syncing subscriber lists.

Sequenzy handles both from one SDK. Same code, same dashboard. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration.

import { sequenzy } from "./lib/email";
 
// Add a subscriber when they sign up
await sequenzy.subscribers.create({
  email: "user@example.com",
  firstName: "Jane",
  tags: ["signed-up"],
  customAttributes: { plan: "free", source: "organic" },
});
 
// Tag them when they upgrade
await sequenzy.subscribers.tags.add({
  email: "user@example.com",
  tag: "customer",
});
 
// Track events to trigger automated sequences
await sequenzy.subscribers.events.trigger({
  email: "user@example.com",
  event: "onboarding.completed",
  properties: { completedSteps: 5 },
});

Set up sequences in the Sequenzy dashboard, and the SDK triggers them based on what happens in your app.

Wrapping Up

Here's what we covered:

  1. Nodemailer vs API providers and when to use each
  2. Express and Fastify route handlers for sending emails
  3. HTML templates with template functions and React Email
  4. Background jobs with BullMQ to keep responses fast
  5. Error handling with typed errors and retry logic
  6. Production checklist: domain verification, rate limiting, batch sending

The code in this guide is production-ready. Pick your provider, copy the patterns that fit, and start sending.