React Email14 min read

Server Actions + React Email: Type-Safe Email Sending in Next.js 15

Build type-safe email flows with Next.js 15 Server Actions and React Email. No API routes, no fetch boilerplate—just one function that validates, renders, and sends. Includes rate limiting, idempotency, and production patterns.

R

React Emails Pro

March 4, 2026

Server Actions in Next.js 15 changed how we think about server-side logic. Instead of API routes, form actions, and manual validation, you get type-safe, progressive-enhanced functions that run on the server.

For email sending, this is massive: no more /api/send-email routes with copy-pasted validation. No more client-side fetch logic. Just one function that handles the whole flow.

Server Actions + React Email = type-safe email sending with zero boilerplate. This is the cleanest pattern for transactional emails in Next.js.

Why Server Actions beat API routes for email

API routes work. But they come with friction:

  • You need a separate file for every endpoint
  • Manual request parsing and validation
  • Client-side fetch boilerplate
  • No type safety between client and server
  • Error handling in two places

Server Actions solve all of this. One function, typed props, automatic serialization, and error boundaries that just work.


The basic pattern: a Server Action that sends email

Here's the minimal setup for sending a React Email template from a Server Action:

app/actions/send-email.ts
"use server";

import { z } from "zod";
import { render } from "@react-email/render";
import { Resend } from "resend";
import { WelcomeEmail } from "@/emails/welcome";

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

const schema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
});

export async function sendWelcomeEmail(
  prevState: any,
  formData: FormData
) {
  // 1. Validate input
  const result = schema.safeParse({
    email: formData.get("email"),
    name: formData.get("name"),
  });

  if (!result.success) {
    return {
      success: false,
      error: result.error.flatten().fieldErrors,
    };
  }

  const { email, name } = result.data;

  try {
    // 2. Render React Email template
    const html = await render(<WelcomeEmail name={name} />);

    // 3. Send via Resend
    await resend.emails.send({
      from: "onboarding@yoursaas.com",
      to: email,
      subject: `Welcome to YourSaaS, ${name}`,
      html,
    });

    return { success: true };
  } catch (error) {
    console.error("Email send failed:", error);
    return {
      success: false,
      error: { _form: ["Failed to send email"] },
    };
  }
}

That's it. No API route. No fetch. The action runs on the server, validates input with Zod, renders your React Email template, and sends.


Using the action in a form (with progressive enhancement)

Server Actions work with forms out of the box. Use useFormState for loading states and errors:

app/signup/page.tsx
"use client";

import { useFormState, useFormStatus } from "react-dom";
import { sendWelcomeEmail } from "@/app/actions/send-email";

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Sending..." : "Sign Up"}
    </button>
  );
}

export default function SignupPage() {
  const [state, formAction] = useFormState(sendWelcomeEmail, null);

  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        placeholder="you@example.com"
        required
      />
      {state?.error?.email && (
        <p className="error">{state.error.email}</p>
      )}

      <input
        type="text"
        name="name"
        placeholder="Your name"
        required
      />
      {state?.error?.name && (
        <p className="error">{state.error.name}</p>
      )}

      <SubmitButton />

      {state?.success && (
        <p className="success">Welcome email sent! Check your inbox.</p>
      )}
      {state?.error?._form && (
        <p className="error">{state.error._form}</p>
      )}
    </form>
  );
}

This works without JavaScript. The form submits, the action runs, the page reloads with state. When JS loads, it upgrades to a smooth, inline experience.

Progressive enhancement for free. Users on slow connections or with JS disabled can still send the form.

Type-safe pattern: Server Actions + React Email props

The real power comes from shared types. Your email template props and Server Action input can be the same schema:

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

export const WelcomeEmailSchema = z.object({
  name: z.string().min(1),
  dashboardUrl: z.string().url().optional(),
});

export type WelcomeEmailProps = z.infer<typeof WelcomeEmailSchema>;

