React Email12 min read

Internationalization for React Email: A Production-Ready i18n Guide

Add multi-language support to React Email templates: centralized translations, RTL support, date/currency formatting, subject line localization, and testing across locales.

R

React Emails Pro

February 28, 2026

If your SaaS has international users, you've already localized your app. But your emails? Probably still in English.

The gap matters: a password reset in the user's language has higher trust and lower support load. A welcome email that matches their app language creates continuity instead of confusion.

Internationalization (i18n) for emails isn't about marketing polish. It's about making critical flows feel legitimate to users who don't speak English.

The problem: email i18n is messier than app i18n

Your app has a router, session state, and user preferences. Your emails? They're stateless functions triggered by events.

You can't:

  • Read from cookies or localStorage
  • Call useLocale() hooks
  • Rely on browser language detection

Instead, you pass the user's locale explicitly to your email template — and that means integrating i18n into your send logic, not just your components.


Architecture: where to store locale and how to pass it

The cleanest pattern: store locale on the user record (database), then pass it as a prop when rendering the email.

lib/send-email.ts
import { render } from "@react-email/render";
import PasswordResetEmail from "@/emails/password-reset";

export async function sendPasswordReset(userId: string, resetToken: string) {
  const user = await db.user.findUnique({ where: { id: userId } });
  if (!user) throw new Error("User not found");

  const html = render(
    <PasswordResetEmail
      userName={user.name}
      resetUrl={`https://app.example.com/reset?token=${resetToken}`}
      locale={user.locale || "en"} // fallback to English
    />
  );

  await sendEmailViaProvider({
    to: user.email,
    subject: getSubjectByLocale(user.locale, "password_reset"),
    html,
  });
}

Now your template receives locale as a prop. No magic, no guessing.


Translation patterns: structured vs inline

There are two common approaches for managing translations in React Email:

Pattern 1: Centralized translation files (recommended)

Use a JSON structure similar to your app's i18n setup. Keep translations in a dedicated file per locale.

emails/locales/en.json
{
  "password_reset": {
    "subject": "Reset your password",
    "heading": "Password reset requested",
    "body": "We received a request to reset your password. Click the button below to create a new one.",
    "cta": "Reset password",
    "expiry": "This link expires in 1 hour.",
    "ignore": "If you didn't request this, you can safely ignore this email."
  }
}
emails/locales/es.json
{
  "password_reset": {
    "subject": "Restablece tu contraseña",
    "heading": "Solicitud de restablecimiento de contraseña",
    "body": "Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva.",
    "cta": "Restablecer contraseña",
    "expiry": "Este enlace caduca en 1 hora.",
    "ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura."
  }
}

Then create a simple translation helper:

emails/lib/translate.ts
import en from "../locales/en.json";
import es from "../locales/es.json";
import fr from "../locales/fr.json";

const translations = { en, es, fr };

type Locale = keyof typeof translations;
type TranslationKey = keyof typeof en;

export function t(locale: Locale, key: TranslationKey, subKey: string): string {
  const lang = translations[locale] || translations.en;
  // @ts-expect-error - dynamic key access
  return lang[key]?.[subKey] || translations.en[key]?.[subKey] || "";
}

Use it in your template:

emails/password-reset.tsx
import { t } from "./lib/translate";

interface PasswordResetEmailProps {
  userName: string;
  resetUrl: string;
  locale: "en" | "es" | "fr";
}

export default function PasswordResetEmail({
  userName,
  resetUrl,
  locale,
}: PasswordResetEmailProps) {
  return (
    <Html>
      <Head />
      <Body>
        <Heading>{t(locale, "password_reset", "heading")}</Heading>
        <Text>
          {t(locale, "password_reset", "body")}
        </Text>
        <Button href={resetUrl}>
          {t(locale, "password_reset", "cta")}
        </Button>
        <Text style={{ color: "#666", fontSize: "14px" }}>
          {t(locale, "password_reset", "expiry")}
        </Text>
        <Text style={{ color: "#999", fontSize: "12px" }}>
          {t(locale, "password_reset", "ignore")}
        </Text>
      </Body>
    </Html>
  );
}

