Developer Tutorial

How to Send Email in Node.js: A Practical Guide with Code

From a basic nodemailer setup to a production-grade email API — how to actually send email in Node.js in 2026.

8 min read·Updated March 2026

Sending email from Node.js has a surprisingly long history of footguns. SMTP credentials in environment variables, Nodemailer configuration that mysteriously stops working, deliverability issues that surface three weeks after launch. This guide covers the modern, production-grade approach.

1. Your options: Nodemailer vs SMTP vs email API

There are three approaches for sending email from Node.js, and they serve very different use cases:

Nodemailer (SMTP relay)

The classic approach. Configure a transporter with SMTP credentials and send directly.

+ Works with any SMTP provider
+ Familiar, lots of examples online
– No webhooks or delivery events
– SMTP credentials management, rotation, and rotation complexity
– Deliverability is entirely on you

REST API (recommended)

POST JSON to an email API. Bearer token auth, webhooks, analytics, and sequences included.

+ Simple auth (one API key)
+ Webhooks for bounces, opens, clicks
+ Deliverability managed by the provider
+ Sequences and templates via API

For production apps, use a REST API. Nodemailer is fine for local development or internal tooling, but for anything user-facing you want delivery events, webhooks, and the ability to inspect what was sent when things go wrong.

2. Sending your first email via REST API

Using native fetch (Node 18+) or any HTTP client. No library needed for basic sends:

// Using native fetch (Node 18+)
const response = await fetch('https://api.tinysend.co/v1/emails', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.TINYSEND_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: '[email protected]',
    to: user.email,
    subject: 'Confirm your email address',
    html: `<h1>Hi ${user.name}</h1><p>Click <a href="${confirmUrl}">here</a> to confirm.</p>`,
    text: `Hi ${user.name}. Confirm your email: ${confirmUrl}`
  }),
})

if (!response.ok) {
  const err = await response.json()
  throw new Error(`Email send failed: ${err.message}`)
}

const result = await response.json()
console.log('Email sent, id:', result.id)

If you prefer a typed SDK and want autocompletion:

// Install: npm install @tinysend/node
import { Tinysend } from '@tinysend/node'

const tinysend = new Tinysend({ apiKey: process.env.TINYSEND_API_KEY })

const { id } = await tinysend.emails.send({
  from: '[email protected]',
  to: user.email,
  subject: 'Password reset request',
  template: 'password-reset',
  variables: {
    firstName: user.name,
    resetUrl: `https://app.example.com/reset?token=${token}`,
    expiresIn: '24 hours'
  }
})

3. HTML templates with variables

Building HTML strings by hand gets messy fast. Two common patterns in Node.js:

Option A: Inline template literals (simple cases)

function buildWelcomeEmail(user) {
  return {
    subject: `Welcome to the app, ${user.firstName}!`,
    html: `
      <!DOCTYPE html>
      <html>
      <body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h1>Welcome, ${user.firstName}!</h1>
        <p>Your account is ready. Start building:</p>
        <a href="https://app.example.com" style="
          background: #0066ff;
          color: white;
          padding: 12px 24px;
          border-radius: 6px;
          text-decoration: none;
        ">Open the app &rarr;</a>
        <p style="color: #888; font-size: 12px; margin-top: 40px;">
          You're receiving this because you signed up at example.com.
        </p>
      </body>
      </html>
    `,
    text: `Welcome, ${user.firstName}! Open the app: https://app.example.com`
  }
}

Option B: Server-side template variables (recommended)

Store templates in your email platform and pass variables at send time. The template is editable without a deploy, and you get a preview UI:

await tinysend.emails.send({
  from: '[email protected]',
  to: user.email,
  template: 'trial-ending',  // stored in tinysend dashboard
  variables: {
    firstName: user.firstName,
    daysLeft: 3,
    upgradeUrl: `https://app.example.com/upgrade?plan=pro`,
    currentUsage: `${user.emailsSentThisMonth} emails`
  }
})

4. Error handling and retries

Email APIs return structured errors. Always handle them explicitly — don't let a failed send silently swallow the error:

class EmailError extends Error {
  constructor(message, status, code) {
    super(message)
    this.status = status
    this.code = code
  }
}

async function sendEmail(payload, retries = 2) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch('https://api.tinysend.co/v1/emails', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.TINYSEND_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      })

      if (res.status === 422) {
        // Validation error — don't retry, fix the payload
        const { message, code } = await res.json()
        throw new EmailError(message, 422, code)
      }

      if (!res.ok) {
        // 5xx — worth retrying after a backoff
        if (attempt < retries) {
          await new Promise(r => setTimeout(r, 1000 * (attempt + 1)))
          continue
        }
        throw new EmailError(`HTTP ${res.status}`, res.status)
      }

      return await res.json()
    } catch (err) {
      if (err instanceof EmailError) throw err
      if (attempt === retries) throw err
    }
  }
}

For high-volume or critical emails (password resets, receipts), consider adding the send call to a job queue (BullMQ, Inngest, Trigger.dev) so failures can be retried with full visibility and alerting rather than inline in a request handler.

5. Triggering onboarding sequences via API

For anything beyond a single transactional send — onboarding drips, trial expiry reminders, re-engagement sequences — you want to enroll contacts in a sequence rather than firing individual emails from your codebase:

// Enroll user in onboarding sequence on signup
async function onUserSignup(user) {
  // 1. Create/update the contact
  await tinysend.contacts.upsert({
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    properties: {
      plan: 'free',
      signedUpAt: new Date().toISOString(),
      appUrl: `https://app.example.com/users/${user.id}`
    }
  })

  // 2. Enroll in onboarding sequence
  await tinysend.sequences.enroll({
    sequenceId: 'onboarding-v3',
    email: user.email,
  })
}

// Unenroll when they complete a key action
async function onUserActivated(user) {
  await tinysend.sequences.unenroll({
    sequenceId: 'onboarding-v3',
    email: user.email,
  })

  // Optionally enroll in a different sequence
  await tinysend.sequences.enroll({
    sequenceId: 'power-user-tips',
    email: user.email,
  })
}

This keeps your application code clean — you fire events at the right moments, and the sequence logic (delays, conditions, templates) lives in the email platform where it can be edited without deploys.

6. Deliverability checklist before you launch

SPF, DKIM, and DMARC are set
Without these, Gmail and Outlook will reject or spam-folder your email. See the complete DKIM/SPF/DMARC guide.
Bounce webhook handler is live
Hard bounces must be suppressed immediately. An unhandled bounce rate above 2% will get your account suspended by most providers.
One-click unsubscribe is implemented
Required by Gmail and Yahoo for bulk senders since February 2024. Use the List-Unsubscribe header — tinysend adds it automatically.
Send plain-text alongside HTML
HTML-only emails score worse in spam filters. Always include a text field with a readable plaintext version.
API key is in environment variables, not source code
Check with git log -S TINYSEND_API_KEY before pushing. One leaked API key will have your account sending spam from someone else's botnet within hours.

Ready to send from Node.js?

tinysend gives you a clean REST API, templates, sequences, and BYOK — so you can keep your existing SES or Postmark account. Free to start, usage-based pricing.

Get your API key →