export function WelcomeEmail({
  name,
  dashboardUrl = "https://yoursaas.com/dashboard",
}: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body>
        <Container>
          <Heading>Welcome, {name}!</Heading>
          <Text>
            Your account is ready. Click below to get started:
          </Text>
          <Button href={dashboardUrl}>Open Dashboard</Button>
        </Container>
      </Body>
    </Html>
  );
}

Now your Server Action can validate against the same schema:

app/actions/send-email.ts
"use server";

import { render } from "@react-email/render";
import { Resend } from "resend";
import {
  WelcomeEmail,
  WelcomeEmailSchema,
} from "@/emails/welcome";

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

export async function sendWelcomeEmail(props: unknown) {
  // Validate against the same schema as the email template
  const result = WelcomeEmailSchema.safeParse(props);

  if (!result.success) {
    return {
      success: false,
      error: result.error.flatten().fieldErrors,
    };
  }

  const validatedProps = result.data;

  // Render with type-safe props
  const html = await render(
    <WelcomeEmail {...validatedProps} />
  );

  await resend.emails.send({
    from: "onboarding@yoursaas.com",
    to: validatedProps.email,
    subject: `Welcome, ${validatedProps.name}`,
    html,
  });

  return { success: true };
}
One schema, two uses: runtime validation in the action + TypeScript types in the template. No prop drift between validation and rendering.

Error handling patterns

Server Actions make error handling cleaner. Return structured errors that map to form fields:

app/actions/send-email.ts
export async function sendWelcomeEmail(props: unknown) {
  try {
    const result = WelcomeEmailSchema.safeParse(props);

    if (!result.success) {
      return {
        success: false as const,
        error: result.error.flatten().fieldErrors,
      };
    }

    const html = await render(
      <WelcomeEmail {...result.data} />
    );

    const response = await resend.emails.send({
      from: "onboarding@yoursaas.com",
      to: result.data.email,
      subject: "Welcome!",
      html,
    });

    // Check for ESP-level errors
    if (response.error) {
      console.error("Resend error:", response.error);
      return {
        success: false as const,
        error: {
          _form: ["Email delivery failed. Try again later."],
        },
      };
    }

    return {
      success: true as const,
      messageId: response.data?.id,
    };
  } catch (error) {
    console.error("Unexpected error:", error);
    return {
      success: false as const,
      error: { _form: ["An unexpected error occurred."] },
    };
  }
}

This structure gives you:

  • Field-specific errors (shown inline)
  • Form-level errors (shown at the top)
  • Success state with metadata (message ID for tracking)

Advanced patterns

Rate limiting

Server Actions run on your server. Add rate limiting to prevent abuse:

app/actions/send-email.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

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

export async function sendWelcomeEmail(props: unknown) {
  // Rate limit by email address
  const { success } = await ratelimit.limit(
    `email:${props.email}`
  );

  if (!success) {
    return {
      success: false as const,
      error: {
        _form: ["Too many requests. Try again in an hour."],
      },
    };
  }

  // ... rest of the action
}

Idempotency keys

Prevent duplicate sends with idempotency keys:

app/actions/send-email.ts
import crypto from "crypto";

export async function sendWelcomeEmail(props: unknown) {
  const result = WelcomeEmailSchema.safeParse(props);
  if (!result.success) {
    return { success: false as const, error: result.error };
  }

  // Generate stable key from email content
  const idempotencyKey = crypto
    .createHash("sha256")
    .update(
      JSON.stringify({
        type: "welcome",
        email: result.data.email,
      })
    )
    .digest("hex");

  // Check if already sent (use Redis, DB, or similar)
  const alreadySent = await redis.get(idempotencyKey);
  if (alreadySent) {
    return {
      success: true as const,
      messageId: alreadySent,
      cached: true,
    };
  }

  const html = await render(<WelcomeEmail {...result.data} />);
  const response = await resend.emails.send({
    from: "onboarding@yoursaas.com",
    to: result.data.email,
    subject: "Welcome!",
    html,
  });

  if (response.data?.id) {
    // Cache the message ID
    await redis.set(idempotencyKey, response.data.id, {
      ex: 60 * 60 * 24, // 24 hours
    });
  }

  return {
    success: true as const,
    messageId: response.data?.id,
  };
}