Pattern 2: Inline conditional rendering (quick and dirty)

For small teams or low translation volume, you can inline the logic directly in the template:

emails/password-reset-inline.tsx
export default function PasswordResetEmail({ locale, resetUrl }: Props) {
  const copy = {
    en: {
      heading: "Password reset requested",
      body: "Click below to create a new password.",
      cta: "Reset password",
    },
    es: {
      heading: "Solicitud de restablecimiento de contraseña",
      body: "Haz clic abajo para crear una nueva contraseña.",
      cta: "Restablecer contraseña",
    },
  };

  const t = copy[locale] || copy.en;

  return (
    <Html>
      <Heading>{t.heading}</Heading>
      <Text>{t.body}</Text>
      <Button href={resetUrl}>{t.cta}</Button>
    </Html>
  );
}
Inline works for 2-3 languages. Beyond that, use centralized translation files to avoid template bloat.

Subject lines: don't forget them

The biggest i18n mistake? Translating the email body but leaving the subject line in English.

Your subject line logic should mirror your body translation approach:

lib/email-subjects.ts
const subjects = {
  en: {
    password_reset: "Reset your password",
    welcome: "Welcome to {product}",
    invoice: "Your invoice for {month}",
  },
  es: {
    password_reset: "Restablece tu contraseña",
    welcome: "Bienvenido a {product}",
    invoice: "Tu factura de {month}",
  },
};

export function getSubject(
  locale: string,
  key: string,
  vars?: Record<string, string>
): string {
  const lang = subjects[locale as keyof typeof subjects] || subjects.en;
  let subject = lang[key as keyof typeof lang] || "";

  // Simple variable interpolation
  if (vars) {
    Object.entries(vars).forEach(([k, v]) => {
      subject = subject.replace(`{${k}}`, v);
    });
  }

  return subject;
}

Then call it when sending:

lib/send-email.ts
await sendEmailViaProvider({
  to: user.email,
  subject: getSubject(user.locale, "password_reset"),
  html,
});

Right-to-left (RTL) support

If you support Arabic, Hebrew, or other RTL languages, you need to flip text direction AND layout.

Add a helper to detect RTL locales:

emails/lib/rtl.ts
const RTL_LOCALES = ["ar", "he", "fa", "ur"];

export function isRTL(locale: string): boolean {
  return RTL_LOCALES.includes(locale);
}

Then conditionally apply dir and text alignment:

emails/password-reset-rtl.tsx
import { isRTL } from "./lib/rtl";

export default function PasswordResetEmail({ locale, resetUrl }: Props) {
  const dir = isRTL(locale) ? "rtl" : "ltr";
  const textAlign = isRTL(locale) ? "right" : "left";

  return (
    <Html dir={dir} lang={locale}>
      <Body style={{ direction: dir }}>
        <Heading style={{ textAlign }}>{t(locale, "password_reset", "heading")}</Heading>
        <Text style={{ textAlign }}>{t(locale, "password_reset", "body")}</Text>
        <Button href={resetUrl}>{t(locale, "password_reset", "cta")}</Button>
      </Body>
    </Html>
  );
}
Be careful with button alignment in RTL. Test across Gmail, Outlook, and Apple Mail — they handle dir="rtl" inconsistently.

Date and number formatting

Don't hardcode date formats. Use Intl.DateTimeFormat and Intl.NumberFormat to match user expectations.

emails/lib/format.ts
export function formatDate(date: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
    day: "numeric",
  }).format(date);
}

export function formatCurrency(
  amount: number,
  currency: string,
  locale: string
): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(amount);
}

Use in your template:

emails/invoice.tsx
import { formatDate, formatCurrency } from "./lib/format";

export default function InvoiceEmail({ locale, dueDate, total }: Props) {
  return (
    <Html lang={locale}>
      <Text>Due: {formatDate(dueDate, locale)}</Text>
      <Text>Total: {formatCurrency(total, "USD", locale)}</Text>
    </Html>
  );
}
Example outputs:
  • en-US: February 28, 2026 | $49.00
  • es-ES: 28 de febrero de 2026 | 49,00 US$
  • fr-FR: 28 février 2026 | 49,00 $US

