How to Send Transactional Emails from a Node.js App with Resend and React Email

How to Send Transactional Emails from a Node.js App with Resend and React Email

by | Oct 31, 2026 | Uncategorized | 0 comments

If you need to send emails from a Node.js app, Resend has become one of the fastest ways to get from zero to a delivered inbox. Combined with React Email, you get typed, reusable templates written in JSX instead of the usual 1998-style HTML tables full of inline styles. Full details on https://mailtrap.io.

This tutorial is a complete, practical walkthrough: API key setup, domain verification with SPF, DKIM and DMARC, building shared templates, sending order confirmations, password resets and welcome emails from Express or Next.js, handling bounces and complaints with webhooks, and testing everything locally without spamming real inboxes.

Why Resend and React Email for transactional email

Most Node.js teams start with Nodemailer plus a random SMTP server, then discover that deliverability, templating and event tracking are the hard parts. Here is how the options compare for transactional sending.

Approach Setup time Templating Events and bounces
Raw SMTP with Nodemailer Fast to code, slow to configure DNS and IP reputation Manual HTML strings You parse bounce emails yourself
Resend HTTP API Minutes, one SDK and one API key React Email components, first class Signed webhooks plus a dashboard log
Resend SMTP Drop-in replacement for legacy code Whatever your current code does Same webhooks as the API

The API route is the one we recommend for new projects, because you keep a request ID for every message and can react to delivery events programmatically.

email code laptop

What you need before you start

  • Node.js 20 or newer (Node 22 LTS is the safe default today).
  • A domain you control with access to its DNS zone. Free mailbox addresses such as Gmail cannot be used as a verified sender.
  • An Express, Fastify or Next.js backend. Everything below works with the App Router or with a plain Node server.
  • A password manager or secret store for the API key. Never ship it to the browser bundle.

Step 1: Create the Resend account and API key

  1. Sign up at resend.com and open the API Keys section.
  2. Create a key with Sending access only. Read and write access is rarely needed by your app server.
  3. Create a separate key per environment: production, staging, local. This makes it trivial to revoke one without breaking the others.
  4. Store it in .env.local and add that file to .gitignore.
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM='Box Software <[email protected]>'
[email protected]

Step 2: Verify your sending domain (SPF, DKIM, DMARC)

Until a domain is verified, Resend only lets you send to the email address that owns the account. That single rule explains most of the “it works locally but fails in production” threads you will find on forums.

Use a subdomain for sending

Add mail.yourdomain.com or send.yourdomain.com instead of the root domain. Two benefits:

  • Your marketing tool, your CRM and your app can each own a subdomain with independent reputation.
  • A bad campaign never drags your password reset emails into the spam folder.

The DNS records to publish

Resend generates the exact values in the dashboard. They follow this shape:

Type Name Value (example) Purpose
MX send.yourdomain.com feedback-smtp.<region>.amazonses.com (priority 10) Bounce and complaint feedback loop
TXT send.yourdomain.com v=spf1 include:amazonses.com ~all SPF: authorises the sending servers
TXT resend._domainkey.yourdomain.com p=MIGfMA0GCSq… (public key) DKIM: cryptographic signature
TXT _dmarc.yourdomain.com v=DMARC1; p=none; rua=mailto:[email protected] DMARC: policy and reporting

DNS gotchas worth knowing

  • Only one SPF record per hostname. If a record already exists, merge the include: instead of adding a second TXT.
  • If your DNS provider auto-appends the domain, enter send and not send.yourdomain.com, otherwise you create send.yourdomain.com.yourdomain.com.
  • Cloudflare users: set the MX and TXT records to DNS only, never proxied.
  • Start DMARC at p=none, read the aggregate reports for two or three weeks, then move to quarantine and finally reject.
  • Propagation is usually minutes but can take a few hours. Click Verify again rather than recreating records.
email code laptop

Step 3: Install the packages

npm install resend @react-email/components
npm install -D react-email

Add a script so you can preview templates in the browser:

{
  'scripts': {
    'email': 'email dev --dir ./emails --port 3001'
  }
}

Step 4: Your first send from Node.js

A minimal Express endpoint looks like this:

import express from 'express';
import { Resend } from 'resend';

const app = express();
app.use(express.json());
const resend = new Resend(process.env.RESEND_API_KEY);

