Methodology. This tutorial synthesizes the Resend and React Email documentation as of May 2026. Code patterns mirror the canonical Resend Next.js examples. For broader email tool comparisons see best email marketing tools for SaaS.

Transactional email is the unglamorous plumbing of every SaaS. Welcome messages, password resets, magic links, receipts, billing failures — none of these features are visible on a landing page, but every single one of them needs to land in an inbox within seconds or the entire signup-to-paid-customer flow breaks. Resend has become the default choice for solo founders building on Next.js because the SDK is small, the React Email integration removes the worst part of HTML email authoring, and the free tier is generous enough to cover product-market fit. This tutorial walks the eight steps from a fresh project to a verified, error-handled welcome email landing in production.

The flow targeted here is concrete: a user signs up via Supabase Auth (magic link or OAuth), the auth callback fires, and a welcome email goes out within seconds. The same pattern extends to password reset emails, billing receipts, team invitations, and any other one-off message a single user triggers. Bulk marketing email is a different problem with a different toolset — covered in the gotchas section below.

1 Sign up and verify a sending domain

Create a Resend account at resend.com. The dashboard immediately offers a sandbox sender address, onboarding@resend.dev, which works for testing but is throttled and goes to spam in most production inboxes. Skip it — the first real action is verifying your own sending domain.

From Domains → Add Domain, enter the domain you want to send from (something like your-domain.com or a subdomain like mail.your-domain.com). Resend generates three DNS records you have to add at your registrar:

  • SPF (TXT record): tells receiving servers that Resend is allowed to send on your behalf
  • DKIM (TXT or CNAME): a cryptographic signature that proves the email was authorized by your domain
  • DMARC (TXT record): tells receiving servers what to do when SPF or DKIM checks fail — start with p=none for monitoring, move to p=quarantine once stable

Propagation usually takes 5–30 minutes. Resend’s domain page polls automatically and turns green when each record validates. Don’t skip DMARC — Gmail and Yahoo started enforcing it in early 2024, and missing it is the most common reason a perfectly configured domain still ends up in spam.

2 Install the SDKs

Two packages cover the entire workflow. resend is the API client, react-email is the component library and CLI for previewing templates locally.

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

Add a preview script to package.json so the React Email dev server is one command away:

{
  "scripts": {
    "dev": "next dev",
    "email": "email dev --dir emails"
  }
}

Running npm run email starts a local preview at localhost:3000 (or 3001 if Next.js is already running) where every template in /emails renders with hot reload. This is the single biggest reason React Email exists — designing transactional templates without a live preview is painful in a way that’s hard to overstate until you’ve done it.

3 Configure environment variables

Resend authenticates with a single API key. Generate one from API Keys → Create API Key, scope it to Sending access, and copy the value — you can’t view it again later.

# .env.local
RESEND_API_KEY=re_xxx_your_real_key_here
EMAIL_FROM="Your SaaS <noreply@your-domain.com>"
EMAIL_REPLY_TO=support@your-domain.com

Then add the same three values to your Vercel project under Settings → Environment Variables for the Production, Preview, and Development environments. The EMAIL_FROM value uses the standard "Friendly Name <address@domain>" format — receivers see “Your SaaS” in the inbox rather than a raw address. Use a subdomain like noreply@mail.your-domain.com if you want to keep your root domain’s reputation isolated from transactional sends.

4 Build a React Email template

Create an /emails folder at the project root (peer to /src) and add a welcome template. React Email templates are normal React components built from a small set of primitives that compile to inline-styled, table-based HTML compatible with every major email client.

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

interface WelcomeEmailProps {
  firstName: string;
  dashboardUrl: string;
}

export default function WelcomeEmail({
  firstName,
  dashboardUrl,
}: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: 'Helvetica, Arial, sans-serif',
                     backgroundColor: '#f8f7f4', padding: '32px 0' }}>
        <Container style={{ maxWidth: 560, margin: '0 auto',
                            background: '#fff', borderRadius: 12,
                            padding: '40px' }}>
          <Heading style={{ fontSize: 24, color: '#0f0f0f',
                            marginBottom: 16 }}>
            Welcome to Your SaaS, {firstName}.
          </Heading>
          <Text style={{ fontSize: 16, color: '#333',
                         lineHeight: 1.6 }}>
            Your account is ready. The first thing most folks do is
            create a workspace and invite a teammate.
          </Text>
          <Section style={{ textAlign: 'center', margin: '32px 0' }}>
            <Button href={dashboardUrl}
              style={{ background: '#0f0f0f', color: '#fff',
                       padding: '12px 24px', borderRadius: 8,
                       textDecoration: 'none', fontWeight: 600 }}>
              Open dashboard
            </Button>
          </Section>
          <Hr style={{ borderColor: '#e8e6e0', margin: '32px 0' }} />
          <Text style={{ fontSize: 13, color: '#777' }}>
            Reply to this email if you have questions — we read every one.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Run npm run email to render this in the local preview. Adjust copy and styling, then save — the template is now ready to be passed to the Resend SDK as a React component, no manual HTML stringification required.

