Developer Tutorial
From a basic nodemailer setup to a production-grade email API — how to actually send email in Node.js in 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.
There are three approaches for sending email from Node.js, and they serve very different use cases:
The classic approach. Configure a transporter with SMTP credentials and send directly.
POST JSON to an email API. Bearer token auth, webhooks, analytics, and sequences included.
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.
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'
}
})Building HTML strings by hand gets messy fast. Two common patterns in Node.js:
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 →</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`
}
}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`
}
})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.
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.
List-Unsubscribe header — tinysend adds it automatically.text field with a readable plaintext version.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.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 →