A password reset email goes out with resetUrl: undefined. An invoice renders $NaN as the total. A welcome email greets null null.
These aren't edge cases. They're Tuesday.
null, the API times out, or someone fat-fingers a variable name in production.You need validation. Not just type hints — actual runtime checks that catch bad data before it renders into HTML and lands in 10,000 inboxes.
The three validation layers (and when to use each)
Good email prop validation happens in layers. Each catches different failure modes.
1) Schema validation (Zod at the API boundary)
Validate props before they reach your email template. This is your first line of defense against garbage data.
import { z } from 'zod';
export const WelcomeEmailPropsSchema = z.object({
userName: z.string().min(1, "Name is required"),
userEmail: z.string().email("Invalid email"),
dashboardUrl: z.string().url("Dashboard URL must be valid"),
companyName: z.string().min(1).default("Our Team"),
});
export type WelcomeEmailProps = z.infer<typeof WelcomeEmailPropsSchema>;Use it at the send boundary:
import { WelcomeEmailPropsSchema } from '@/lib/email-schemas';
import { render } from '@react-email/render';
import WelcomeEmail from '@/emails/welcome';
export async function POST(req: Request) {
const body = await req.json();
// Validate before render
const validated = WelcomeEmailPropsSchema.safeParse(body);
if (!validated.success) {
return Response.json(
{ error: "Invalid props", issues: validated.error.issues },
{ status: 400 }
);
}
const html = await render(<WelcomeEmail {...validated.data} />);
// ... send with Resend/etc
}.safeParse() for user-facing endpoints, .parse() when you want to throw.2) Template-level guards (defensive rendering)
Even with schema validation, props can be truthy-but-useless: userName: " " passes validation but breaks your greeting.
Add fallbacks inside your templates:
export default function WelcomeEmail({ userName, dashboardUrl }: WelcomeEmailProps) {
const displayName = userName?.trim() || "there";
const safeUrl = dashboardUrl || "https://app.example.com/dashboard";
return (
<Html>
<Body>
<Heading>Welcome, {displayName}!</Heading>
<Text>Your account is ready. Get started below:</Text>
<Button href={safeUrl}>Go to Dashboard</Button>
</Body>
</Html>
);
}3) Pre-send smoke tests (catch render explosions)
Some bugs only appear when React Email tries to render. URLs with special characters. Nested objects that aren't serializable. Circular references.
Test the render step before you call your email provider:
import { render } from '@react-email/render';
export async function sendEmail<T>(
Template: React.ComponentType<T>,
props: T,
to: string
) {
// Smoke test: does it render?
let html: string;
try {
html = await render(<Template {...props} />);
} catch (err) {
console.error("Email render failed:", err);
throw new Error(`Failed to render email: ${err.message}`);
}
// Sanity check: is the output reasonable?
if (html.length < 100) {
throw new Error("Rendered email suspiciously short");
}
// Send with provider
return resend.emails.send({ to, html, subject: "..." });
}Common prop validation mistakes (and fixes)
1) You validate at send time but not in dev/preview
If your validation only runs in production, you'll ship broken templates.
Fix: Share the same schemas between your send API and your react-email dev preview data:
import { WelcomeEmailPropsSchema } from '@/lib/email-schemas';
WelcomeEmail.PreviewProps = WelcomeEmailPropsSchema.parse({
userName: "Alex Chen",
userEmail: "alex@example.com",
dashboardUrl: "https://app.example.com/dashboard",
companyName: "Acme Inc",
});
export default function WelcomeEmail(props: WelcomeEmailProps) {
// ...
}If your preview data doesn't match the schema, it'll throw during dev.
2) URLs aren't validated (hello, XSS)
Don't trust user-generated URLs. Even in transactional emails.
const SafeUrlSchema = z.string().url().refine(
(url) => url.startsWith("https://yourdomain.com/"),
"URL must be from your domain"
);
export const PasswordResetPropsSchema = z.object({
resetUrl: SafeUrlSchema,
userEmail: z.string().email(),
});3) Dates aren't timezone-safe
Passing raw date strings is a recipe for confusion. Users see "Your trial ends Feb 28" when it actually ended yesterday in UTC.
import { z } from 'zod';
const DateStringSchema = z.string().datetime(); // ISO 8601
export const TrialEndingPropsSchema = z.object({
trialEndsAt: DateStringSchema,
userTimezone: z.string().default("UTC"),
userName: z.string().min(1),
});Format dates in the template using the user's timezone:
import { format } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
export default function TrialEndingEmail({
trialEndsAt,
userTimezone,
userName
}: TrialEndingProps) {
const zonedDate = toZonedTime(new Date(trialEndsAt), userTimezone);
const formattedDate = format(zonedDate, "MMM d 'at' h:mm a zzz");
return (
<Html>
<Body>
<Text>Hi {userName},</Text>
<Text>Your trial ends on {formattedDate}.</Text>
</Body>
</Html>
);
}4) Numbers aren't sanitized (currency edge cases)
Floating point math breaks invoices. Always validate and format currency properly.
const CurrencyAmountSchema = z.number()
.positive("Amount must be positive")
.finite("Amount must be a valid number")
.refine(
(n) => Number.isInteger(n * 100),
"Amount can't have more than 2 decimal places"
);
export const InvoicePropsSchema = z.object({
totalAmount: CurrencyAmountSchema,
currency: z.enum(["USD", "EUR", "GBP"]).default("USD"),
items: z.array(z.object({
name: z.string().min(1),
quantity: z.number().int().positive(),
unitPrice: CurrencyAmountSchema,
})),
});const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
};
export default function InvoiceEmail({ totalAmount, currency, items }: InvoiceProps) {
return (
<Html>
<Body>
{items.map((item) => (
<Row key={item.name}>
<Text>{item.name} × {item.quantity}</Text>
<Text>{formatCurrency(item.unitPrice * item.quantity, currency)}</Text>
</Row>
))}
<Text><Strong>Total: {formatCurrency(totalAmount, currency)}</Strong></Text>
</Body>
</Html>
);
}Testing your validation (automate the boring stuff)
Don't rely on manual testing. Write unit tests for your schemas.
import { describe, it, expect } from 'vitest';
import { WelcomeEmailPropsSchema } from '@/lib/email-schemas';
describe('WelcomeEmailPropsSchema', () => {
it('accepts valid props', () => {
const result = WelcomeEmailPropsSchema.safeParse({
userName: "Alex",
userEmail: "alex@example.com",
dashboardUrl: "https://app.example.com/dashboard",
});
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = WelcomeEmailPropsSchema.safeParse({
userName: "Alex",
userEmail: "not-an-email",
dashboardUrl: "https://app.example.com/dashboard",
});
expect(result.success).toBe(false);
});
it('rejects missing required fields', () => {
const result = WelcomeEmailPropsSchema.safeParse({
userEmail: "alex@example.com",
});
expect(result.success).toBe(false);
});
it('applies defaults correctly', () => {
const result = WelcomeEmailPropsSchema.parse({
userName: "Alex",
userEmail: "alex@example.com",
dashboardUrl: "https://app.example.com/dashboard",
});
expect(result.companyName).toBe("Our Team"); // default value
});
});Production checklist
Before you ship email validation:
- ✓ Schemas defined for every email template (Zod or similar)
- ✓ Validation at API boundary — use
.safeParse()and return 400 errors - ✓ Fallbacks in templates for non-critical props
- ✓ Pre-send render check to catch React explosions
- ✓ URL validation — no untrusted domains in links
- ✓ Date/time formatting with timezone awareness
- ✓ Currency sanitization — no
$NaN - ✓ Unit tests for all schemas
- ✓ Error logging when validation fails (track patterns)
When to fail vs. when to fall back
Not all validation failures are equal. Here's a decision framework:
- Missing recipient email
- Invalid password reset URL
- Missing invoice total
- Core CTA link is broken
- Missing user name → "there" or omit greeting
- Missing logo URL → render without logo
- Empty feature list → hide section entirely
- Missing company name → generic default
If the email can't fulfill its primary function without the prop, fail. If it's polish or personalization, fall back.
What to read next
This covers validation. For handling what happens after validation fails, see Error Handling for React Email.
If you want production-ready templates with validation built in, check out the SaaS template library — schemas, fallbacks, and tests included.
And if you're still setting up your email system, see React Email + Resend: Production Checklist for the full setup guide.