5 Build the send server action

Create a small wrapper module that owns the Resend client and exposes typed send functions per template. Keeping all sends in one module makes it trivial to add tracing, retries, or a kill switch later.

// src/lib/email.ts
import { Resend } from 'resend';
import WelcomeEmail from '../../emails/welcome';

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

const FROM = process.env.EMAIL_FROM!;
const REPLY_TO = process.env.EMAIL_REPLY_TO;

export async function sendWelcomeEmail(params: {
  to: string;
  firstName: string;
  dashboardUrl: string;
}) {
  const { data, error } = await resend.emails.send({
    from: FROM,
    to: params.to,
    replyTo: REPLY_TO,
    subject: `Welcome to Your SaaS, ${params.firstName}`,
    react: WelcomeEmail({
      firstName: params.firstName,
      dashboardUrl: params.dashboardUrl,
    }),
    tags: [
      { name: 'category', value: 'transactional' },
      { name: 'template', value: 'welcome' },
    ],
  });

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

  return data;
}

The react field accepts a React element directly — the SDK handles rendering to HTML. The tags array shows up in the Resend dashboard for filtering and debugging, which gets useful around the time you have ten templates in flight.

6 Wire into the post-signup flow

The most natural place to fire a welcome email is the auth callback route — after Supabase confirms the magic link or OAuth flow and creates a session, but before redirecting the user to the dashboard. The exchange-code-for-session step (covered in the magic link auth tutorial) is where you have a confirmed user object and a fresh session to work with.

// src/app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
import { sendWelcomeEmail } from '@/lib/email';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code');
  if (!code) return NextResponse.redirect(new URL('/auth/login', req.url));

  const cookieStore = cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: {
        get: (n) => cookieStore.get(n)?.value,
        set: (n, v, o) => cookieStore.set({ name: n, value: v, ...o }),
        remove: (n, o) => cookieStore.set({ name: n, value: '', ...o }),
    } }
  );

  const { data, error } = await supabase.auth.exchangeCodeForSession(code);
  if (error || !data.user) {
    return NextResponse.redirect(new URL('/auth/login?error=1', req.url));
  }

  // Only fire on first sign-in (created_at within the last 60 seconds)
  const created = new Date(data.user.created_at).getTime();
  const isNew = Date.now() - created < 60_000;
  if (isNew) {
    // fire-and-forget — don't block the redirect on email
    sendWelcomeEmail({
      to: data.user.email!,
      firstName: data.user.user_metadata?.first_name ?? 'there',
      dashboardUrl: `${url.origin}/dashboard`,
    }).catch((e) => console.error('[email] welcome failed', e));
  }

  return NextResponse.redirect(new URL('/dashboard', req.url));
}

Two design choices worth calling out. First, the isNew check — without it, every sign-in resends the welcome email, which is exactly the kind of bug that reaches production unnoticed. Second, the email send is intentionally not awaited in the redirect path. Email send latency is unpredictable (usually 200–600 ms, occasionally a couple of seconds during Resend incidents), and a slow welcome email shouldn’t delay the user landing on the dashboard. Log failures, but don’t block the user.

7 Handle errors, rate limits, and retries

The Resend response always returns { data, error } — never throws on send failure. Inspect error to distinguish three kinds of problems: validation errors (bad from, missing recipient), rate limits (429 responses), and infrastructure errors (5xx, network timeouts). Only the last two are worth retrying.

// src/lib/email-retry.ts
import { Resend } from 'resend';

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

interface SendArgs {
  from: string;
  to: string;
  subject: string;
  react: React.ReactElement;
}