Testing localized emails

Don't ship translations you can't read. Add preview mode data for each supported locale:

emails/password-reset.tsx
PasswordResetEmail.PreviewProps = {
  en: {
    userName: "Alex Chen",
    resetUrl: "https://app.example.com/reset?token=abc123",
    locale: "en",
  },
  es: {
    userName: "María García",
    resetUrl: "https://app.example.com/reset?token=abc123",
    locale: "es",
  },
  ar: {
    userName: "أحمد محمد",
    resetUrl: "https://app.example.com/reset?token=abc123",
    locale: "ar",
  },
};

Then in your preview server, cycle through locales or add a dropdown to switch between them.

Bonus: use real names from each locale in preview data. It helps catch layout issues (e.g., long German compound words, Arabic diacritics).

Fallback strategy: what to do when translations are incomplete

You'll never have 100% translation coverage on day one. Build a graceful fallback:

  • Primary: user's locale preference
  • Secondary: browser/app language (if available)
  • Tertiary: English (or your default language)
emails/lib/translate.ts
export function t(locale: Locale, key: string, subKey: string): string {
  const lang = translations[locale] || translations.en;
  const value = lang[key]?.[subKey];

  // Fallback to English if translation missing
  if (!value && locale !== "en") {
    return translations.en[key]?.[subKey] || "[missing translation]";
  }

  return value || "[missing translation]";
}

Log missing translations in development so you can track coverage:

emails/lib/translate.ts
if (!value && process.env.NODE_ENV === "development") {
  console.warn(`Missing translation: ${locale}.${key}.${subKey}`);
}

Implementation checklist

  • Store locale on the user record (database)
  • Pass locale as a prop to email templates
  • Translate subject lines (not just body content)
  • Use centralized translation files for 3+ languages
  • Support RTL if you have Arabic/Hebrew users
  • Use Intl APIs for dates and currency
  • Add preview data for each locale
  • Build a fallback strategy for incomplete translations
  • Test across email clients (Gmail, Outlook, Apple Mail) in each locale

Common mistakes to avoid

1) Forgetting the lang attribute

Always set <Html lang={locale}> so screen readers and email clients know what language to expect.

2) Hardcoding English in fallback CTAs

If your translation is missing, don't fall back to English inline. Use a placeholder that makes the gap obvious:

return value || `[MISSING: ${key}.${subKey}]`;

3) Not testing character encoding

Special characters (ñ, ü, é, 中文, العربية) can break if your email provider or template renderer doesn't handle UTF-8 properly. Always set:

<Head>
  <meta charSet="UTF-8" />
</Head>

Optional: using i18n libraries

If you're already using i18next or react-intl in your app, you can reuse the same translation files in emails.

Just initialize the library server-side (not in the React component):

lib/send-email.ts
import i18next from "i18next";
import en from "../locales/en.json";
import es from "../locales/es.json";

i18next.init({
  lng: user.locale,
  resources: { en: { translation: en }, es: { translation: es } },
});

const html = render(
  <PasswordResetEmail
    resetUrl={resetUrl}
    t={i18next.t} // pass the translate function as a prop
  />
);

Then in your template:

export default function PasswordResetEmail({ t, resetUrl }: Props) {
  return (
    <Html>
      <Heading>{t("password_reset.heading")}</Heading>
      <Text>{t("password_reset.body")}</Text>
      <Button href={resetUrl}>{t("password_reset.cta")}</Button>
    </Html>
  );
}
This approach works well if you want consistency between app and email translations — but adds a dependency. For most teams, a simple t() helper is enough.

Next steps

Once you've implemented i18n, your email system is no longer hardcoded to one language. Next:

  • Add locale selection in user settings (so they can override browser defaults)
  • Track email open rates by locale (to see if translations improve engagement)
  • Build a workflow for non-technical team members to update translations (e.g., a simple CMS or Google Sheets integration)

And if you're building your email infrastructure from scratch, see React Email + Resend: Production Checklist for a complete setup guide.

Want production-ready templates with built-in i18n structure? Check out our SaaS template library — includes locale props and translation scaffolding out of the box.

Production-ready templates for every flow

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates