Transactional Email with Resend and React Email
How to build a mailer module that renders React Email templates and sends through Resend, with test-time capture so you never send real emails from CI.
Sending transactional email from a Next.js app looks simple on the surface: call an API, pass in some HTML, done. But the details add up. You need branded templates, a way to test them without actually sending mail, environment-specific behaviour, and compliance headers for unsubscribe links and postal addresses.
I built a Mailer module that handles all of this. It renders React Email templates, sends through Resend in production, and captures to an in-memory store in tests. Here is how it works.
The Mailer Interface
The starting point is a single send method that takes a template instance, a recipient, and optional props. The template decides the subject and body, the mailer decides the transport.
import { Resend } from 'resend'
import { MailCapture } from './capture'
type Template = {
subject: (props: Record<string, unknown>) => string
render: (props: Record<string, unknown>) => Promise<{ html: string; text: string }>
}
export class Mailer {
static async send(template: Template, to: string, props: Record<string, unknown> = {}) {
const subject = template.subject(props)
const { html, text } = await template.render(props)
if (process.env.NODE_ENV === 'test') {
MailCapture.push({ to, subject, html, text })
return
}
const resend = new Resend(process.env.RESEND_API_KEY)
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to,
replyTo: process.env.EMAIL_REPLY_TO,
subject,
html,
text,
})
}
}
The key design choice: in the test environment, Mailer.send writes to MailCapture instead of calling Resend. This means integration tests and unit tests never hit an external API, and you can assert on what was captured.
The EMAIL_FROM and EMAIL_REPLY_TO environment variables come from .env.example, which documents every variable the mailer needs:
RESEND_API_KEY=re_xxxxx
EMAIL_FROM=MyApp <[email protected]>
[email protected]
EMAIL_UNSUBSCRIBE_BASE_URL=https://myapp.com/unsubscribe
The EMAIL_UNSUBSCRIBE_BASE_URL is used by templates that need an unsubscribe link, which is legally required for any email the user can opt out of.
React Email Templates
React Email gives you typed components for building email layouts. The advantage over raw HTML is maintainability: you get TypeScript checking, reusable components, and consistent styling across templates.
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Section,
Text,
} from '@react-email/components'
interface WelcomeEmailProps {
name: string
unsubscribeUrl: string
}
export function WelcomeEmail({ name, unsubscribeUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to the waitlist</Preview>
<Body style={main}>
<Container style={container}>
<Section style={header}>
<Text style={logo}>MyApp</Text>
</Section>
<Section style={body}>
<Text style={greeting}>Hi {name},</Text>
<Text style={text}>
You're on the waitlist. We'll let you know as soon as your
account is ready.
</Text>
</Section>
<Section style={footer}>
<Text style={unsubscribe}>
<a href={unsubscribeUrl}>Unsubscribe</a>
</Text>
<Text style={address}>
123 Example Street, London, UK
</Text>
</Section>
</Container>
</Body>
</Html>
)
}
Every template includes a postal address in the footer. PECR and CAN-SPAM require this, even for transactional messages. The unsubscribe link covers any email the user might want to stop receiving.
To make the template work with the Mailer, export a subject function and a render function alongside the component:
import { render } from '@react-email/components'
export const WelcomeTemplate = {
subject: () => 'Welcome to the waitlist',
render: async (props: WelcomeEmailProps) => {
consthtml = await render(<WelcomeEmail {...props} />)
return { html: html, text: 'Welcome to the waitlist' }
},
}
MailCapture for Testing
The MailCapture class is a simple in-memory array that mimics an email outbox. It stores every email that Mailer.send produces during a test run.
export class MailCapture {
private static emails: CapturedEmail[] = []
static push(email: CapturedEmail) {
MailCapture.emails.push(email)
}
static last(): CapturedEmail {
return MailCapture.emails[MailCapture.emails.length - 1]
}
static all(): CapturedEmail[] {
return [...MailCapture.emails]
}
static clear() {
MailCapture.emails = []
}
}
In your test setup, call MailCapture.clear() in a beforeEach so each test starts with an empty inbox. Then assert on the captured emails:
import { MailCapture } from '@/lib/mailer/capture'
import { Mailer } from '@/lib/mailer'
import { WelcomeTemplate } from '@/lib/mailer/templates/waitlist-welcome'
beforeEach(() => MailCapture.clear())
test('sends welcome email with correct subject', async () => {
await Mailer.send(WelcomeTemplate, '[email protected]', {
name: 'Alex',
unsubscribeUrl: 'https://example.com/unsubscribe?token=abc',
})
const captured = MailCapture.last()
expect(captured.to).toBe('[email protected]')
expect(captured.subject).toBe('Welcome to the waitlist')
expect(captured.html).toContain('Hi Alex')
})
This approach means you can test the full rendering pipeline, from React component to HTML string, without ever touching Resend. The tests run fast because there are no network calls, and they are deterministic because there is no external state.
Testing Templates in Isolation
Alongside the mailer integration tests, I write unit tests that render individual templates and check their content. These are fast, isolated, and catch layout regressions early.
import { render } from '@react-email/components'
import { WelcomeEmail } from '@/lib/mailer/templates/waitlist-welcome'
test('renders welcome email with name', async () => {
const html = await render(
<WelcomeEmail
name="Alex"
unsubscribeUrl="https://example.com/unsubscribe?token=abc"
/>
)
expect(html).toContain('Hi Alex')
expect(html).toContain('Unsubscribe')
expect(html).toContain('unsubscribe?token=abc')
})
These tests render the component to an HTML string and assert that the dynamic content appears. They catch typos, missing props, and broken links without needing the full mailer pipeline.
DNS Setup for Deliverability
Building the email code is half the job. The other half is making sure it reaches the inbox. Resend requires you to verify your sending domain, and proper DNS records are what keep your emails out of spam.
The three records you need:
-
SPF (Sender Policy Framework) tells receiving servers which IPs are allowed to send on behalf of your domain. Add a TXT record with the value Resend provides.
-
DKIM (DomainKeys Identified Mail) adds a cryptographic signature to each email so receivers can verify it wasn't tampered with. Resend generates the DKIM key for you; add it as a CNAME.
-
DMARC (Domain-based Message Authentication, Reporting, and Conformance) tells receivers what to do when SPF or DKIM fails. Start with a monitoring-only policy (
v=DMARC1; p=none) and tighten it once you've verified your mail flows correctly.
# SPF
TXT @ "v=spf1 include:resend.com ~all"
# DKIM
CNAME resend._domainkey xxx.resend.com.
# DMARC
TXT _dmarc "v=DMARC1; p=none; rua=mailto:[email protected]"
After adding these records, verify the domain in the Resend dashboard. Resend checks that the DNS records match what it expects, and only then will it start accepting mail for your domain.
Environment Variables
Every env variable the mailer depends on is documented in .env.example. This is not optional. When a new developer clones the repo or CI spins up a runner, the example file should tell them everything they need to configure.
RESEND_API_KEY=re_xxxxx # Resend API key
EMAIL_FROM=MyApp <[email protected]> # Sender name and address
[email protected] # Reply-to address
EMAIL_UNSUBSCRIBE_BASE_URL=https://myapp.com/unsubscribe # Unsubscribe base URL
The test environment doesn't need RESEND_API_KEY set because MailCapture intercepts all sends. But you still want the variable documented so the next person knows what to configure for production.
Conclusion
A transactional email module needs three things: typed templates (React Email gives you this), a transport layer (Resend handles production, MailCapture handles tests), and proper DNS records (SPF, DKIM, DMARC) so your emails actually arrive. The Mailer.send abstraction means every new template gets the same transport, compliance headers, and test coverage with no extra wiring. Add a template, add a test, ship it.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.