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.
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.
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.
{
"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."
}
}{
"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:
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:
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:
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>
);
}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:
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:
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:
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:
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>
);
}dir="rtl" inconsistently.Date and number formatting
Don't hardcode date formats. Use Intl.DateTimeFormat and Intl.NumberFormat to match user expectations.
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:
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>
);
}- 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:
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.
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
localepreference - Secondary: browser/app language (if available)
- Tertiary: English (or your default language)
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:
if (!value && process.env.NODE_ENV === "development") {
console.warn(`Missing translation: ${locale}.${key}.${subKey}`);
}Implementation checklist
- Store
localeon the user record (database) - Pass
localeas 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
IntlAPIs 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):
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>
);
}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.
locale props and translation scaffolding out of the box.