app.post('/api/test-email', async (req, res) => {
  const { data, error } = await resend.emails.send({
    from: process.env.EMAIL_FROM,
    to: ['[email protected]'],
    replyTo: process.env.EMAIL_REPLY_TO,
    subject: 'Hello from Node.js',
    html: '<p>It works.</p>',
    text: 'It works.'
  });

  if (error) {
    console.error('resend_error', error);
    return res.status(502).json({ ok: false, error: error.message });
  }

  return res.json({ ok: true, id: data.id });
});

app.listen(3000);

Two habits to adopt right away:

  • Always send a plain text part alongside the HTML. Spam filters reward it and some clients need it.
  • Log the returned id. It is the only way to correlate an application event with a delivery event later on.

Step 5: Build reusable templates with React Email

Create an emails/ folder at the root of your project. The trick that saves the most time is a shared layout that every template reuses.

A shared layout component

// emails/components/Layout.tsx
import {
  Body, Container, Head, Hr, Html, Img,
  Preview, Section, Text
} from '@react-email/components';

export function Layout({ preview, children }) {
  return (
    <Html lang='en'>
      <Head />
      <Preview>{preview}</Preview>
      <Body style={{ backgroundColor: '#f5f6f8', fontFamily: 'Helvetica, Arial, sans-serif' }}>
        <Container style={{ backgroundColor: '#ffffff', padding: '32px', borderRadius: '8px', maxWidth: '560px' }}>
          <Img src='https://yourdomain.com/logo.png' width='120' alt='Logo' />
          <Section>{children}</Section>
          <Hr />
          <Text style={{ fontSize: '12px', color: '#6b7280' }}>
            You received this email because you have an account with us.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

The Preview component controls the preheader text shown next to the subject line in Gmail and Apple Mail. Most teams forget it, and it is one of the cheapest open rate wins available.

Welcome email

// emails/WelcomeEmail.tsx
import { Button, Heading, Text } from '@react-email/components';
import { Layout } from './components/Layout';

export default function WelcomeEmail({ firstName = 'there', activationUrl = '#' }) {
  return (
    <Layout preview={'Welcome aboard, ' + firstName}>
      <Heading as='h1'>Welcome, {firstName}</Heading>
      <Text>Your account is ready. Activate it to unlock every feature.</Text>
      <Button href={activationUrl} style={{ background: '#111827', color: '#fff', padding: '12px 20px', borderRadius: '6px' }}>
        Activate my account
      </Button>
    </Layout>
  );
}

Password reset email

// emails/PasswordResetEmail.tsx
import { Button, Heading, Text } from '@react-email/components';
import { Layout } from './components/Layout';

export default function PasswordResetEmail({ resetUrl = '#', expiresInMinutes = 30 }) {
  return (
    <Layout preview='Reset your password'>
      <Heading as='h1'>Password reset</Heading>
      <Text>Click the button below to choose a new password. This link expires in {expiresInMinutes} minutes.</Text>
      <Button href={resetUrl} style={{ background: '#111827', color: '#fff', padding: '12px 20px', borderRadius: '6px' }}>
        Reset password
      </Button>
      <Text style={{ fontSize: '12px', color: '#6b7280' }}>
        If you did not request this, you can safely ignore this email.
      </Text>
    </Layout>
  );
}

Order confirmation email

// emails/OrderConfirmationEmail.tsx
import { Column, Heading, Row, Text } from '@react-email/components';
import { Layout } from './components/Layout';

export default function OrderConfirmationEmail({ orderId, items = [], total }) {
  return (
    <Layout preview={'Order ' + orderId + ' confirmed'}>
      <Heading as='h1'>Thanks for your order</Heading>
      <Text>Order reference: <strong>{orderId}</strong></Text>
      {items.map((item) => (
        <Row key={item.sku}>
          <Column>{item.quantity} x {item.name}</Column>
          <Column align='right'>{item.price}</Column>
        </Row>
      ))}
      <Row>
        <Column><strong>Total</strong></Column>
        <Column align='right'><strong>{total}</strong></Column>
      </Row>
    </Layout>
  );
}
email code laptop

Step 6: Wire the templates into your backend

Option A: Express

In a plain Node server you compile the component to HTML first, then pass it to Resend. Make sure your build step handles JSX (tsx, esbuild, ts-node or Babel).

import { render } from '@react-email/render';
import OrderConfirmationEmail from '../emails/OrderConfirmationEmail';

export async function sendOrderConfirmation(order) {
  const element = OrderConfirmationEmail({
    orderId: order.id,
    items: order.items,
    total: order.totalFormatted
  });

  const html = await render(element);
  const text = await render(element, { plainText: true });

  return resend.emails.send({
    from: process.env.EMAIL_FROM,
    to: [order.customerEmail],
    subject: 'Your order ' + order.id + ' is confirmed',
    html,
    text,
    headers: { 'X-Entity-Ref-ID': order.id }
  });
}

Option B: Next.js App Router

In Next.js you can pass the React element directly with the react key. Keep the call inside a route handler or a server action so the API key never reaches the client.

// app/api/send-welcome/route.ts
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
import WelcomeEmail from '@/emails/WelcomeEmail';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(request: Request) {
  const { email, firstName, activationUrl } = await request.json();

  const { data, error } = await resend.emails.send({
    from: process.env.EMAIL_FROM!,
    to: [email],
    subject: 'Welcome to Box Software',
    react: WelcomeEmail({ firstName, activationUrl })
  });

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 502 });
  }

  return NextResponse.json({ id: data?.id });
}

