Email templates are a common source of runtime errors. A missing variable renders as “undefined” in the subject line. A wrong type silently breaks the layout. And since you can't “undo send” an email, these bugs reach users instantly.
TypeScript can catch these errors at build time — before they ever reach a user's inbox. This guide covers practical patterns for making your React Email templates type-safe from props to sending.
Pattern 1: Typed Template Props
Every email template should have an explicit props type. This is the contract between your application code and the email template. Never use any or untyped objects.
// Define a base type for all email props
type BaseEmailProps = {
previewText?: string;
};
// Each template gets its own props type
export type WelcomeEmailProps = BaseEmailProps & {
name: string;
loginUrl: string;
trialDays: number;
};
export type InvoiceEmailProps = BaseEmailProps & {
customerName: string;
invoiceNumber: string;
items: Array<{
description: string;
quantity: number;
unitPrice: number;
}>;
total: number;
currency: string;
pdfUrl: string;
};
export type PasswordResetProps = BaseEmailProps & {
resetUrl: string;
expiresInMinutes: number;
requestedFromIp?: string;
};With these types in place, your template components get full autocomplete and error checking:
import type { InvoiceEmailProps } from "./types";
import { Html, Text, Section, Row, Column } from "@react-email/components";
export default function InvoiceEmail({
customerName,
invoiceNumber,
items,
total,
currency,
pdfUrl,
}: InvoiceEmailProps) {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
});
return (
<Html>
{/* TypeScript ensures all required props are passed */}
<Text>Invoice #{invoiceNumber}</Text>
<Text>Hi {customerName},</Text>
<Section>
{items.map((item, i) => (
<Row key={i}>
<Column>{item.description}</Column>
<Column>{item.quantity}x</Column>
<Column>{formatter.format(item.unitPrice)}</Column>
</Row>
))}
</Section>
<Text>Total: {formatter.format(total)}</Text>
</Html>
);
}Pattern 2: Runtime Validation with Zod
TypeScript only checks types at build time. For data coming from webhooks, APIs, or user input at runtime, add Zod validation before passing data to your templates:
import { z } from "zod";
export const welcomeEmailSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
loginUrl: z.string().url("Invalid login URL"),
trialDays: z.number().int().positive(),
});
export const invoiceEmailSchema = z.object({
customerName: z.string().min(1),
email: z.string().email(),
invoiceNumber: z.string().min(1),
items: z.array(
z.object({
description: z.string(),
quantity: z.number().int().positive(),
unitPrice: z.number().nonnegative(),
})
).min(1, "Invoice must have at least one item"),
total: z.number().nonnegative(),
currency: z.string().length(3),
pdfUrl: z.string().url(),
});
// Infer types from schemas to keep types and validation in sync
export type WelcomeEmailInput = z.infer<typeof welcomeEmailSchema>;
export type InvoiceEmailInput = z.infer<typeof invoiceEmailSchema>;Use the schema in your API route to validate before sending:
import { welcomeEmailSchema } from "@/lib/email-schemas";
import { Resend } from "resend";
import WelcomeEmail from "@/emails/welcome";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const body = await request.json();
// Validate at runtime — throws with clear error messages
const result = welcomeEmailSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { email, name, loginUrl, trialDays } = result.data;
await resend.emails.send({
from: "App <hello@yourapp.com>",
to: email,
subject: `Welcome, ${name}!`,
react: WelcomeEmail({ name, loginUrl, trialDays }),
});
return Response.json({ success: true });
}z.infer), you get a single source of truth. Update the schema, and the types update automatically.Recommended
SaaS Essentials Pack
21+ Templates · 60+ Variations. One-time purchase, lifetime updates.
Pattern 3: Type-Safe Send Helper
Wrap your email sending logic in a generic helper that enforces the correct props for each template:
import { Resend } from "resend";
import type { WelcomeEmailProps } from "@/emails/types";
import type { InvoiceEmailProps } from "@/emails/types";
import type { PasswordResetProps } from "@/emails/types";
import WelcomeEmail from "@/emails/welcome";
import InvoiceEmail from "@/emails/invoice";
import PasswordResetEmail from "@/emails/password-reset";
const resend = new Resend(process.env.RESEND_API_KEY);
// Template registry with typed props
const templates = {
welcome: {
component: WelcomeEmail,
subject: (props: WelcomeEmailProps) =>
`Welcome, ${props.name}!`,
},
invoice: {
component: InvoiceEmail,
subject: (props: InvoiceEmailProps) =>
`Invoice #${props.invoiceNumber}`,
},
passwordReset: {
component: PasswordResetEmail,
subject: () => "Reset your password",
},
} as const;
// Extract the props type for each template
type TemplateMap = typeof templates;
type TemplateName = keyof TemplateMap;
type TemplateProps<T extends TemplateName> = Parameters<
TemplateMap[T]["component"]
>[0];
// Type-safe send function
export async function sendEmail<T extends TemplateName>(
template: T,
to: string,
props: TemplateProps<T>
) {
const { component: Component, subject } = templates[template];
const subjectLine = (subject as (p: typeof props) => string)(props);
return resend.emails.send({
from: "App <hello@yourapp.com>",
to,
subject: subjectLine,
react: (Component as React.FC<typeof props>)(props),
});
}Now sending an email is fully type-checked:
// TypeScript enforces the correct props for each template
await sendEmail("welcome", "user@example.com", {
name: "Alice",
loginUrl: "https://app.example.com/login",
trialDays: 14,
});
// This would cause a type error — "invoice" expects different props
await sendEmail("invoice", "user@example.com", {
name: "Alice", // Error: 'name' does not exist on InvoiceEmailProps
});Pattern 4: Type-Safe Preview Data
React Email's dev server uses default props for previewing. Make these type-safe too, so your previews always match your template contracts:
import type { WelcomeEmailProps } from "./types";
// Preview props must satisfy the full type
WelcomeEmail.PreviewProps = {
name: "Alice Chen",
loginUrl: "https://app.example.com/login",
trialDays: 14,
} satisfies WelcomeEmailProps;The satisfies keyword (TypeScript 4.9+) ensures the preview data matches the props type without widening the type. If you add a required prop to WelcomeEmailProps, you'll get a type error in the preview data until you add it there too.
Type Safety Checklist
Here's a quick checklist to make sure your email templates are fully type-safe:
- Every template has an explicit
Propstype with noany - API routes validate incoming data with Zod before passing to templates
- Types are inferred from Zod schemas (single source of truth)
- The send helper uses generics to enforce correct props per template
- Preview data uses
satisfiesto stay in sync with prop types strict: trueis enabled intsconfig.json
With these patterns, you get compile-time confidence that every email your app sends has the right data in the right shape. No more “undefined” showing up in someone's inbox.