Background processing (queues)

For high-volume sends, push to a queue instead of sending inline:

app/actions/send-email.ts
import { Queue } from "bullmq";

const emailQueue = new Queue("emails", {
  connection: { host: "localhost", port: 6379 },
});

export async function sendWelcomeEmail(props: unknown) {
  const result = WelcomeEmailSchema.safeParse(props);
  if (!result.success) {
    return { success: false as const, error: result.error };
  }

  // Enqueue instead of sending inline
  const job = await emailQueue.add("send-welcome", {
    email: result.data.email,
    name: result.data.name,
  });

  return {
    success: true as const,
    jobId: job.id,
  };
}

Then process the queue in a separate worker. This keeps the user interaction fast and handles retries automatically.


Testing Server Actions with React Email

Server Actions are just async functions. Test them like any other:

__tests__/send-email.test.ts
import { describe, it, expect, vi } from "vitest";
import { sendWelcomeEmail } from "@/app/actions/send-email";
import { Resend } from "resend";

vi.mock("resend");

describe("sendWelcomeEmail", () => {
  it("validates props before sending", async () => {
    const result = await sendWelcomeEmail({
      email: "invalid",
      name: "",
    });

    expect(result.success).toBe(false);
    expect(result.error?.email).toBeDefined();
  });

  it("sends email with valid props", async () => {
    const mockSend = vi.fn().mockResolvedValue({
      data: { id: "msg_123" },
    });

    vi.mocked(Resend).mockImplementation(() => ({
      emails: { send: mockSend },
    }));

    const result = await sendWelcomeEmail({
      email: "user@example.com",
      name: "Alice",
    });

    expect(result.success).toBe(true);
    expect(mockSend).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "user@example.com",
        subject: expect.stringContaining("Alice"),
      })
    );
  });
});

Common gotchas and fixes

Serialization limits

Server Actions serialize return values with JSON. Don't return complex objects (dates, functions, etc.) without converting them first.

Bad: return { sentAt: new Date() }
Good: return { sentAt: new Date().toISOString() }

Client vs server context

Server Actions run on the server. Don't access window, localStorage, or browser APIs.

If you need client-side data (like a CSRF token), pass it as a hidden form field or function argument.


Environment variables

Server Actions have access to all env vars. Client components only see NEXT_PUBLIC_* vars.

Keep API keys in Server Actions where they belong.


Migrating from API routes

If you have existing /api/send-email routes, migration is straightforward:

  • Move the route handler logic into a Server Action
  • Replace req.body parsing with Zod validation
  • Replace res.json() with return values
  • Update forms to use action instead of onSubmit + fetch

You can do this gradually. Server Actions and API routes coexist just fine.


When NOT to use Server Actions for email

Server Actions aren't always the right choice:

  • Webhooks from external services — use API routes. Webhooks need stable URLs and custom auth.
  • Public APIs — if you're exposing an email endpoint to third parties, stick with REST.
  • Non-Next.js apps — Server Actions are Next.js-specific.

For everything else — forms, user-triggered sends, internal flows — Server Actions are cleaner.


Production checklist

Before shipping Server Actions in production:

  • ✅ Validate all inputs with Zod or similar
  • ✅ Add rate limiting (per email or per user)
  • ✅ Log errors (but not sensitive data)
  • ✅ Test error states (validation, send failures, timeouts)
  • ✅ Add idempotency for critical emails (password resets, etc.)
  • ✅ Use queues for high-volume sends
  • ✅ Monitor send success rates
Full production setup guide: React Email + Resend: Production Checklist

Server Actions + React Email is the cleanest, most type-safe way to send emails in Next.js. No API routes. No fetch boilerplate. Just one function that validates, renders, and sends.

If you're starting fresh, this is the pattern. If you're migrating from API routes, it's worth the refactor.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates