Next.js Transactional Emails with Resend and React Email: Server Actions Guide (2026)

Send beautiful, type-safe transactional emails from Next.js Server Actions and Route Handlers. Complete 2026 guide using Resend, React Email, edge runtime, webhooks, rate limiting, and testing.

Next.js Resend & React Email Guide 2026

Transactional emails — welcome messages, password resets, order receipts, magic links — are mission-critical for most Next.js apps, and yet they're surprisingly easy to get wrong. I've lost count of how many tutorials still lean on Nodemailer with hand-tuned SMTP credentials and hard-coded HTML strings, completely missing the App Router patterns introduced in Next.js 14, 15, and 16. So let's take a different route: we'll combine Resend (a modern, developer-first email API) with React Email (typed JSX templates) and call them straight from Server Actions and Route Handlers.

By the end you'll have a production-ready setup that sends rich, branded emails with full type safety, previews them locally, handles bounces and webhooks, and survives the cold reality of inbox providers in 2026. Honestly, once you've built it once, you'll never want to go back.

Why Resend + React Email Beats Nodemailer in 2026

For years, nodemailer was the default. It works — but it forces you to manage SMTP credentials, build raw HTML, and reason about MIME boundaries (yikes). In a serverless world (Vercel, Netlify, Cloudflare Workers), SMTP connections are slow, frequently blocked on certain ports, and just don't play well with the Edge Runtime.

Resend solves this by exposing a simple HTTPS API — free up to 3,000 emails per month at the time of writing — and pairing natively with React Email. React Email lets you author messages as React components, which means you preview them in the browser, share design tokens with your marketing site, and let TypeScript catch typos in props before they reach a customer's inbox.

  • Edge-compatible — works in Edge Runtime, no SMTP sockets required.
  • Type-safe templates — React Email components catch errors at build time.
  • Built-in deliverability — DKIM, SPF, and DMARC are configured through the dashboard.
  • Webhooks for events — opens, clicks, bounces, and complaints arrive as JSON.

Project Setup

We'll start from a clean Next.js 16 project using the App Router. If you already have one, just skip to the package installation step.

npx create-next-app@latest nextjs-emails --typescript --tailwind --app
cd nextjs-emails
npm install resend react-email @react-email/components
npm install -D dotenv-cli

Configure Environment Variables

Create an .env.local file in the project root. Generate an API key inside the Resend dashboard at API Keys → Create API Key, then add it along with a verified sender domain.

RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
EMAIL_FROM="AutoContent <[email protected]>"
[email protected]

Important: Resend will only deliver email from domains you've verified through DNS. While testing, you can use the shared [email protected] sender, but it's restricted to your own account email — verify a real domain before going live (trust me, I learned this one the hard way at 11pm before a launch).

Build Your First React Email Template

Create a folder structure that keeps email templates separate from your UI components:

app/
emails/
  welcome.tsx
  password-reset.tsx
  order-receipt.tsx
lib/
  email.ts

Here's a typed welcome template that uses the prebuilt @react-email/components primitives:

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

interface WelcomeEmailProps {
  name: string;
  verifyUrl: string;
}

