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.
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:
"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:
"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.
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:
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:
"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 };
}Error handling patterns
Server Actions make error handling cleaner. Return structured errors that map to form fields:
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:
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:
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:
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:
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.
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.bodyparsing with Zod validation - Replace
res.json()with return values - Update forms to use
actioninstead ofonSubmit + 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
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.