How to Send Emails from React (2026 Guide)

React runs in the browser. You can't send emails directly from the client, that would expose your API key in the JavaScript bundle for anyone to steal. Instead, your React app calls a backend API that holds the key and sends the email server-side.
Most React email tutorials show a fetch call and stop. This guide covers the full picture: custom hooks, React Email templates, form validation, error handling, the backend patterns you need, and common SaaS emails like password resets and receipts.
The Pattern
React Component → fetch("/api/send-email") → Express Backend → Email Provider
If you're using a meta-framework, check our dedicated guides for Next.js, Remix, or Astro -- they have server-side rendering that simplifies this. This guide is for standalone React apps (Vite, CRA, custom setups) that need a separate backend.
Pick a Provider
Every code example below lets you switch between three providers.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Native Stripe integration and built-in retries.
- Resend is developer-friendly. Clean API, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, high volume. Bigger API surface.
Install
Install the SDK on your backend (not in the React app):
npm install sequenzy express corsnpm install resend express corsnpm install @sendgrid/mail express corsAdd your API key to the backend's .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereInitialize the Backend
import Sequenzy from "sequenzy";
// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };Custom Hook for Email Sending
Create a reusable hook that handles loading, success, and error states:
// src/hooks/useApi.ts
import { useState, useCallback } from "react";
interface UseApiOptions<T> {
onSuccess?: (data: T) => void;
onError?: (error: string) => void;
}
export function useApi<TInput, TOutput>(
url: string,
options?: UseApiOptions<TOutput>,
) {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = useState("");
const execute = useCallback(
async (data: TInput) => {
setStatus("loading");
setError("");
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: "Request failed" }));
throw new Error(body.error || `HTTP ${res.status}`);
}
const result = await res.json();
setStatus("success");
options?.onSuccess?.(result);
return result as TOutput;
} catch (err) {
const message = err instanceof Error ? err.message : "Something went wrong";
setError(message);
setStatus("error");
options?.onError?.(message);
return null;
}
},
[url, options],
);
return {
execute,
status,
error,
isLoading: status === "loading",
isSuccess: status === "success",
isError: status === "error",
reset: () => { setStatus("idle"); setError(""); },
};
}Send Your First Email
A contact form using the custom hook:
// src/components/ContactForm.tsx
import { useRef } from "react";
import { useApi } from "../hooks/useApi";
interface ContactResponse {
success: boolean;
}
export function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const { execute, isLoading, isSuccess, isError, error } = useApi<
{ name: string; email: string; message: string },
ContactResponse
>("/api/contact");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const result = await execute({
name: form.get("name") as string,
email: form.get("email") as string,
message: form.get("message") as string,
});
if (result?.success) {
formRef.current?.reset();
}
}
if (isSuccess) {
return <p style={{ color: "green" }}>Message sent! We'll get back to you soon.</p>;
}
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" rows={4} required />
<button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Message"}
</button>
{isError && <p style={{ color: "red" }}>{error}</p>}
</form>
);
}The backend endpoint:
import { Router } from "express";
import { sequenzy } from "../email";
const router = Router();
router.post("/api/contact", async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: "All fields are required" });
}
try {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
body: `
<h2>New Contact Form</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
res.json({ success: true });
} catch {
res.status(500).json({ error: "Failed to send message" });
}
});
export default router;import { Router } from "express";
import { resend } from "../email";
const router = Router();
router.post("/api/contact", async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: "All fields are required" });
}
const { error } = await resend.emails.send({
from: "Contact <noreply@yourdomain.com>",
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
if (error) {
return res.status(500).json({ error: "Failed to send message" });
}
res.json({ success: true });
});
export default router;import { Router } from "express";
import { sgMail } from "../email";
const router = Router();
router.post("/api/contact", async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: "All fields are required" });
}
try {
await sgMail.send({
to: "you@yourcompany.com",
from: "noreply@yourdomain.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
res.json({ success: true });
} catch {
res.status(500).json({ error: "Failed to send message" });
}
});
export default router;The main server file:
// server/index.ts
import express from "express";
import cors from "cors";
import contactRouter from "./routes/contact";
const app = express();
app.use(cors({ origin: "http://localhost:5173" })); // Vite dev server
app.use(express.json());
app.use(contactRouter);
app.listen(3001, () => console.log("Server running on port 3001"));React Email Templates
Inline HTML strings get messy. React Email lets you build email templates as React components that compile to email-safe HTML. Install on the backend:
npm install @react-email/components react-email// server/emails/layout.tsx
import {
Html, Head, Body, Container, Text, Hr,
} from "@react-email/components";
interface EmailLayoutProps {
children: React.ReactNode;
}
export function EmailLayout({ children }: EmailLayoutProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{
backgroundColor: "#ffffff",
padding: "40px",
borderRadius: "8px",
margin: "40px auto",
maxWidth: "560px",
}}>
{children}
<Hr style={{ borderColor: "#e6ebf1", margin: "32px 0" }} />
<Text style={{ color: "#8898aa", fontSize: "12px" }}>
YourApp Inc. · 123 Main St · San Francisco, CA
</Text>
</Container>
</Body>
</Html>
);
}// server/emails/welcome.tsx
import { Text, Button, Heading } from "@react-email/components";
import { EmailLayout } from "./layout";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<EmailLayout>
<Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
Welcome, {name}!
</Heading>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
Your account is ready. Here's what to do next:
</Text>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
1. Set up your first project{"\n"}
2. Invite your team{"\n"}
3. Connect your integrations
</Text>
<Button
href={loginUrl}
style={{
backgroundColor: "#5046e5",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
display: "inline-block",
marginTop: "16px",
}}
>
Go to Dashboard
</Button>
</EmailLayout>
);
}Render and send:
import { render } from "@react-email/components";
import { sequenzy } from "../email";
import { WelcomeEmail } from "../emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return sequenzy.transactional.send({
to,
subject: `Welcome to YourApp, ${name}!`,
body: html,
});
}import { render } from "@react-email/components";
import { resend } from "../email";
import { WelcomeEmail } from "../emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject: `Welcome to YourApp, ${name}!`,
html,
});
}import { render } from "@react-email/components";
import { sgMail } from "../email";
import { WelcomeEmail } from "../emails/welcome";
export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
return sgMail.send({
to,
from: "noreply@yourdomain.com",
subject: `Welcome to YourApp, ${name}!`,
html,
});
}React Email components run on the server to produce HTML strings. They don't ship to the browser or interact with your React frontend. You get the same component model and JSX syntax you're used to, but for building email templates.
Common SaaS Patterns
Password Reset
React component:
// src/components/ForgotPassword.tsx
import { useApi } from "../hooks/useApi";
export function ForgotPassword() {
const { execute, isLoading, isSuccess, isError, error } = useApi<
{ email: string },
{ success: boolean }
>("/api/forgot-password");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
await execute({ email: form.get("email") as string });
}
if (isSuccess) {
return (
<div>
<h2>Check your email</h2>
<p>If an account exists with that email, we sent a password reset link.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<h2>Forgot your password?</h2>
<p>Enter your email and we'll send you a reset link.</p>
<input name="email" type="email" placeholder="you@example.com" required />
<button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Reset Link"}
</button>
{isError && <p style={{ color: "red" }}>{error}</p>}
</form>
);
}Backend:
import { Router } from "express";
import { render } from "@react-email/components";
import { sequenzy } from "../email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
const router = Router();
router.post("/api/forgot-password", async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email is required" });
// Always return success to prevent email enumeration
const user = await db.user.findByEmail(email);
if (!user) return res.json({ success: true });
const token = crypto.randomBytes(32).toString("hex");
await db.resetToken.create({
userId: user.id,
token,
expires: new Date(Date.now() + 60 * 60 * 1000),
});
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
await sequenzy.transactional.send({
to: email,
subject: "Reset your password",
body: html,
});
res.json({ success: true });
});
export default router;import { Router } from "express";
import { render } from "@react-email/components";
import { resend } from "../email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
const router = Router();
router.post("/api/forgot-password", async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email is required" });
const user = await db.user.findByEmail(email);
if (!user) return res.json({ success: true });
const token = crypto.randomBytes(32).toString("hex");
await db.resetToken.create({
userId: user.id,
token,
expires: new Date(Date.now() + 60 * 60 * 1000),
});
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: email,
subject: "Reset your password",
html,
});
res.json({ success: true });
});
export default router;import { Router } from "express";
import { render } from "@react-email/components";
import { sgMail } from "../email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
const router = Router();
router.post("/api/forgot-password", async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: "Email is required" });
const user = await db.user.findByEmail(email);
if (!user) return res.json({ success: true });
const token = crypto.randomBytes(32).toString("hex");
await db.resetToken.create({
userId: user.id,
token,
expires: new Date(Date.now() + 60 * 60 * 1000),
});
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
try {
await sgMail.send({ to: email, from: "noreply@yourdomain.com", subject: "Reset your password", html });
} catch {
console.error("Failed to send password reset");
}
res.json({ success: true });
});
export default router;Stripe Webhook
Webhooks run entirely on the backend. React doesn't need to know about them. For a complete walkthrough, see sending emails from Stripe webhooks.
import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { sequenzy } from "../email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();
// Stripe needs raw body for signature verification
router.post(
"/api/stripe-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, signature, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await sequenzy.transactional.send({
to: email,
subject: "Payment received — thank you!",
body: `<h2>Payment Receipt</h2><p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sequenzy.transactional.send({
to: invoice.customer_email,
subject: "Payment failed — action required",
body: `<h2>Your payment didn't go through</h2><p>Please update your payment method.</p>`,
});
}
break;
}
}
res.json({ received: true });
},
);
export default router;import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { resend } from "../email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();
router.post(
"/api/stripe-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, signature, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: email,
subject: "Payment received — thank you!",
html: `<h2>Payment Receipt</h2><p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: invoice.customer_email,
subject: "Payment failed — action required",
html: `<h2>Your payment didn't go through</h2><p>Please update your payment method.</p>`,
});
}
break;
}
}
res.json({ received: true });
},
);
export default router;import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { sgMail } from "../email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();
router.post(
"/api/stripe-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, signature, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await sgMail.send({
to: email, from: "billing@yourdomain.com",
subject: "Payment received — thank you!",
html: `<h2>Payment Receipt</h2><p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sgMail.send({
to: invoice.customer_email, from: "billing@yourdomain.com",
subject: "Payment failed — action required",
html: `<h2>Your payment didn't go through</h2><p>Please update your payment method.</p>`,
});
}
break;
}
}
res.json({ received: true });
},
);
export default router;Register the webhook route before express.json() middleware, since Stripe needs the raw body:
// server/index.ts
import stripeWebhookRouter from "./routes/stripe-webhook";
import contactRouter from "./routes/contact";
// Webhook route first (uses express.raw internally)
app.use(stripeWebhookRouter);
// JSON parsing for all other routes
app.use(express.json());
app.use(contactRouter);Error Handling
Backend Error Wrapper
import Sequenzy from "sequenzy";
import { sequenzy } from "../email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
body: string,
): Promise<SendResult> {
try {
await sequenzy.transactional.send({ to, subject, body });
return { success: true };
} catch (error) {
if (error instanceof Sequenzy.RateLimitError) {
return { success: false, error: "Too many emails. Try again later." };
}
if (error instanceof Sequenzy.AuthenticationError) {
console.error("Invalid SEQUENZY_API_KEY");
return { success: false, error: "Email service configuration error" };
}
if (error instanceof Sequenzy.ValidationError) {
return { success: false, error: "Invalid email parameters" };
}
console.error("Email error:", error);
return { success: false, error: "Failed to send email" };
}
}import { resend } from "../email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const { error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to, subject, html,
});
if (error) {
console.error(`Resend: ${error.name} - ${error.message}`);
if (error.name === "rate_limit_exceeded") {
return { success: false, error: "Too many emails. Try again later." };
}
return { success: false, error: "Failed to send email" };
}
return { success: true };
}import { sgMail } from "../email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
try {
await sgMail.send({ to, from: "noreply@yourdomain.com", subject, html });
return { success: true };
} catch (error: unknown) {
const sgError = error as { code?: number };
if (sgError.code === 429) {
return { success: false, error: "Too many emails. Try again later." };
}
console.error("SendGrid error:", error);
return { success: false, error: "Failed to send email" };
}
}Production Checklist
1. Never Expose API Keys
API keys go in the backend's .env. Never put them in:
VITE_*environment variables (exposed to the browser)NEXT_PUBLIC_*orREACT_APP_*variables- Any file that ships to the client
2. Verify Your Sending Domain
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes servers to send |
| DKIM | TXT | Cryptographic signature |
| DMARC | TXT | Policy for failed checks |
See our step-by-step email authentication guide for the full setup process.
3. CORS Configuration
In development, use a Vite proxy:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": "http://localhost:3001",
},
},
});In production, configure CORS on the backend:
import cors from "cors";
app.use(cors({ origin: "https://yourapp.com" }));4. Rate Limiting
import rateLimit from "express-rate-limit";
const emailRateLimit = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: "Too many requests. Try again later." },
});
app.use("/api/contact", emailRateLimit);
app.use("/api/forgot-password", emailRateLimit);5. Input Validation
Validate on the backend (always) and optionally in React (for UX):
// Backend: Zod validation
import { z } from "zod";
const contactSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
message: z.string().min(10).max(5000),
});
router.post("/api/contact", async (req, res) => {
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0].message });
}
// ... send email with parsed.data
});Beyond Transactional
Once you have transactional emails working, you'll want welcome email sequences and onboarding drip campaigns to guide new users through your product.
import { Router } from "express";
import { sequenzy } from "../email";
const router = Router();
router.post("/api/subscribe", async (req, res) => {
const { email, name } = req.body;
await sequenzy.subscribers.add({
email,
attributes: { name },
tags: ["signed-up"],
});
res.json({ success: true });
});
export default router;
// Sequenzy triggers welcome sequences automatically
// when the "signed-up" tag is applied.import { Router } from "express";
import { resend } from "../email";
const router = Router();
router.post("/api/subscribe", async (req, res) => {
const { email, name } = req.body;
const { error } = await resend.contacts.create({
audienceId: process.env.RESEND_AUDIENCE_ID!,
email,
firstName: name,
});
if (error) return res.status(500).json({ error: error.message });
res.json({ success: true });
});
export default router;import { Router } from "express";
const router = Router();
router.post("/api/subscribe", async (req, res) => {
const { email, name } = req.body;
const response = await fetch("https://api.sendgrid.com/v3/marketing/contacts", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
list_ids: [process.env.SENDGRID_LIST_ID],
contacts: [{ email, first_name: name }],
}),
});
if (!response.ok) return res.status(500).json({ error: "Failed" });
res.json({ success: true });
});
export default router;Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one SDK. Native Stripe integration tags subscribers automatically when they purchase, cancel, or churn.
FAQ
Can I send emails directly from React?
No. React runs in the browser. If you put an API key in your React code, anyone can extract it from the JavaScript bundle using browser DevTools. You must call a backend API that holds your key and sends the email.
Should I use Next.js or Remix instead?
If you're building a new app and want server-side rendering, yes. Next.js and Remix have built-in server routes, so you don't need a separate Express backend. If you already have a React app (Vite, CRA) with a separate backend, this guide covers that pattern.
How do I use React Email in a React project?
React Email runs on the backend only. It uses React components to produce HTML strings that get passed to your email provider. It never ships to the browser or interacts with your frontend React app. Install it alongside your backend code, not your frontend.
Can I use React Hook Form for email forms?
Yes. React Hook Form works great for validation and state management. Replace the FormData approach with useForm():
import { useForm } from "react-hook-form";
function ContactForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const api = useApi("/api/contact");
return (
<form onSubmit={handleSubmit((data) => api.execute(data))}>
<input {...register("email", { required: true })} type="email" />
{errors.email && <span>Email is required</span>}
<textarea {...register("message", { required: true, minLength: 10 })} />
<button disabled={api.isLoading}>Send</button>
</form>
);
}Do I need CORS?
In development, use Vite's proxy (server.proxy in vite.config.ts) to forward /api requests to your backend. In production, either serve React from the same origin as the backend (recommended) or configure CORS.
How do I handle retries for failed emails?
Add retry logic on the backend, not in React:
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { return await fn(); }
catch (error) {
if (attempt === maxAttempts) throw error;
await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1)));
}
}
throw new Error("Unreachable");
}Can I use TanStack Query (React Query) instead of a custom hook?
Yes. TanStack Query gives you caching, retries, and devtools:
import { useMutation } from "@tanstack/react-query";
function ContactForm() {
const mutation = useMutation({
mutationFn: (data: { email: string; message: string }) =>
fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((res) => {
if (!res.ok) throw new Error("Failed");
return res.json();
}),
});
// mutation.isPending, mutation.isSuccess, mutation.isError
}How do I test email components?
Test React form components by mocking the fetch call. Test the backend with your provider's sandbox API keys:
// React component test
vi.stubGlobal("fetch", vi.fn(() =>
Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }))
));Wrapping Up
- Custom hooks (
useApi) give reusable loading, success, and error state management - Express backend keeps API keys secure and sends emails server-side
- React Email builds maintainable templates on the backend
- CORS/proxy bridges React dev server to the backend
- Rate limiting protects email endpoints from abuse
- Zod validation catches bad input on the backend
Pick your provider, build the backend, and connect your React forms.
Frequently Asked Questions
Can I send emails directly from a React app without a backend?
No. React runs in the browser, so any API keys in client-side code would be exposed to users. You must send emails through a backend API (Express, Next.js API routes, or any server) and call that API from your React app via fetch.
What backend should I use with React for email sending?
Next.js is the simplest option since it includes API routes alongside your React app. Express and Fastify are good standalone choices. For serverless, use Vercel/Netlify functions. The backend just needs to accept an HTTP request and call your email SDK.
How do I handle form state for email forms in React?
Use useState for simple forms or a form library like React Hook Form for complex ones. Track loading, error, and success states. Disable the submit button while sending and show appropriate feedback messages.
How do I prevent duplicate email sends from double-clicks in React?
Disable the submit button immediately when clicked and set a loading state. Use a ref to track if a submission is in progress. If using React Hook Form, it handles submission locking automatically with its isSubmitting state.
Should I use React Server Components for email sending?
If you're using Next.js with the App Router, server actions are a clean way to send emails without creating separate API endpoints. For client-triggered sends (form submissions), server actions work perfectly. For webhook-triggered sends, use API routes.
How do I validate email form inputs in React?
Validate on the client with HTML5 validation attributes (type="email", required) for instant feedback, and validate again on the server with Zod or Yup. Client-side validation improves UX but is easily bypassed—server validation is mandatory.
How do I show loading and error states for email forms?
Track state with useState: loading (boolean), error (string or null), success (boolean). Show a spinner or "Sending..." text while loading. Display error messages in red below the form. Show a success message and optionally reset the form on completion.
Can I use React Email to build templates and send from React?
React Email is for building server-side email templates, not for sending from the browser. Create templates as React Email components, render them to HTML on the server, and send via your email provider. The React frontend only triggers the send via an API call.
How do I handle CORS when calling my email API from React?
If your React app and API are on the same origin (same domain and port), CORS isn't an issue. For separate origins, configure CORS headers on your backend to allow requests from your React app's domain. Use a proxy in development to avoid CORS entirely.
How do I test email form components in React?
Use React Testing Library to render the form, fill in fields with userEvent.type(), and click submit. Mock the fetch call to your backend API. Assert that the form shows loading states, success messages, and error handling correctly.