export default function WelcomeEmail({ name, verifyUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to AutoContent — verify your email</Preview>
      <Tailwind>
        <Body className="bg-white font-sans">
          <Container className="mx-auto max-w-xl px-6 py-10">
            <Heading className="text-2xl font-bold text-slate-900">
              Hi {name}, welcome aboard
            </Heading>
            <Text className="text-slate-600">
              Confirm your email so we can keep your account secure.
            </Text>
            <Section className="my-6">
              <Button
                href={verifyUrl}
                className="rounded-md bg-slate-900 px-5 py-3 text-white"
              >
                Verify email
              </Button>
            </Section>
            <Text className="text-xs text-slate-400">
              If the button doesn't work, paste this URL: {verifyUrl}
            </Text>
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
}

WelcomeEmail.PreviewProps = {
  name: "Alex",
  verifyUrl: "https://autocontent.example/verify?token=abc",
} satisfies WelcomeEmailProps;

That PreviewProps assignment is genuinely one of my favorite parts — it lets you launch a local preview server with sample data, which is invaluable when you're fine-tuning spacing in a Gmail or Outlook client (and Outlook, well, has opinions).

Preview Templates Locally

Add a script to package.json:

{
  "scripts": {
    "email": "email dev --dir emails --port 3001"
  }
}

Run npm run email and open http://localhost:3001. You'll see every template rendered against multiple inbox clients, plus a button to send a real test email through Resend. It's a tiny bit magical the first time.

Wire Up the Sender Helper

Centralize sending logic so you can swap providers later without touching feature code. Future-you will thank you.

// lib/email.ts
import "server-only";
import { Resend } from "resend";
import { ReactElement } from "react";

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

interface SendOptions {
  to: string | string[];
  subject: string;
  react: ReactElement;
  replyTo?: string;
  tags?: { name: string; value: string }[];
}

export async function sendEmail(opts: SendOptions) {
  const { data, error } = await resend.emails.send({
    from: process.env.EMAIL_FROM!,
    to: opts.to,
    subject: opts.subject,
    react: opts.react,
    replyTo: opts.replyTo ?? process.env.EMAIL_REPLY_TO,
    tags: opts.tags,
  });

  if (error) {
    console.error("[email] failed", { error, to: opts.to });
    throw new Error(error.message);
  }

  return data;
}

The "server-only" import guarantees this module is never accidentally bundled into a client component — a classic foot-gun that quietly leaks API keys. Don't skip it.

Send Email from a Server Action

Server Actions are the natural place to send transactional email: they run on the server, can read cookies, and integrate cleanly with form submissions. Here's a sign-up flow that creates a user and sends a welcome email in one go:

// app/(auth)/signup/actions.ts
"use server";

import { z } from "zod";
import { createUser, generateVerifyToken } from "@/lib/users";
import { sendEmail } from "@/lib/email";
import WelcomeEmail from "@/emails/welcome";
import { redirect } from "next/navigation";

const SignupSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

export async function signupAction(prev: unknown, formData: FormData) {
  const parsed = SignupSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  const { name, email, password } = parsed.data;
  const user = await createUser({ name, email, password });
  const token = await generateVerifyToken(user.id);
  const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${token}`;

  await sendEmail({
    to: email,
    subject: "Welcome — verify your email",
    react: WelcomeEmail({ name, verifyUrl }),
    tags: [{ name: "category", value: "welcome" }],
  });

  redirect("/signup/check-email");
}

Three details that competing tutorials usually skip:

  1. Validation runs before the side effect — Zod parses input first, so we never send mail with malformed data.
  2. Tags are attached — Resend uses tags to filter analytics in the dashboard and to scope webhook events.
  3. The redirect happens after sending — if delivery fails, the user sees a controlled error rather than a silent black hole.

Send Email from a Route Handler

Route Handlers are the right fit when emails are triggered by an external service — Stripe webhooks, GitHub events, or Trigger.dev jobs. Here's a Stripe receipt example:

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sendEmail } from "@/lib/email";
import OrderReceipt from "@/emails/order-receipt";

const stripe = new Stripe(process.env.STRIPE_SECRET!);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    await sendEmail({
      to: session.customer_details!.email!,
      subject: `Receipt for order #${session.id.slice(-6)}`,
      react: OrderReceipt({
        amount: session.amount_total! / 100,
        currency: session.currency!.toUpperCase(),
        orderId: session.id,
      }),
      tags: [{ name: "category", value: "receipt" }],
    });
  }

  return NextResponse.json({ received: true });
}

Handling Webhooks: Bounces, Complaints, and Opens

Sending email is half the job. Knowing what actually happened to it is the other half. Resend posts events to a webhook endpoint you configure in Webhooks → Add endpoint. Verify the signature using the svix-id, svix-timestamp, and svix-signature headers:

// app/api/email/webhook/route.ts
import { Webhook } from "svix";
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);

  let event;
  try {
    event = wh.verify(payload, {
      "svix-id": req.headers.get("svix-id")!,
      "svix-timestamp": req.headers.get("svix-timestamp")!,
      "svix-signature": req.headers.get("svix-signature")!,
    }) as { type: string; data: { email_id: string; to: string[] } };
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  if (event.type === "email.bounced" || event.type === "email.complained") {
    await db.emailSuppression.upsert({
      where: { address: event.data.to[0] },
      update: { reason: event.type, updatedAt: new Date() },
      create: { address: event.data.to[0], reason: event.type },
    });
  }

  return NextResponse.json({ ok: true });
}

Persist a suppression list and check it before sending. Repeated delivery to a bounced address damages your sender reputation more than just missing a single message ever would.

Edge Runtime vs Node.js Runtime

Resend works in both runtimes because it uses the standard fetch API. Edge Runtime is faster to cold-start and runs closer to the user, which makes it tempting for high-traffic flows. The catch? Edge functions have a 1MB request size limit and can't use Node-specific APIs like fs.

  • Use Node.js runtime when the email handler also touches a database via Prisma or Drizzle that needs Node sockets.
  • Use Edge runtime when sending is the only work — for example, a "contact us" form that doesn't persist anything.
// app/api/contact/route.ts
export const runtime = "edge";

import { sendEmail } from "@/lib/email";
import ContactNotification from "@/emails/contact";

export async function POST(req: Request) {
  const { name, message, email } = await req.json();
  await sendEmail({
    to: process.env.SUPPORT_INBOX!,
    subject: `New message from ${name}`,
    react: ContactNotification({ name, message, email }),
    replyTo: email,
  });
  return Response.json({ ok: true });
}

Rate Limiting and Abuse Prevention

Every public email endpoint is a spam vector — full stop. Wrap your sender in a rate limiter; Upstash Ratelimit pairs perfectly with Server Actions:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { headers } from "next/headers";

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(3, "1 h"),
  analytics: true,
});

