How to Send Emails in Remix (2026 Guide)

Most Remix email tutorials show a contact form and stop. That ignores everything you actually need: password resets, payment receipts, Stripe webhooks, HTML templates, error handling, and subscriber management.
Remix gives you a great foundation for email. Server code runs in action and loader functions. API keys never touch the browser. The .server.ts suffix tree-shakes entire modules from the client bundle. Progressive enhancement means forms work even before JavaScript loads.
This guide covers the full picture with working TypeScript examples. For other React-based frameworks, see our Next.js guide. For non-React options, check out SvelteKit or Nuxt.
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
npm install sequenzynpm install resendnpm install @sendgrid/mailAdd your API key to .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereInitialize the Client
Create a shared client in a .server.ts file. The .server.ts suffix is a Remix convention that guarantees the module is excluded from the browser bundle entirely, not just the exports, the whole file and its dependencies.
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 };Without the .server.ts suffix, Remix would try to bundle your email SDK and API key into the client JavaScript. The suffix prevents that completely.
Send Your First Email
A contact form that sends email from an action function. Remix Form gives you progressive enhancement: the form works without JavaScript, and when JS loads, it submits without a full page reload.
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { sequenzy } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const message = formData.get("message") as string;
if (!email || !name || !message) {
return json({ error: "All fields are required" }, { status: 400 });
}
try {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
body: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
return json({ success: true });
} catch {
return json({ error: "Failed to send message" }, { status: 500 });
}
}
export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" required />
<button type="submit" disabled={sending}>
{sending ? "Sending..." : "Send Message"}
</button>
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
{actionData?.success && <p style={{ color: "green" }}>Message sent!</p>}
</Form>
);
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { resend } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const message = formData.get("message") as string;
if (!email || !name || !message) {
return json({ error: "All fields are required" }, { status: 400 });
}
const { error } = await resend.emails.send({
from: "Contact <noreply@yourdomain.com>",
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
if (error) {
return json({ error: "Failed to send message" }, { status: 500 });
}
return json({ success: true });
}
export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" required />
<button type="submit" disabled={sending}>
{sending ? "Sending..." : "Send Message"}
</button>
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
{actionData?.success && <p style={{ color: "green" }}>Message sent!</p>}
</Form>
);
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { sgMail } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const message = formData.get("message") as string;
if (!email || !name || !message) {
return json({ error: "All fields are required" }, { status: 400 });
}
try {
await sgMail.send({
to: "you@yourcompany.com",
from: "noreply@yourdomain.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
return json({ success: true });
} catch {
return json({ error: "Failed to send message" }, { status: 500 });
}
}
export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" required />
<button type="submit" disabled={sending}>
{sending ? "Sending..." : "Send Message"}
</button>
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
{actionData?.success && <p style={{ color: "green" }}>Message sent!</p>}
</Form>
);
}Key Remix patterns here:
actionruns on the server for POST requests. Your API key never reaches the browser.useNavigationtracks form state.navigation.state === "submitting"lets you show loading indicators.useActionDatareads the return value fromactionfor displaying success/error messages.Formwithmethod="post"works without JavaScript (progressive enhancement). When JS loads, it submits via fetch instead of a full page reload.
React Email Templates
Inline HTML strings get messy fast. React Email lets you build email templates as React components that compile to email-safe HTML with inline styles.
npm install @react-email/components react-emailCreate a reusable layout and your first template:
// app/emails/components/layout.tsx
import {
Html,
Head,
Body,
Container,
Text,
Link,
Hr,
} from "@react-email/components";
interface EmailLayoutProps {
children: React.ReactNode;
preview?: string;
}
export function EmailLayout({ children, preview }: 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>
);
}// app/emails/welcome.tsx
import { Text, Button, Heading } from "@react-email/components";
import { EmailLayout } from "./components/layout";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<EmailLayout preview={`Welcome to YourApp, ${name}`}>
<Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
Welcome aboard, {name}!
</Heading>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
Your account is ready. Here's what you can 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 the template to HTML and send it:
import { render } from "@react-email/components";
import { sequenzy } from "~/lib/email.server";
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 "~/lib/email.server";
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 "~/lib/email.server";
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,
});
}The .server.ts suffix on these files keeps the email rendering code and SDK out of the client bundle.
Resource Routes
Resource routes are Remix files that export a loader or action but no default component. They act as pure API endpoints, useful for webhooks, programmatic email sending, or anything that doesn't need a UI.
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { sequenzy } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
// Verify internal API key
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${process.env.INTERNAL_API_KEY}`) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const { to, subject, body } = await request.json();
if (!to || !subject || !body) {
return json({ error: "Missing required fields: to, subject, body" }, { status: 400 });
}
try {
const result = await sequenzy.transactional.send({ to, subject, body });
return json({ success: true, id: result.id });
} catch {
return json({ error: "Failed to send" }, { status: 500 });
}
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { resend } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${process.env.INTERNAL_API_KEY}`) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const { to, subject, html } = await request.json();
if (!to || !subject || !html) {
return json({ error: "Missing required fields: to, subject, html" }, { status: 400 });
}
const { data, error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject,
html,
});
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json({ success: true, id: data?.id });
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { sgMail } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${process.env.INTERNAL_API_KEY}`) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const { to, subject, html } = await request.json();
if (!to || !subject || !html) {
return json({ error: "Missing required fields: to, subject, html" }, { status: 400 });
}
try {
await sgMail.send({
to,
from: "noreply@yourdomain.com",
subject,
html,
});
return json({ success: true });
} catch {
return json({ error: "Failed to send" }, { status: 500 });
}
}Remix flat routing maps app/routes/api.send-email.ts to /api/send-email. Call it from other services or internal tooling:
curl -X POST http://localhost:3000/api/send-email \
-H "Authorization: Bearer your-internal-key" \
-H "Content-Type: application/json" \
-d '{"to": "user@example.com", "subject": "Hello", "body": "<p>Test</p>"}'Common SaaS Patterns
Password Reset
Users submit a form to request a password reset. The action generates a token, saves it to the database, and sends the email. The form works with or without JavaScript.
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { render } from "@react-email/components";
import { sequenzy } from "~/lib/email.server";
import { db } from "~/lib/db.server";
import crypto from "node:crypto";
// React Email template (inline for brevity)
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 the button 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, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
if (!email) {
return json({ error: "Email is required" }, { status: 400 });
}
// Always return success to prevent email enumeration
const user = await db.user.findByEmail(email);
if (!user) {
return json({ success: true });
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await db.resetToken.create({ userId: user.id, token, expires });
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,
});
return json({ success: true });
}
export default function ForgotPassword() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";
if (actionData?.success) {
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 method="post">
<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={sending}>
{sending ? "Sending..." : "Send Reset Link"}
</button>
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
</Form>
);
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { render } from "@react-email/components";
import { resend } from "~/lib/email.server";
import { db } from "~/lib/db.server";
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 the button 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, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
if (!email) {
return json({ error: "Email is required" }, { status: 400 });
}
const user = await db.user.findByEmail(email);
if (!user) {
return json({ success: true });
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
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,
});
return json({ success: true });
}
export default function ForgotPassword() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";
if (actionData?.success) {
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 method="post">
<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={sending}>
{sending ? "Sending..." : "Send Reset Link"}
</button>
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
</Form>
);
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { render } from "@react-email/components";
import { sgMail } from "~/lib/email.server";
import { db } from "~/lib/db.server";
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 the button 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, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
if (!email) {
return json({ error: "Email is required" }, { status: 400 });
}
const user = await db.user.findByEmail(email);
if (!user) {
return json({ success: true });
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
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 {
// Log error internally, don't reveal to user
console.error("Failed to send password reset email");
}
return json({ success: true });
}
export default function ForgotPassword() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const sending = navigation.state === "submitting";
if (actionData?.success) {
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 method="post">
<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={sending}>
{sending ? "Sending..." : "Send Reset Link"}
</button>
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
</Form>
);
}The action always returns { success: true } whether or not the user exists. This prevents attackers from discovering which emails are registered.
Payment Receipt
Send a receipt after a successful purchase. This typically runs in a Stripe webhook handler, but here's the email template and send function:
import { render } from "@react-email/components";
import { sequenzy } from "~/lib/email.server";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";
interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}
function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
<Text>Hi {customerName}, thanks for your purchase!</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item, i) => (
<Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<Column>{item.name}</Column>
<Column align="right">{item.amount}</Column>
</Row>
))}
</Section>
<Hr style={{ margin: "16px 0" }} />
<Row>
<Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
<Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
</Row>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
View your full receipt at {receiptUrl}
</Text>
</Container>
</Body>
</Html>
);
}
export async function sendReceipt(
to: string,
data: ReceiptProps,
) {
const html = await render(<ReceiptEmail {...data} />);
return sequenzy.transactional.send({
to,
subject: `Receipt for your ${data.total} payment`,
body: html,
});
}import { render } from "@react-email/components";
import { resend } from "~/lib/email.server";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";
interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}
function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
<Text>Hi {customerName}, thanks for your purchase!</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item, i) => (
<Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<Column>{item.name}</Column>
<Column align="right">{item.amount}</Column>
</Row>
))}
</Section>
<Hr style={{ margin: "16px 0" }} />
<Row>
<Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
<Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
</Row>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
View your full receipt at {receiptUrl}
</Text>
</Container>
</Body>
</Html>
);
}
export async function sendReceipt(
to: string,
data: ReceiptProps,
) {
const html = await render(<ReceiptEmail {...data} />);
return resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to,
subject: `Receipt for your ${data.total} payment`,
html,
});
}import { render } from "@react-email/components";
import { sgMail } from "~/lib/email.server";
import {
Html, Body, Container, Text, Heading, Hr,
Row, Column, Section,
} from "@react-email/components";
interface ReceiptProps {
customerName: string;
items: { name: string; amount: string }[];
total: string;
receiptUrl: string;
}
function ReceiptEmail({ customerName, items, total, receiptUrl }: ReceiptProps) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Heading as="h1" style={{ fontSize: "24px" }}>Payment Receipt</Heading>
<Text>Hi {customerName}, thanks for your purchase!</Text>
<Section style={{ marginTop: "24px" }}>
{items.map((item, i) => (
<Row key={i} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
<Column>{item.name}</Column>
<Column align="right">{item.amount}</Column>
</Row>
))}
</Section>
<Hr style={{ margin: "16px 0" }} />
<Row>
<Column><Text style={{ fontWeight: "bold" }}>Total</Text></Column>
<Column align="right"><Text style={{ fontWeight: "bold" }}>{total}</Text></Column>
</Row>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "24px" }}>
View your full receipt at {receiptUrl}
</Text>
</Container>
</Body>
</Html>
);
}
export async function sendReceipt(
to: string,
data: ReceiptProps,
) {
const html = await render(<ReceiptEmail {...data} />);
return sgMail.send({
to,
from: "billing@yourdomain.com",
subject: `Receipt for your ${data.total} payment`,
html,
});
}Stripe Webhook
Stripe sends webhooks as POST requests with a signature. In Remix, you handle this with a resource route. The key detail: you need the raw request body for signature verification, which request.text() gives you.
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import Stripe from "stripe";
import { sequenzy } from "~/lib/email.server";
import { sendReceipt } from "~/lib/send-receipt.server";
import { db } from "~/lib/db.server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function action({ request }: ActionFunctionArgs) {
// Get raw body for signature verification
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Webhook signature verification failed");
return json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const customerEmail = session.customer_details?.email;
if (customerEmail) {
await sendReceipt(customerEmail, {
customerName: session.customer_details?.name || "Customer",
items: [{ name: "Pro Plan", amount: "$29.00" }],
total: `$${(session.amount_total! / 100).toFixed(2)}`,
receiptUrl: `${process.env.APP_URL}/billing`,
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await sequenzy.transactional.send({
to: customer.email,
subject: "Your subscription has been cancelled",
body: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
<p>If you change your mind, you can resubscribe anytime.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const customerEmail = invoice.customer_email;
if (customerEmail) {
await sequenzy.transactional.send({
to: customerEmail,
subject: "Payment failed - action required",
body: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p>Please update your payment method to keep your subscription active.</p>
<p><a href="${process.env.APP_URL}/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return json({ received: true });
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import Stripe from "stripe";
import { resend } from "~/lib/email.server";
import { sendReceipt } from "~/lib/send-receipt.server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function action({ request }: ActionFunctionArgs) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Webhook signature verification failed");
return json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const customerEmail = session.customer_details?.email;
if (customerEmail) {
await sendReceipt(customerEmail, {
customerName: session.customer_details?.name || "Customer",
items: [{ name: "Pro Plan", amount: "$29.00" }],
total: `$${(session.amount_total! / 100).toFixed(2)}`,
receiptUrl: `${process.env.APP_URL}/billing`,
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: customer.email,
subject: "Your subscription has been cancelled",
html: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
<p>If you change your mind, you can resubscribe anytime.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const customerEmail = invoice.customer_email;
if (customerEmail) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: customerEmail,
subject: "Payment failed - action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p>Please update your payment method to keep your subscription active.</p>
<p><a href="${process.env.APP_URL}/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return json({ received: true });
}import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import Stripe from "stripe";
import { sgMail } from "~/lib/email.server";
import { sendReceipt } from "~/lib/send-receipt.server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function action({ request }: ActionFunctionArgs) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Webhook signature verification failed");
return json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const customerEmail = session.customer_details?.email;
if (customerEmail) {
await sendReceipt(customerEmail, {
customerName: session.customer_details?.name || "Customer",
items: [{ name: "Pro Plan", amount: "$29.00" }],
total: `$${(session.amount_total! / 100).toFixed(2)}`,
receiptUrl: `${process.env.APP_URL}/billing`,
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(
subscription.customer as string,
) as Stripe.Customer;
if (customer.email) {
await sgMail.send({
to: customer.email,
from: "noreply@yourdomain.com",
subject: "Your subscription has been cancelled",
html: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled.
You still have access until the end of your billing period.</p>
<p>If you change your mind, you can resubscribe anytime.</p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const customerEmail = invoice.customer_email;
if (customerEmail) {
await sgMail.send({
to: customerEmail,
from: "billing@yourdomain.com",
subject: "Payment failed - action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p>Please update your payment method to keep your subscription active.</p>
<p><a href="${process.env.APP_URL}/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
return json({ received: true });
}The critical part is request.text() instead of request.json(). Stripe needs the raw request body exactly as it arrived to verify the signature. If you parse it as JSON first, verification fails.
Set the webhook URL in Stripe Dashboard to https://yourdomain.com/api/stripe-webhook.
Error Handling
Each provider has different error types. Handle them properly in your actions:
import Sequenzy from "sequenzy";
import { sequenzy } from "~/lib/email.server";
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) {
console.warn(`Rate limited sending to ${to}. Retry after: ${error.retryAfter}s`);
return { success: false, error: "Too many emails sent. 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) {
console.error(`Validation error: ${error.message}`);
return { success: false, error: "Invalid email parameters" };
}
console.error("Unexpected email error:", error);
return { success: false, error: "Failed to send email" };
}
}import { resend } from "~/lib/email.server";
interface SendResult {
success: boolean;
error?: string;
id?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const { data, error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject,
html,
});
if (error) {
// Resend error codes: validation_error, rate_limit_exceeded,
// missing_required_field, invalid_api_key, not_found
console.error(`Resend error [${error.name}]: ${error.message}`);
switch (error.name) {
case "rate_limit_exceeded":
return { success: false, error: "Too many emails. Try again later." };
case "validation_error":
case "missing_required_field":
return { success: false, error: "Invalid email parameters" };
case "invalid_api_key":
return { success: false, error: "Email service configuration error" };
default:
return { success: false, error: "Failed to send email" };
}
}
return { success: true, id: data?.id };
}import { sgMail } from "~/lib/email.server";
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; response?: { body?: { errors?: { message: string }[] } } };
if (sgError.code === 429) {
console.warn("SendGrid rate limit hit");
return { success: false, error: "Too many emails. Try again later." };
}
if (sgError.code === 401) {
console.error("Invalid SendGrid API key");
return { success: false, error: "Email service configuration error" };
}
const messages = sgError.response?.body?.errors?.map((e) => e.message);
if (messages?.length) {
console.error("SendGrid errors:", messages);
}
return { success: false, error: "Failed to send email" };
}
}Use the safe wrapper in your actions:
// app/routes/invite.tsx
import { sendEmailSafe } from "~/lib/send-email-safe.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const result = await sendEmailSafe(
email,
"You've been invited!",
"<p>Click here to join the team.</p>",
);
if (!result.success) {
return json({ error: result.error }, { status: 500 });
}
return json({ success: true });
}Retry with Backoff
For critical emails (receipts, password resets), add retry logic:
// app/lib/retry.server.ts
export 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;
const delay = Math.min(1000 * 2 ** (attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("Unreachable");
}
// Usage:
// await withRetry(() => sequenzy.transactional.send({ to, subject, body }));Session Flash Messages
Remix sessions let you set flash messages that display once and disappear. This is a cleaner pattern for showing success/error states after email actions, especially when you redirect after submission:
// app/lib/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secrets: [process.env.SESSION_SECRET!],
},
});
export async function setFlashMessage(request: Request, message: string, type: "success" | "error") {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
session.flash("message", { text: message, type });
return redirect(request.url, {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
}// app/routes/contact.tsx
import { sessionStorage, setFlashMessage } from "~/lib/session.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// ... validate and send email ...
// Redirect with flash message instead of returning JSON
return setFlashMessage(request, "Message sent!", "success");
}
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const message = session.get("message") as { text: string; type: string } | undefined;
return json(
{ message },
{ headers: { "Set-Cookie": await sessionStorage.commitSession(session) } },
);
}Production Checklist
1. Verify Your Sending Domain
Add DNS records so emails don't land in spam:
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes servers to send |
| DKIM | TXT | Cryptographic signature |
| DMARC | TXT | Policy for failed checks |
Your provider's dashboard walks you through this. Do it before sending to real users. Our email authentication guide has step-by-step instructions.
2. Use .server.ts Everywhere
Any file that imports your email SDK should use the .server.ts suffix. This isn't just a convention, it's a security boundary:
app/
lib/
email.server.ts # SDK client
send-receipt.server.ts # Receipt sender
send-email-safe.server.ts # Error wrapper
retry.server.ts # Retry logic
session.server.ts # Flash messages
emails/
welcome.tsx # React Email templates (safe, no secrets)
receipt.tsx
Templates in app/emails/ don't need .server.ts since they're just React components with no secrets.
3. Environment Variables
Remix loads .env automatically in development. In production, set variables through your hosting provider.
# .env
SEQUENZY_API_KEY=sq_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
SESSION_SECRET=a-random-string
APP_URL=https://yourapp.comNever commit .env to git. Add it to .gitignore.
4. Validate Inputs in Actions
Always validate form data before sending emails:
import { z } from "zod";
const contactSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(1, "Name is required").max(100),
message: z.string().min(10, "Message must be at least 10 characters").max(5000),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const parsed = contactSchema.safeParse({
email: formData.get("email"),
name: formData.get("name"),
message: formData.get("message"),
});
if (!parsed.success) {
return json(
{ errors: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}
// Safe to use parsed.data
const { email, name, message } = parsed.data;
// ... send email
}5. Rate Limiting
Protect your email actions from abuse. Remix doesn't have built-in rate limiting, but you can use a middleware approach with express or apply it at the action level:
// app/lib/rate-limit.server.ts
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
export function checkRateLimit(
key: string,
maxRequests = 5,
windowMs = 60_000,
): boolean {
const now = Date.now();
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (entry.count >= maxRequests) {
return false;
}
entry.count++;
return true;
}// In your action:
export async function action({ request }: ActionFunctionArgs) {
const ip = request.headers.get("x-forwarded-for") || "unknown";
if (!checkRateLimit(ip)) {
return json({ error: "Too many requests. Try again later." }, { status: 429 });
}
// ... send email
}For production, use Redis or a distributed rate limiter instead of the in-memory map.
6. Progressive Enhancement
Remix forms work without JavaScript by default. This means:
- Your contact form works even if the JS bundle fails to load
- Search engine bots can submit forms
- Users on slow connections get a working experience immediately
Test with JavaScript disabled to make sure your email features still work.
Beyond Transactional
A contact form is step one. Production apps need welcome sequences, onboarding emails, marketing campaigns, and subscriber management. Understanding the difference between transactional and marketing email helps you pick the right architecture. You can either build this yourself (separate tools for each) or use a platform that handles it all.
// Add subscriber when user signs up
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { sequenzy } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const { email, name } = await request.json();
await sequenzy.subscribers.add({
email,
attributes: { name },
tags: ["signed-up"],
});
return json({ success: true });
}
// In your signup action (app/routes/signup.tsx):
// After creating the user account, add them as a subscriber.
// Sequenzy triggers your welcome sequence automatically
// based on the "signed-up" tag.// Resend has an Audiences API for managing contacts
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { resend } from "~/lib/email.server";
export async function action({ request }: ActionFunctionArgs) {
const { email, name } = await request.json();
const { error } = await resend.contacts.create({
audienceId: process.env.RESEND_AUDIENCE_ID!,
email,
firstName: name,
});
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json({ success: true });
}
// Note: Resend supports one-off broadcast campaigns
// but not automated sequences. You'd need a separate
// tool for welcome emails, onboarding flows, etc.// SendGrid uses the Marketing API for contacts
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const { email, name } = await request.json();
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 json({ error: "Failed to subscribe" }, { status: 500 });
}
return json({ success: true });
}
// SendGrid has full marketing automation, but requires
// a separate Marketing Campaigns plan on top of the email API.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, so you can trigger the right emails without writing webhook handlers.
FAQ
Can I use Nodemailer with Remix?
Yes, but it's not recommended for production. Nodemailer connects directly to SMTP servers, which means you lose delivery analytics, bounce handling, reputation management, and the ability to scale without managing SMTP infrastructure. API-based providers handle all of that. Nodemailer is fine for development or internal tools where deliverability doesn't matter.
What's the difference between .server.ts and regular .ts files?
A .server.ts file (or .server/ directory) is completely excluded from the browser bundle by the Remix compiler. Not just unused exports are tree-shaken; the entire file and all its imports are removed. Use this for anything with secrets (API keys, database clients). Regular .ts files may end up in the client bundle if imported by a route component.
Should I send emails in loaders or actions?
Actions. Loaders run on GET requests, which means every page visit would trigger an email send. Actions run on POST/PUT/DELETE, so emails only send when the user explicitly submits something. The one exception: if you need to send an email based on visiting a URL (like an email verification link), use a loader but with a one-time-use token to prevent repeat sends.
How do I handle Stripe webhooks? Do I need express.raw()?
No. Remix gives you the raw request body via request.text(). Unlike Express (which parses the body before you see it), Remix's web-standard Request object lets you read the body in any format. Call request.text() to get the raw string, then pass it to stripe.webhooks.constructEvent().
Can I send emails from a Remix loader?
Technically yes, but be careful. Loaders run on every navigation to a route. If you send email in a loader, you need to guard it with a one-time token or database check. For example, an email verification link might use a loader:
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const record = await db.verificationToken.findUnique({ where: { token } });
if (!record || record.used) {
return json({ error: "Invalid or expired link" }, { status: 400 });
}
await db.verificationToken.update({ where: { token }, data: { used: true } });
await db.user.update({ where: { id: record.userId }, data: { verified: true } });
// Now safe to send a confirmation email (only runs once)
await sendEmailSafe(record.email, "Email verified", "<p>Your email is verified!</p>");
return redirect("/dashboard?verified=true");
}How do I test emails during development?
Three options:
- Provider test mode: Sequenzy, Resend, and SendGrid all have test/sandbox API keys that don't actually deliver emails.
- Email preview: Use
react-email devto preview your React Email templates locally atlocalhost:3000. - Catch-all inbox: Use a service like Mailpit or Mailtrap to capture all outgoing emails in development.
Does progressive enhancement work with email forms?
Yes. Remix's <Form> component works without JavaScript as a regular HTML form submission. When JavaScript loads, it enhances to use fetch. Your email-sending action runs the same code either way because it's always server-side. The only difference is UX: without JS, you get a full page reload; with JS, you get the useNavigation pending state and useActionData response without leaving the page.
How do I send emails in the background without blocking the action?
Remix actions are request-response. If an email takes too long, it blocks the user's response. Options:
- Fire and forget: Don't await the email send. The email sends in the background, but you lose error handling. Use
void sendEmail()syntax. - Queue: Use a job queue (BullMQ, Inngest, or similar) to enqueue email sends and process them asynchronously. This is the production answer for high-volume sending.
- Defer: Remix's
deferstreams responses. You can start the email send and stream the result, though this is complex for simple cases.
// Fire and forget (simplest)
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// ... validate
// Don't await - email sends in background
void sendWelcomeEmail(email, name);
return json({ success: true });
}Wrapping Up
.server.tskeeps API keys and email SDKs out of the browser bundle- Action functions handle form-based email sending with progressive enhancement
- Resource routes create API endpoints for webhooks and programmatic emails
- React Email builds maintainable templates as React components
request.text()gives you the raw body for Stripe webhook signature verification- Session flash messages provide clean success/error states after redirects
- Zod validation catches bad input before it hits your email provider
Pick your provider, copy the patterns, and ship.
Frequently Asked Questions
Should I send emails from Remix actions or loaders?
Actions, always. Loaders run on GET requests and should be side-effect-free (data fetching only). Actions handle POST/PUT/DELETE requests and are the right place for mutations like sending emails. This follows Remix's progressive enhancement model.
How do I handle form submissions that trigger emails in Remix?
Use Remix's <Form> component and an action function. The form submits to the action, which validates input, sends the email, and returns a response. Use useActionData() to display success or error messages. Forms work without JavaScript enabled.
Can I use React Email templates in Remix?
Yes. Install React Email packages, create template components, and render them in your action with render(). Remix runs actions server-side, so React Email rendering works the same as in any Node.js environment.
How do I show loading states during email sends in Remix?
Use useNavigation() to check if an action is in progress: navigation.state === 'submitting'. Show a spinner or "Sending..." text while the action runs. Remix handles this without any manual state management.
How do I validate email form data in Remix?
Use Zod to define a schema and validate formData in your action. Return validation errors that your component displays with useActionData(). This gives you server-side validation that works with and without JavaScript.
How do I send emails in the background in Remix?
Remix actions are synchronous request handlers, so you can't easily run background jobs within Remix itself. Use a job queue service (Inngest, Trigger.dev) or push to a message queue (SQS, Redis) and process emails in a separate worker.
How do I handle email webhook callbacks in Remix?
Create a resource route (a route without a component) that exports only an action. Parse the webhook payload, verify the signature, and process the event. Return a json() response with appropriate status codes.
Does Remix's nested routing affect email-sending patterns?
Not directly. Email sends happen in actions regardless of routing depth. However, nested routes let you colocate email-triggering actions with the UI components that display them, keeping related logic together.
How do I test Remix email actions?
Create a test that calls your action function directly with a mocked request and formData. Mock the email SDK and assert it was called correctly. Use createRemixStub for integration tests that render the full route with form submission.
How do I handle rate limiting for email endpoints in Remix?
Remix doesn't have built-in rate limiting. Use Upstash Rate Limit or a similar library in your action to check limits before sending. For Cloudflare deployments, use Workers rate limiting. Track limits by IP or authenticated user.