export async function sendWithRetry(args: SendArgs, attempts = 3) {
  let lastError: unknown;

  for (let i = 0; i < attempts; i++) {
    const { data, error } = await resend.emails.send(args);
    if (!error) return data;

    lastError = error;
    const isRateLimit = error.name === 'rate_limit_exceeded';
    const isServer = error.statusCode && error.statusCode >= 500;

    if (!isRateLimit && !isServer) break;  // permanent failure
    await new Promise((r) => setTimeout(r, 2 ** i * 500));
  }

  throw lastError;
}

Resend’s default rate limit on the free and Pro plans is 2 requests per second per API key — well above what most transactional flows generate, but easy to hit during a launch when fifty users sign up in a minute. The exponential backoff above is enough to ride out short bursts. For sustained higher-volume flows, queue the sends through a job runner (Inngest, Trigger.dev, or a simple Vercel cron job) rather than firing them inline.

8 Verify in dev and production

Local verification has two modes. The React Email preview server (npm run email) renders templates as HTML so you can iterate on copy and design without sending anything. To verify the full send path end-to-end in development, send to your own inbox — Resend’s dashboard logs every send with the rendered HTML, headers, and response code, which makes debugging deliverability issues much faster than digging through inbox spam folders.

For production verification, the checklist:

  • SPF, DKIM, DMARC all green in the Resend Domains page
  • Reply-to address points at a real inbox — not a noreply that bounces user replies
  • From-name is set in EMAIL_FROM — raw addresses look like phishing in some clients
  • One real signup tested against a Gmail address, a Yahoo address, and an Outlook address — the three biggest providers each enforce subtly different rules
  • Resend webhook configured for email.bounced and email.complained events — you want to know fast when a user’s email is hard-bouncing or marking your messages as spam

Production gotchas and what to watch

Why you NEED a verified domain in production

The default onboarding@resend.dev address works for the first ten test sends and then runs into multiple problems: it’s rate-limited aggressively, most major inboxes filter it to spam by default, and it doesn’t carry your brand. A verified domain is non-negotiable for production. The 5–30 minutes of DNS work pays off the first time a user replies to a welcome email and the conversation actually reaches you.

The 100 emails/day free tier

Resend’s free tier is 100 emails per day and 3,000 per month with one verified domain — enough for early product-market fit and the first few hundred users. The Pro plan starts at $20/month for 50,000 emails/month and adds dedicated IPs and higher rate limits. Most solo founders outgrow Free between $1K and $5K MRR, depending on how chatty their flows are. The full pricing breakdown lives at Resend pricing explained.

Why marketing emails should NOT go through Resend

Resend is built for transactional sends — one user, one trigger, one message. Bulk marketing email (newsletters, broadcast announcements, drip campaigns to 5,000 subscribers) is a different category with different requirements: list management, unsubscribe handling, segmentation, deliverability tuning over months of warmup. Mixing transactional and marketing on the same domain or same provider also damages your transactional deliverability when someone marks a marketing email as spam. The standard split for solo founders in 2026 is Resend (or SendGrid) for transactional and a dedicated platform like Beehiiv or Loops for marketing — rounded up in best email marketing tools for SaaS.

Resend webhooks for tracking

The Resend dashboard shows a per-email log, but for production observability you want webhook events flowing into your own database or analytics tool. Configure Webhooks → Add Endpoint with a route like /api/webhooks/resend and subscribe to the events that matter: email.delivered, email.bounced, email.complained, and optionally email.opened and email.clicked. Verify the webhook signature on receipt (Resend signs payloads with HMAC-SHA256) and write the event into a small email_events table keyed on the Resend message ID. This is what gives you a real answer when a customer support ticket says “I never got the email” — you can see whether it bounced, was delivered, or never sent in the first place.

Sandbox sending while developing

Resend doesn’t have a true sandbox mode like Stripe, but the React Email preview covers most of what you’d use a sandbox for. For end-to-end testing without spamming yourself, use the Resend “test” tag and route those events to a separate Slack channel or log stream, or use a service like Mailtrap as your dev SMTP target. Keep production keys out of .env.local and use a separate Resend project (with its own free 100/day quota) for development if you’re paranoid about staging email reaching real users.

Summary
Verify domain → React Email template → server action → auth callback → webhook tracking

Resend is the lowest-friction transactional email setup available for Next.js in 2026. The eight steps above cover everything from a fresh project to a production-verified welcome flow. Skip the verified domain, skip DMARC, or skip the webhook setup at your own risk — each one is a different way for a working email pipeline to silently break in production.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.