Send in the background, not in the request

A checkout request should not fail because an email provider is slow. Push the send to a queue (BullMQ, Inngest, Trigger.dev, a Vercel background function) and return to the user immediately. If the send fails, the queue retries instead of the customer.

Step 7: Test locally without spamming real inboxes

This is where most tutorials stop and where most bugs live. Four layers of protection:

1. Preview templates in the browser

npm run email

Open http://localhost:3001 and you get a hot-reloading preview of every file in emails/, with desktop, mobile and plain text views. No message is sent.

2. Use the built-in test addresses

Resend exposes simulator addresses that trigger a specific outcome without touching a real mailbox:

Address Simulated result Use it to test
[email protected] Successful delivery Happy path and delivered webhook
[email protected] Hard bounce Your suppression logic
[email protected] Spam complaint Unsubscribe and opt-out flow

3. Add a global recipient guard

One helper function, used everywhere, prevents the classic incident where a staging seed script emails ten thousand real customers.

const ALLOWED = (process.env.EMAIL_ALLOWLIST || '').split(',').filter(Boolean);

function safeRecipients(to: string[]) {
  if (process.env.NODE_ENV === 'production') return to;
  const redirect = process.env.EMAIL_DEV_REDIRECT || '[email protected]';
  return to.map((address) => (ALLOWED.includes(address) ? address : redirect));
}

4. Keep a local SMTP catcher for offline work

If you work on a plane or want zero external calls, point a Nodemailer transport at Mailpit or MailHog in Docker when MAIL_DRIVER=local, and use Resend only when MAIL_DRIVER=resend. The template rendering stays identical because React Email produces plain HTML either way. Much the same conclusion turns up on stackademic.com.

Step 8: Handle bounces, complaints and failures with webhooks

Sending is the easy half. Reputation management is what keeps you out of the spam folder.

Events you should listen to

Event What it means Recommended action
email.sent Accepted by the provider Store the id against your entity
email.delivered Accepted by the recipient server Mark the notification as done
email.bounced Permanent or temporary failure Suppress hard bounces immediately
email.complained Marked as spam Stop all non-critical email to that address
email.delivery_delayed Greylisting or a full mailbox Wait, do not resend manually

A webhook endpoint that verifies signatures

Resend signs webhooks with the Svix format. Verify the signature and use the raw body, not the parsed JSON. shahmirfaisal.com has covered this at length.

import express from 'express';
import { Webhook } from 'svix';

const router = express.Router();

router.post('/webhooks/resend',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET);
    let event;

    try {
      event = wh.verify(req.body, {
        'svix-id': req.headers['svix-id'],
        'svix-timestamp': req.headers['svix-timestamp'],
        'svix-signature': req.headers['svix-signature']
      });
    } catch {
      return res.status(400).send('invalid signature');
    }

    const recipient = event.data.to?.[0];

    switch (event.type) {
      case 'email.bounced':
        await suppressAddress(recipient, 'bounce');
        break;
      case 'email.complained':
        await suppressAddress(recipient, 'complaint');
        break;
      case 'email.delivered':
        await markDelivered(event.data.email_id);
        break;
    }

    return res.status(200).send('ok');
  }
);

Hard bounce versus soft bounce

  • Hard bounce (mailbox does not exist, domain invalid): never send to that address again. Flag it in your database.
  • Soft bounce (mailbox full, server busy): safe to retry with backoff, but suppress after three or four consecutive failures.
  • Retrying a hard bounce repeatedly is the fastest way to damage your domain reputation.
email code laptop

Step 9: Retries, idempotency and rate limits

Automated retries are the most common cause of duplicate emails, especially in queue workers and AI agent loops. Three defences:

  1. Idempotency keys. Pass a stable key per business event so a repeated call returns the original result instead of sending twice.
  2. A send ledger. Store a row keyed by (user_id, template, entity_id) before calling the API, and refuse to send if a recent row exists.
  3. Backoff on 429. The default API rate limit is modest (around two requests per second), so add exponential backoff and jitter in your worker.
await resend.emails.send(
  {
    from: process.env.EMAIL_FROM,
    to: [order.customerEmail],
    subject: 'Your order ' + order.id + ' is confirmed',
    react: OrderConfirmationEmail({ ...order })
  },
  { idempotencyKey: 'order-confirmation/' + order.id }
);

For bulk notifications, use the batch endpoint instead of a loop. It accepts up to 100 messages per call:

await resend.batch.send(
  recipients.map((user) => ({
    from: process.env.EMAIL_FROM,
    to: [user.email],
    subject: 'Your weekly summary',
    react: WeeklySummaryEmail({ user })
  }))
);

Common errors and how to fix them

Symptom Cause Fix
403 domain is not verified Sending from an unverified domain Finish DNS verification or use [email protected] while testing
Works locally, fails in production You were only sending to your own account address Verify the domain, then any recipient is allowed
401 invalid API key Env var missing at build time Set it in the hosting dashboard and redeploy
429 too many requests Loop sending without throttle Use batch send plus exponential backoff
Email lands in spam Missing DMARC, no text part, link shorteners Publish DMARC, add plain text, use your own domain for links
Broken layout in Outlook Flexbox or modern CSS Stick to React Email Row and Column primitives

Pre-launch deliverability checklist

  • SPF, DKIM and DMARC all green in the dashboard.
  • A real Reply-To that a human monitors.
  • Plain text alternative generated for every template.
  • Physical address and an unsubscribe link for anything non-transactional.
  • Bounce and complaint webhook deployed and writing to a suppression table.
  • Subject lines under 60 characters and a filled preheader.
  • All links pointing to your own verified domain, no URL shorteners.
  • A staging environment that can never reach a production mailing list.

FAQ

How do you send an email with Resend?

Install the SDK with npm install resend, create an API key, verify your domain, then call resend.emails.send() with from, to, subject and either html, text or a react component. The call returns an email id you should log.

Does Resend have SMTP?

Yes. Alongside the HTTP API, Resend offers an SMTP endpoint so you can keep an existing Nodemailer or framework mailer setup. You use resend as the username and your API key as the password. The HTTP API is still preferable for new code because of better error reporting and native React Email support.

How do I resend a failed email?

Check the event first. Temporary failures and delays usually resolve themselves, so retry with exponential backoff. Hard bounces should never be retried: suppress the address instead. When you do retry programmatically, always attach an idempotency key so a queue replay does not send the same message twice.

Can I receive emails with Resend?

Resend supports inbound email on a configured domain, delivered to your application as a webhook payload rather than an IMAP mailbox. This is handy for reply-to-ticket flows, but check the current availability and pricing of the inbound feature before designing around it.

Is React Email required to use Resend?

No. You can pass raw HTML strings, Handlebars output or any template engine. React Email simply gives you type safety, component reuse and a local preview server, which pays off as soon as you have more than two or three templates.

Should transactional and marketing emails share a domain?

No. Use separate subdomains such as mail.yourdomain.com for transactional messages and news.yourdomain.com for campaigns, so a low-performing newsletter cannot push your password resets into the spam folder.

Wrapping up

Sending email from Node.js with Resend takes about ten lines of code. Sending email that consistently reaches the inbox takes the rest of this article: a verified subdomain with SPF, DKIM and DMARC, templates that render in Outlook, a queue with idempotency, a suppression list fed by webhooks, and a local workflow where nobody can accidentally email your customers.

Need help building or auditing the notification layer of your product? The team at Box Software ships this kind of infrastructure for Node.js and Next.js applications every week. Get in touch and we will review your current setup.