export async function contactAction(formData: FormData) {
  const ip = (await headers()).get("x-forwarded-for") ?? "anon";
  const { success } = await limiter.limit(`email:${ip}`);
  if (!success) return { error: "Too many requests" };
  // ...send email
}

Three messages per IP per hour is generous for legitimate users and pretty much lethal for bots.

Testing Email Flows

Treat email like any other side effect: mock it in unit tests, verify it in end-to-end tests. With Vitest and a thin wrapper, mocking takes a single line:

// lib/email.test.ts
import { vi, describe, it, expect } from "vitest";
import { signupAction } from "@/app/(auth)/signup/actions";

vi.mock("@/lib/email", () => ({
  sendEmail: vi.fn().mockResolvedValue({ id: "test-id" }),
}));

describe("signupAction", () => {
  it("sends a welcome email after creating a user", async () => {
    const fd = new FormData();
    fd.set("name", "Alex");
    fd.set("email", "[email protected]");
    fd.set("password", "supersecure");
    await signupAction(null, fd);
    const { sendEmail } = await import("@/lib/email");
    expect(sendEmail).toHaveBeenCalledWith(
      expect.objectContaining({ to: "[email protected]" })
    );
  });
});

For end-to-end tests, Resend supports a test mode API key that accepts requests but never delivers — so your Playwright suites stay deterministic and your inbox stays sane.

Production Checklist

  • Verify your sending domain — add SPF, DKIM, and DMARC records in Resend's domain settings.
  • Configure a dedicated subdomain like mail.yourdomain.com to keep marketing and transactional reputations separate.
  • Set up a feedback loop — wire bounce and complaint webhooks before you scale.
  • Add an unsubscribe header for any non-receipt email — Gmail's bulk-sender rules require List-Unsubscribe and one-click unsubscribe support.
  • Monitor delivery rates in the Resend dashboard. A drop below 95% almost always indicates a DNS issue or a content trigger.
  • Throttle outbound volume — Resend ramps domain reputation gradually; sending 100k emails on day one will get you flagged faster than you can refresh the dashboard.

Common Pitfalls and How to Avoid Them

Bundling React Email Components into the Client

If you import a template from a Client Component, Next.js will warn about server-only modules. Always render the React Email JSX inside a Server Action or Route Handler — no exceptions.

Forgetting preview Text

Most inboxes show the first 100 characters of an email next to the subject line. The <Preview> component sets that snippet — without it, recipients see the start of your hidden tracking pixel. Not exactly a great first impression.

Hard-Coding URLs

A welcome link that points to localhost:3000 is honestly the most embarrassing production bug imaginable. Read URLs from process.env.NEXT_PUBLIC_APP_URL and validate it at boot.

Ignoring Plain-Text Fallbacks

React Email auto-generates a text version, but you can override it by passing text to resend.emails.send. Test that the plain version still makes sense — some corporate inboxes strip HTML entirely (looking at you, certain Fortune 500 mail gateways).

Frequently Asked Questions

Can I send emails from Next.js without Resend?

Yep — alternatives include AWS SES, Postmark, SendGrid, and Loops. Resend is the easiest path because it ships first-class React Email support, but the sendEmail wrapper above can be re-implemented against any provider in minutes. Just stick with one provider per environment to keep your deliverability stats meaningful.

Does Resend work with the Next.js Edge Runtime?

Yes. Resend's SDK uses fetch exclusively, so it runs in Edge Runtime, Cloudflare Workers, and Vercel Edge Functions. Just remember that database libraries you call alongside it may not be Edge-compatible.

How do I handle email verification tokens securely?

Store a hash of the token in your database, set a short expiry (1 hour for verification, 15 minutes for password reset), and invalidate it on first use. Generate tokens with crypto.randomUUID() or crypto.getRandomValues() — never Math.random(). Seriously, never.

Why are my emails landing in spam?

Nine times out of ten, the cause is missing or mis-configured DNS records. Run dig TXT yourdomain.com to confirm SPF and DMARC, then verify DKIM through Resend's domain page. Content matters too: avoid all-image emails, link shorteners, and the words "free" and "guarantee" in subject lines.

Can I preview React Email templates without leaving Next.js?

Yes — mount the React Email render() function inside a /dev/email/[template] route in development only. Many teams expose this internally so designers can iterate without running a separate dev server.

Wrapping Up

Resend plus React Email turns email from a chore into something you'll actually look forward to building (which, if you've ever wrestled with Nodemailer at 2am, sounds suspiciously like a marketing claim — but it's true). You get JSX templates, type-safe props, edge-friendly delivery, and webhooks that integrate cleanly with the App Router primitives you already use.

Ship the welcome flow first, then layer on receipts, magic links, and digest emails as your product grows. The combination scales from a side project to millions of messages without a rewrite — which is exactly what a production stack should do.

About the Author Editorial Team

Our team of expert writers and editors.