Email Template Translations | Next.js Supabase SaaS Kit

Learn how to translate email templates for team invitations, account deletion, and OTP verification in your multi-language SaaS application.

Email templates in Makerkit have their own translation system, separate from the main application translations. This allows emails to be sent in the recipient's preferred language.

Email Translation Structure

Email translations are stored in the packages/email-templates package:

packages/email-templates/src/
├── locales/
│ └── en/
│ ├── account-delete-email.json
│ ├── invite-email.json
│ └── otp-email.json
└── lib/
└── i18n.ts

Default Email Templates

Makerkit includes three translatable email templates:

Team Invitation Email

Sent when a user is invited to join a team:

{
"subject": "You have been invited to join a team",
"heading": "Join {{teamName}} on {{productName}}",
"hello": "Hello {{invitedUserEmail}},",
"mainText": "<strong>{{inviter}}</strong> has invited you to the <strong>{{teamName}}</strong> team on <strong>{{productName}}</strong>.",
"joinTeam": "Join {{teamName}}",
"copyPasteLink": "or copy and paste this URL into your browser:",
"invitationIntendedFor": "This invitation is intended for {{invitedUserEmail}}."
}

Account Deletion Email

Sent when a user requests account deletion:

{
"subject": "Account Deletion Confirmation",
"heading": "Your account has been deleted",
"hello": "Hello,",
"mainText": "Your account and all associated data have been permanently deleted from {{productName}}.",
"contactSupport": "If you did not request this deletion, please contact support immediately."
}

OTP Verification Email

Sent for one-time password verification:

{
"subject": "Your verification code",
"heading": "Verification Code",
"hello": "Hello,",
"mainText": "Your verification code is:",
"codeExpiry": "This code will expire in {{minutes}} minutes.",
"ignoreIfNotRequested": "If you did not request this code, you can safely ignore this email."
}

Adding Email Translations

1. Create Language Folder

Create a new folder for your language:

mkdir packages/email-templates/src/locales/es

2. Copy and Translate Files

Copy the English templates:

cp packages/email-templates/src/locales/en/*.json packages/email-templates/src/locales/es/

Translate each file:

{
"subject": "Has sido invitado a unirte a un equipo",
"heading": "Unete a {{teamName}} en {{productName}}",
"hello": "Hola {{invitedUserEmail}},",
"mainText": "<strong>{{inviter}}</strong> te ha invitado al equipo <strong>{{teamName}}</strong> en <strong>{{productName}}</strong>.",
"joinTeam": "Unirse a {{teamName}}",
"copyPasteLink": "o copia y pega esta URL en tu navegador:",
"invitationIntendedFor": "Esta invitacion es para {{invitedUserEmail}}."
}

How Email Translations Work

The email template system initializes i18n separately from the main application:

import { initializeServerI18n } from '@kit/i18n/server';
import { createI18nSettings } from '@kit/i18n';
export function initializeEmailI18n(params: {
language: string | undefined;
namespace: string;
}) {
const language = params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';
return initializeServerI18n(
createI18nSettings({
language,
languages: [language],
namespaces: params.namespace,
}),
async (language, namespace) => {
const data = await import(`../locales/${language}/${namespace}.json`);
return data as Record<string, string>;
},
);
}

Key points:

  • Each email type has its own namespace (e.g., invite-email, otp-email)
  • The language can be passed when sending the email
  • Falls back to NEXT_PUBLIC_DEFAULT_LOCALE if no language specified

Sending Translated Emails

When sending emails, pass the recipient's preferred language:

import { renderInviteEmail } from '@kit/email-templates';
export async function sendInviteEmail(params: {
invitedUserEmail: string;
inviter: string;
teamName: string;
inviteLink: string;
language?: string; // Recipient's preferred language
}) {
const { html, subject } = await renderInviteEmail({
invitedUserEmail: params.invitedUserEmail,
inviter: params.inviter,
teamName: params.teamName,
link: params.inviteLink,
productName: 'Your App',
language: params.language, // Pass the language
});
await sendEmail({
to: params.invitedUserEmail,
subject,
html,
});
}

Determining Recipient Language

To send emails in the recipient's language, you need to know their preference. Common approaches:

From User Profile

Store language preference in the user profile:

// When sending an email
const user = await getUserById(userId);
const language = user.preferredLanguage ?? 'en';
await sendInviteEmail({
// ...other params
language,
});

From Request Context

Use the current user's language when they trigger an action:

'use server';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
export async function inviteMember(email: string) {
const i18n = await createI18nServerInstance();
const currentLanguage = i18n.language;
await sendInviteEmail({
invitedUserEmail: email,
// Use inviter's language as default for the invited user
language: currentLanguage,
});
}

Adding Custom Email Templates

To add a new translatable email template:

1. Create Translation Files

{
"subject": "Welcome to {{productName}}",
"heading": "Welcome aboard!",
"hello": "Hello {{userName}},",
"mainText": "Thank you for joining {{productName}}. We're excited to have you!",
"getStarted": "Get Started",
"helpText": "If you have any questions, feel free to reach out to our support team."
}

2. Create the Email Template Component

import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
render,
} from '@react-email/components';
import { initializeEmailI18n } from '../lib/i18n';
interface WelcomeEmailProps {
userName: string;
productName: string;
dashboardUrl: string;
language?: string;
}
export async function renderWelcomeEmail(props: WelcomeEmailProps) {
const namespace = 'welcome-email';
const { t } = await initializeEmailI18n({
language: props.language,
namespace,
});
const subject = t(`${namespace}:subject`, { productName: props.productName });
// Use render() to convert JSX to HTML string
const html = await render(
<Html>
<Head />
<Preview>{subject}</Preview>
<Body>
<Container>
<Heading>{t(`${namespace}:heading`)}</Heading>
<Text>{t(`${namespace}:hello`, { userName: props.userName })}</Text>
<Text>
{t(`${namespace}:mainText`, { productName: props.productName })}
</Text>
<Section>
<Button href={props.dashboardUrl}>
{t(`${namespace}:getStarted`)}
</Button>
</Section>
<Text>{t(`${namespace}:helpText`)}</Text>
</Container>
</Body>
</Html>
);
return { html, subject };
}

3. Export from Package

export * from './emails/welcome.email';

Interpolation in Email Translations

Email translations support the same interpolation syntax as the main application:

Simple Variables

{
"hello": "Hello {{userName}},"
}

HTML Tags

You can use basic HTML for formatting:

{
"mainText": "<strong>{{inviter}}</strong> has invited you to join <strong>{{teamName}}</strong>."
}

The email template must render this content appropriately:

<Text dangerouslySetInnerHTML={{ __html: t('mainText', { inviter, teamName }) }} />

When using dangerouslySetInnerHTML, ensure all interpolated values come from trusted sources (your database, not user input). Never interpolate raw user input into HTML translations without sanitization. For user-provided content, use plain text translations instead.

Testing Email Translations

Preview in Development

Use the email preview feature to test translations:

# Start the email preview server
cd packages/email-templates
pnpm dev

Then open http://localhost:3001 to preview email templates.

Test with Inbucket

When running Supabase locally, emails are captured by Inbucket:

  1. Start Supabase: pnpm supabase:start
  2. Open Inbucket: http://localhost:54324
  3. Trigger an action that sends an email
  4. Check Inbucket for the translated email

Verify All Languages

Create a test script to verify translations exist for all configured languages:

# Check that all email translation files exist
for lang in en es de fr; do
for file in invite-email account-delete-email otp-email; do
path="packages/email-templates/src/locales/${lang}/${file}.json"
if [ -f "$path" ]; then
echo "OK: $path"
else
echo "MISSING: $path"
fi
done
done

Troubleshooting

Email Shows English Instead of User's Language

Check that you're passing the language parameter when rendering the email:

const { html, subject } = await renderInviteEmail({
// ...
language: user.preferredLanguage, // Must be passed explicitly
});

Translation File Not Found Error

Verify the file exists at the expected path:

packages/email-templates/src/locales/[language]/[namespace].json

The namespace must match the email template name (e.g., invite-email, not invite).

HTML Not Rendering in Email

Email clients have limited HTML support. Stick to basic tags (<strong>, <em>, <br>) and avoid complex CSS. Test with multiple email clients (Gmail, Outlook, Apple Mail).

Frequently Asked Questions

How do I preview email translations locally?
Run 'pnpm dev' in the packages/email-templates directory to start the email preview server at localhost:3001. You can switch languages in the preview to test different translations.
Can I use the same translations for app and email?
Email templates use a separate translation system in packages/email-templates/src/locales. This separation allows emails to be rendered without the full app context and keeps email-specific strings isolated.
How do I add a new email template with translations?
Create the translation JSON files in packages/email-templates/src/locales/[lang]/[template-name].json, then create the React Email component that calls initializeEmailI18n with the matching namespace.
Do email translations support pluralization?
Yes, email translations use the same i18next configuration as the main app. Use _one, _other suffixes in your JSON files for pluralization.
How do I comply with email regulations (CAN-SPAM, GDPR)?
Include an unsubscribe link in marketing emails, add your physical address, and honor unsubscribe requests within 10 days. For GDPR, ensure you have consent before sending and document it. These requirements apply regardless of language.