Patterns
Templates, scheduled sends, webhooks, transactional vs marketing, multi-channel orchestration
Patterns
The patterns every product reaches for once "I can send an email" stops being interesting.
Templates and Variables
Hand-coded HTML per email gets unmaintainable fast. Two approaches:
Provider-Hosted Templates
Most providers let you create templates in their dashboard and pass variables:
// SendGrid
await sgMail.send({
from: 'noreply@example.com',
to: 'alice@example.com',
templateId: 'd-abc123...',
dynamicTemplateData: {
name: 'Alice',
activation_url: 'https://example.com/activate?token=...',
},
});Pros: marketing / support can edit without code changes. Versioning, preview, send-test in the UI.
Cons: templates live outside your repo. Hard to test in CI. Drift between code references and template variables.
Code-Defined Templates (Recommended)
Templates as React Email / MJML / handlebars in your codebase:
// emails/order-shipped.tsx
export default function OrderShipped({ name, trackingUrl, items }) {
return (
<Html>
<Body>
<Heading>Hi {name}, your order is on the way</Heading>
<Text>Items:</Text>
<ul>
{items.map(i => <li key={i.id}>{i.name}</li>)}
</ul>
<Button href={trackingUrl}>Track shipment</Button>
</Body>
</Html>
);
}Pros: versioned with code, testable, type-checked, reviewable.
Cons: every change needs a deploy.
In 2026, React Email is the default for new projects using a Node stack. Other languages have analogs (mjml-* templates).
Multi-Language / Localization
import { useTranslation } from '@/i18n';
export default function WelcomeEmail({ name, locale = 'en' }) {
const t = useTranslation(locale);
return (
<Html lang={locale}>
<Body>
<Heading>{t('welcome.heading', { name })}</Heading>
<Text>{t('welcome.body')}</Text>
</Body>
</Html>
);
}Three concerns:
- Translations — your i18n system serves email like any other UI.
- RTL languages — Arabic, Hebrew need
dir="rtl"and right-aligned content. - Date / number formatting — locale-aware via
Intl.
Test rendering in each language before launch — a translation that overflows looks broken.
Scheduled Sends
Send at a specific time, not now. Two ways:
Provider-Native (when supported)
await resend.emails.send({
// ...
scheduledAt: '2025-06-01T09:00:00Z',
});Resend, Postmark, SendGrid all support delayed send.
Via Job Queue (more flexible)
Schedule a job in Background Jobs that calls the email API when due:
await emailQueue.add('send-welcome', { userId }, {
delay: 60 * 60 * 1000, // 1 hour
});
// Worker
async function sendWelcome(job) {
const user = await getUser(job.data.userId);
await sendEmail(user, ...);
}The queue approach is more flexible — you can re-evaluate state when the job runs ("is this user still active?" before sending).
Transactional vs Marketing
Different beasts:
| Transactional | Marketing | |
|---|---|---|
| Trigger | User action | Time-based / segment-based |
| Volume per send | Per-user, 1 at a time | Batches, often very large |
| Unsubscribe | Usually exempt (account-related) | Required, prominent |
| Deliverability strategy | High volume from steady IP, simple flow | Reputation per campaign |
| Compliance | CAN-SPAM, GDPR consent for related processing | CAN-SPAM, GDPR explicit consent |
| Suppression list | Hard bounces only | Hard bounces + unsubscribes + complaints |
| Common providers | Resend, Postmark, SendGrid, SES | Mailchimp, Customer.io, Brevo, Loops |
Use separate sending domains / IP pools for the two. A bad marketing batch should not poison your transactional reputation.
Customer.io / Loops Pattern
These tools blur the line — they handle both transactional sends and behavioral marketing campaigns ("send X if user does Y within Z days"). For teams that want both in one place with less code, they're a good fit.
Webhooks: React to Email Events
Every transactional provider sends webhooks. Common shape:
// POST from provider
{
type: 'email.delivered' | 'email.bounced' | 'email.complained' | ...
data: {
email_id: '...',
to: 'alice@example.com',
reason: 'mailbox full',
timestamp: '2025-05-21T14:32:00Z',
}
}Verify the Signature
Always verify the webhook came from the provider, not an attacker:
import { Webhook } from 'svix';
app.post('/webhooks/resend', (req, res) => {
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET);
let event;
try {
event = wh.verify(req.rawBody, req.headers);
} catch {
return res.sendStatus(401);
}
// process event...
});Handle Idempotently
Providers retry on 5xx. Process events idempotently — keep a processed_event_ids table or set; ignore duplicates.
Update Your Data
switch (event.type) {
case 'email.bounced':
if (event.data.bounceType === 'hard') {
await db.suppressions.upsert({ email: event.data.to, reason: 'hard-bounce' });
}
break;
case 'email.complained':
await db.suppressions.upsert({ email: event.data.to, reason: 'complaint' });
break;
case 'email.unsubscribed':
await db.users.update({ where: { email: event.data.to }, data: { unsubscribed: true } });
break;
}Honor suppressions before every send:
async function sendEmail(to, ...) {
if (await isSuppressed(to)) return; // silently skip
await provider.send({ to, ... });
}Multi-Channel (Email + SMS + Push)
For critical notifications (OTPs, urgent alerts), you might fan out across channels:
async function notifyUrgent(userId, message) {
const user = await getUser(userId);
await Promise.allSettled([
sendEmail(user.email, message),
user.phone && sendSMS(user.phone, message.short),
user.pushToken && sendPush(user.pushToken, message.short),
]);
}For more sophisticated multi-channel (with fallback, preferences, frequency capping), Knock and Courier are dedicated platforms. They wrap all the providers and add:
- Per-user channel preferences ("don't send me push notifications")
- Frequency capping ("at most 3 emails per day")
- Cross-channel deduplication
- Workflow orchestration
For most teams, the Promise.allSettled approach is fine until it isn't.
SMS via Twilio
npm install twilioimport Twilio from 'twilio';
const twilio = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
await twilio.messages.create({
from: '+15555551234',
to: '+12015555678',
body: 'Your verification code is 123456',
});Concerns:
- Pricing per-message, per-country. A SaaS sending OTPs to India and the Philippines costs differently than to the US.
- Long codes vs short codes vs alphanumeric senders vary by country.
- Toll-free numbers and 10DLC registration required for US bulk SMS.
- Apple Messages for Business / RCS / WhatsApp are channel-specific.
For OTPs specifically, Twilio Verify is a higher-level API that handles delivery, fallback channels, and code validation in one call.
Push Notifications
For mobile / web apps:
| Service | What for |
|---|---|
| Firebase Cloud Messaging (FCM) | Android, web; can do iOS via APNs proxy |
| Apple Push Notification service (APNs) | iOS native; via FCM or directly |
| Web Push | Browser push without an app; uses VAPID + service worker |
| OneSignal / Airship | Cross-platform push platforms; UI + API |
// FCM example
import admin from 'firebase-admin';
await admin.messaging().send({
token: deviceToken,
notification: {
title: 'New message',
body: 'Alice sent you a message',
},
data: { conversationId: '42' },
apns: { payload: { aps: { sound: 'default' } } },
});Push tokens expire and change; store them tied to a user + device, clean up failed sends.
In-App Notifications
Notifications inside your product (the bell icon with a red dot):
- Build it: a
notificationstable, your app polls or uses WebSocket. - Use a platform: Knock, Courier, Magic Bell, Novu — they handle multi-channel + in-app for you.
For B2B products with notification preferences and complex workflows, the platforms pay off. For a small app, building it is straightforward.
What's Next
You know the patterns. Best Practices covers operations — deliverability, unsubscribe, observability, compliance.