50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
emailMay 19, 2026·7 min read

Transactional Emails with Resend and React Email: The Setup That Actually Works

Stop wrestling with HTML email templates. Here's how we wire up Resend and React Email in Next.js to send beautiful, reliable transactional emails.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Transactional Emails with Resend and React Email: The Setup That Actually Works

Every SaaS needs transactional email. Welcome emails, password resets, invoices, usage alerts — users expect them, and if they end up in spam or look like they were designed in 2003, you lose trust fast. We've been through the whole spectrum: raw nodemailer with inline HTML, SendGrid's drag-and-drop editor (never again), and finally landing on Resend + React Email. It's the first setup we've genuinely enjoyed working with.

This post walks through the full setup — installing dependencies, writing your first React Email template, sending it via a Next.js Server Action, and a few gotchas we hit along the way. By the end you'll have something production-ready, not just a proof of concept.

Why Resend and React Email?

The honest answer: because writing HTML emails by hand is a form of self-harm. Table layouts, inline styles everywhere, Outlook quirks that make no sense — it's miserable. React Email lets you write email templates as React components with a proper component library (buttons, columns, images) that handles cross-client compatibility for you. Resend is the email delivery service built by the same team, with a clean API and a generous free tier (3,000 emails/month).

Together they feel like they were designed for each other — because they were. The DX is miles ahead of anything else we've tried. You preview templates live in the browser, you get TypeScript types on the send API, and you don't have to think about whether Yahoo Mail will render your CSS correctly.

Installation

Start with the packages. You need both the React Email component library and the Resend SDK:

npm install resend @react-email/components react-email
# or
pnpm add resend @react-email/components react-email

Then add your Resend API key to your environment variables. Grab one from resend.com — takes about 30 seconds to sign up and get a key.

# .env.local
RESEND_API_KEY=re_your_key_here
EMAIL_FROM=hello@yourdomain.com
One thing that trips people up: Resend requires you to verify your domain before sending from it in production. During development, you can send to your own email from onboarding@resend.dev, which is useful for testing without domain setup.

Writing Your First Email Template

Create an emails/ directory at your project root. React Email's dev server will pick up templates from there automatically when you run the preview server. Here's a welcome email template — the kind you'd send after someone signs up:

// emails/welcome.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Html,
  Img,
  Link,
  Preview,
  Section,
  Text,
} from '@react-email/components';
import * as React from 'react';

interface WelcomeEmailProps {
  userName: string;
  loginUrl: string;
}

export default function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to the app, {userName}!</Preview>
      <Body style={main}>
        <Container style={container}>
          <Heading style={h1}>Welcome, {userName} 👋</Heading>
          <Text style={text}>
            Thanks for signing up. Your account is ready — here's what you can do next.
          </Text>
          <Section style={buttonContainer}>
            <Button href={loginUrl} style={button}>
              Go to your dashboard
            </Button>
          </Section>
          <Text style={footer}>
            If you didn't create this account, you can safely ignore this email.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

const main = {
  backgroundColor: '#f6f9fc',
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
};

const container = {
  backgroundColor: '#ffffff',
  margin: '0 auto',
  padding: '40px 20px',
  maxWidth: '600px',
  borderRadius: '8px',
};

const h1 = {
  color: '#1a1a1a',
  fontSize: '24px',
  fontWeight: '600',
  margin: '0 0 20px',
};

const text = {
  color: '#4a4a4a',
  fontSize: '16px',
  lineHeight: '1.6',
  margin: '0 0 24px',
};

const buttonContainer = {
  margin: '24px 0',
};

const button = {
  backgroundColor: '#0070f3',
  borderRadius: '6px',
  color: '#fff',
  fontSize: '16px',
  fontWeight: '600',
  padding: '12px 24px',
  textDecoration: 'none',
  display: 'inline-block',
};

const footer = {
  color: '#9ca3af',
  fontSize: '13px',
  marginTop: '32px',
};

Styles go as plain JS objects — no CSS-in-JS magic needed, React Email handles converting them to inline styles for email client compatibility. It's a bit verbose, but it's explicit and it works everywhere.

To preview this in the browser while you're building it, run the React Email dev server:

npx react-email dev --dir emails --port 3001

You get a live preview at localhost:3001 that shows how the email renders, with a desktop/mobile toggle and even a dark mode preview. This alone saves hours compared to sending test emails every time you tweak a margin.

The Email Client: One File, Used Everywhere

Rather than instantiating the Resend client in every file, create a shared email utility. We put ours in lib/email.ts:

// lib/email.ts
import { Resend } from 'resend';
import { render } from '@react-email/components';
import WelcomeEmail from '@/emails/welcome';
import PasswordResetEmail from '@/emails/password-reset';

const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = process.env.EMAIL_FROM ?? 'hello@yourdomain.com';

export async function sendWelcomeEmail({
  to,
  userName,
  loginUrl,
}: {
  to: string;
  userName: string;
  loginUrl: string;
}) {
  const { data, error } = await resend.emails.send({
    from: FROM,
    to,
    subject: `Welcome, ${userName}!`,
    react: WelcomeEmail({ userName, loginUrl }),
  });

  if (error) {
    console.error('[sendWelcomeEmail]', error);
    throw new Error(`Failed to send welcome email: ${error.message}`);
  }

  return data;
}

export async function sendPasswordResetEmail({
  to,
  userName,
  resetUrl,
  expiresInMinutes = 30,
}: {
  to: string;
  userName: string;
  resetUrl: string;
  expiresInMinutes?: number;
}) {
  const { data, error } = await resend.emails.send({
    from: FROM,
    to,
    subject: 'Reset your password',
    react: PasswordResetEmail({ userName, resetUrl, expiresInMinutes }),
  });

  if (error) {
    console.error('[sendPasswordResetEmail]', error);
    throw new Error(`Failed to send password reset email: ${error.message}`);
  }

  return data;
}

The pattern here: one exported function per email type, typed inputs, throw on error. Callers don't need to know anything about Resend — they just call sendWelcomeEmail() and trust it works. When you want to switch providers someday, you change one file.

Wiring It Into a Server Action

Here's how you'd trigger that welcome email from a signup Server Action in Next.js. The key thing: always do this server-side, never from client components. Your API key stays safe, and you avoid CORS nonsense.

// app/actions/auth.ts
'use server';

import { db } from '@/lib/db';
import { sendWelcomeEmail } from '@/lib/email';
import { hash } from 'bcryptjs';
import { redirect } from 'next/navigation';

export async function signUpAction(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  const name = formData.get('name') as string;

  // Basic validation
  if (!email || !password || !name) {
    return { error: 'All fields are required' };
  }

  const existing = await db.user.findUnique({ where: { email } });
  if (existing) {
    return { error: 'Email already in use' };
  }

  const hashedPassword = await hash(password, 12);

  const user = await db.user.create({
    data: { email, name, password: hashedPassword },
  });

  // Send welcome email — don't await if you want faster response
  // but do handle the error somewhere
  sendWelcomeEmail({
    to: email,
    userName: name,
    loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  }).catch((err) => {
    // Log but don't fail the signup
    console.error('Welcome email failed for user', user.id, err);
  });

  redirect('/dashboard');
}

Notice we're not awaiting the email send. Signup should feel instant — the user shouldn't wait an extra 200ms because our email provider had a slow moment. We fire it off and catch any errors separately. If the welcome email fails, that's a problem worth logging and alerting on, but it shouldn't block the user from accessing their account.

For critical emails like password resets, you should await and surface errors to the user. For 'nice to have' emails like welcome or digest, fire-and-forget with error logging is usually fine.

Gotchas We Hit

A few things that bit us before we figured them out:

  • Domain verification in production: You can't send from your own domain until you add DNS records (SPF, DKIM) that Resend provides. Do this before launch day, not during. We didn't, and spent an hour in DNS hell at 11pm.
  • The 'react' prop vs 'html' prop: Resend accepts either react (a React element) or html (a string). When passing a React component, make sure you're passing the rendered element — WelcomeEmail({ props }) not WelcomeEmail itself.
  • Environment variables in email templates: Don't reference process.env inside email template files. Do it in the calling code and pass values as props. Email templates get rendered on the server, so it technically works, but it's messy and harder to test.
  • Preview text length: The Preview component sets the preheader text. Keep it under 90 characters — beyond that, email clients cut it off or pull in content from the email body to fill the preview snippet.
  • Images in emails: Host them on a public CDN, not locally. Resend doesn't host images for you. We use Cloudflare R2 for static assets including email images.

Testing Without Sending Real Emails

During development, you have a few options. The simplest: Resend lets you send to any email when using their test domain (onboarding@resend.dev as the from address), so you can send to your own Gmail and actually see the emails. For a more systematic approach, check your Resend dashboard — every email you send shows up there with the rendered HTML, which is handy for debugging.

For CI/automated tests, you can mock the email module entirely:

// In your test setup or individual test files
jest.mock('@/lib/email', () => ({
  sendWelcomeEmail: jest.fn().mockResolvedValue({ id: 'test-email-id' }),
  sendPasswordResetEmail: jest.fn().mockResolvedValue({ id: 'test-email-id' }),
}));

// Then in your test
import { sendWelcomeEmail } from '@/lib/email';

it('sends welcome email after signup', async () => {
  await signUpAction(formData);
  expect(sendWelcomeEmail).toHaveBeenCalledWith(
    expect.objectContaining({
      to: 'user@example.com',
      userName: 'Test User',
    })
  );
});

This keeps tests fast and deterministic. You're testing that the right email function was called with the right arguments — not that Resend's API works (that's their job).

When You're Ready to Scale This Up

The setup above handles most SaaS needs out of the box. When you start sending thousands of emails, a few things are worth adding: rate limiting on your side (Resend has limits per plan), idempotency checks so you don't double-send on retries, and a job queue for batch emails rather than hitting the API directly from API routes.

For the queue side, we use Trigger.dev or BullMQ depending on the project. If you want to see how all of this — email, auth, payments, database — fits together in a production-ready starter, our templates on peal.dev come with Resend and React Email already wired up, so you're not rebuilding this from scratch every project.

The honest takeaway: transactional email isn't glamorous, but it's one of those things users definitely notice when it's broken and rarely think about when it works. Get it set up properly once — with real templates, a clean sending abstraction, and domain authentication — and you won't have to think about it again until you're adding a new email type. That's the goal.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders