Code Tips

Type-Safe Email Templates with TypeScript

React Emails ProFebruary 23, 20267 min read

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.

emails/types.ts
// 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:

emails/invoice.tsx
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>
  );
}
If you pass the wrong type or forget a required prop, TypeScript catches it immediately in your editor — not after you've sent 10,000 emails.

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:

lib/email-schemas.ts
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:

app/api/send-welcome/route.ts
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 });
}
By inferring your TypeScript types from Zod schemas (using 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.

$19.95$9.95Get it

Pattern 3: Type-Safe Send Helper

Wrap your email sending logic in a generic helper that enforces the correct props for each template:

lib/email.ts
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:

emails/welcome.tsx
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 Props type with no any
  • 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 satisfies to stay in sync with prop types
  • strict: true is enabled in tsconfig.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.

Stop building emails from scratch

Get production-ready React Email templates. Tested across Gmail, Outlook & Apple Mail. One-time purchase, lifetime updates.

Browse templates