Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
421
docs/translations/adding-translations.mdoc
Normal file
421
docs/translations/adding-translations.mdoc
Normal file
@@ -0,0 +1,421 @@
|
||||
---
|
||||
status: "published"
|
||||
title: "Adding new translations | Next.js Supabase SaaS Kit"
|
||||
label: "Adding new translations"
|
||||
description: "Learn how to add new languages, create translation files, and organize namespaces in your Next.js Supabase SaaS application."
|
||||
order: 1
|
||||
---
|
||||
|
||||
This guide covers adding new languages, creating translation files, and organizing your translations into namespaces.
|
||||
|
||||
{% sequence title="Steps to add new translations" description="Learn how to add new translations to your Next.js Supabase SaaS project." %}
|
||||
|
||||
[Create language files](#1-create-language-files)
|
||||
|
||||
[Register the language](#2-register-the-language)
|
||||
|
||||
[Add custom namespaces](#3-add-custom-namespaces)
|
||||
|
||||
[Translate email templates](#4-translate-email-templates)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## 1. Create Language Files
|
||||
|
||||
Translation files live in `apps/web/i18n/messages/{locale}/`. Each language needs its own folder with JSON files matching your namespaces.
|
||||
|
||||
### Create the Language Folder
|
||||
|
||||
Create a new folder using the [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes):
|
||||
|
||||
```bash
|
||||
mkdir apps/web/i18n/messages/es
|
||||
```
|
||||
|
||||
Common language codes:
|
||||
- `de` - German
|
||||
- `es` - Spanish
|
||||
- `fr` - French
|
||||
- `it` - Italian
|
||||
- `ja` - Japanese
|
||||
- `pt` - Portuguese
|
||||
- `zh` - Chinese
|
||||
|
||||
### Regional Language Codes
|
||||
|
||||
For regional variants like `es-ES` (Spanish - Spain) or `pt-BR` (Portuguese - Brazil), use lowercase with a hyphen:
|
||||
|
||||
```bash
|
||||
# Correct
|
||||
mkdir apps/web/i18n/messages/es-es
|
||||
mkdir apps/web/i18n/messages/pt-br
|
||||
|
||||
# Incorrect - will not work
|
||||
mkdir apps/web/i18n/messages/es-ES
|
||||
```
|
||||
|
||||
The system normalizes language codes to lowercase internally.
|
||||
|
||||
### Copy and Translate Files
|
||||
|
||||
Copy the English files as a starting point:
|
||||
|
||||
```bash
|
||||
cp apps/web/i18n/messages/en/*.json apps/web/i18n/messages/es/
|
||||
```
|
||||
|
||||
Then translate each JSON file. Here's an example for `common.json`:
|
||||
|
||||
```json title="apps/web/i18n/messages/es/common.json"
|
||||
{
|
||||
"homeTabLabel": "Inicio",
|
||||
"cancel": "Cancelar",
|
||||
"clear": "Limpiar",
|
||||
"goBack": "Volver",
|
||||
"tryAgain": "Intentar de nuevo",
|
||||
"loading": "Cargando. Por favor espere...",
|
||||
"routes": {
|
||||
"home": "Inicio",
|
||||
"account": "Cuenta",
|
||||
"billing": "Facturacion"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep the same key structure as the English files. Only translate the values.
|
||||
|
||||
## 2. Register the Language
|
||||
|
||||
Add your new language to the locales configuration:
|
||||
|
||||
```tsx title="packages/i18n/src/locales.tsx" {6}
|
||||
/**
|
||||
* The list of supported locales.
|
||||
* Add more locales here as needed.
|
||||
*/
|
||||
export const locales: string[] = ['en', 'es', 'de', 'fr'];
|
||||
```
|
||||
|
||||
The order matters for fallback behavior:
|
||||
1. First locale is the default fallback
|
||||
2. When a translation is missing, the system falls back through this list
|
||||
|
||||
### Verify the Registration
|
||||
|
||||
After adding a language, verify it works:
|
||||
|
||||
1. Restart the development server
|
||||
2. Navigate to your app with the locale prefix (e.g., `/es/home`)
|
||||
3. You should see your translations appear
|
||||
|
||||
## 3. Add Custom Namespaces
|
||||
|
||||
Namespaces organize translations by feature or domain. The default namespaces are registered in `apps/web/i18n/request.ts`:
|
||||
|
||||
```tsx title="apps/web/i18n/request.ts"
|
||||
const namespaces = [
|
||||
'common', // Shared UI elements
|
||||
'auth', // Authentication flows
|
||||
'account', // Account settings
|
||||
'teams', // Team management
|
||||
'billing', // Billing and subscriptions
|
||||
'marketing', // Marketing pages
|
||||
];
|
||||
```
|
||||
|
||||
### Create a New Namespace
|
||||
|
||||
#### Create the JSON file for each language:
|
||||
|
||||
```bash
|
||||
# Create for English
|
||||
touch apps/web/i18n/messages/en/projects.json
|
||||
|
||||
# Create for other languages
|
||||
touch apps/web/i18n/messages/es/projects.json
|
||||
```
|
||||
|
||||
#### Add your translations
|
||||
|
||||
```json title="apps/web/i18n/messages/en/projects.json"
|
||||
{
|
||||
"title": "Projects",
|
||||
"createProject": "Create Project",
|
||||
"projectName": "Project Name",
|
||||
"projectDescription": "Description",
|
||||
"deleteProject": "Delete Project",
|
||||
"confirmDelete": "Are you sure you want to delete this project?",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
"draft": "Draft"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Register the namespace:
|
||||
|
||||
```tsx title="apps/web/i18n/request.ts" {8}
|
||||
const namespaces = [
|
||||
'common',
|
||||
'auth',
|
||||
'account',
|
||||
'teams',
|
||||
'billing',
|
||||
'marketing',
|
||||
'projects', // Your new namespace
|
||||
];
|
||||
```
|
||||
|
||||
#### Use the namespace in your components:
|
||||
|
||||
```tsx title="apps/web/app/[locale]/home/[account]/projects/page.tsx"
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function ProjectsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>
|
||||
<Trans i18nKey="projects.title" />
|
||||
</h1>
|
||||
|
||||
<button>
|
||||
<Trans i18nKey="projects.createProject" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Namespace Best Practices
|
||||
|
||||
**Keep namespaces focused**: Each namespace should cover a single feature or domain.
|
||||
|
||||
```
|
||||
Good:
|
||||
- projects.json (project management)
|
||||
- invoices.json (invoicing feature)
|
||||
- notifications.json (notification system)
|
||||
|
||||
Avoid:
|
||||
- misc.json (too vague)
|
||||
- page1.json (not semantic)
|
||||
```
|
||||
|
||||
**Use consistent key naming**:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Page title",
|
||||
"description": "Page description",
|
||||
"actions": {
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"success": "Success!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Avoid duplicating common strings**: Use the `common` namespace for shared strings like "Cancel", "Save", "Loading".
|
||||
|
||||
## 4. Translate Email Templates
|
||||
|
||||
Email templates have their own translation system in `packages/email-templates/src/locales/`.
|
||||
|
||||
### Email Translation Structure
|
||||
|
||||
```
|
||||
packages/email-templates/src/locales/
|
||||
└── en/
|
||||
├── account-delete-email.json
|
||||
├── invite-email.json
|
||||
└── otp-email.json
|
||||
```
|
||||
|
||||
### Add Email Translations for a New Language
|
||||
|
||||
#### Create the language folder:
|
||||
|
||||
```bash
|
||||
mkdir packages/email-templates/src/locales/es
|
||||
```
|
||||
|
||||
#### Copy and translate the email files:
|
||||
|
||||
```bash
|
||||
cp packages/email-templates/src/locales/en/*.json packages/email-templates/src/locales/es/
|
||||
```
|
||||
|
||||
#### Translate the content:
|
||||
|
||||
```json title="packages/email-templates/src/locales/es/invite-email.json"
|
||||
{
|
||||
"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}."
|
||||
}
|
||||
```
|
||||
|
||||
Email templates support interpolation with `{variable}` syntax and basic HTML tags.
|
||||
|
||||
## Organizing Large Translation Files
|
||||
|
||||
For applications with many translations, consider splitting by feature:
|
||||
|
||||
```
|
||||
apps/web/i18n/messages/en/
|
||||
├── common.json # 50-100 keys max
|
||||
├── auth.json
|
||||
├── account.json
|
||||
├── billing/
|
||||
│ ├── subscriptions.json
|
||||
│ ├── invoices.json
|
||||
│ └── checkout.json
|
||||
└── features/
|
||||
├── projects.json
|
||||
├── analytics.json
|
||||
└── integrations.json
|
||||
```
|
||||
|
||||
Update your namespace registration accordingly:
|
||||
|
||||
```tsx
|
||||
const namespaces = [
|
||||
'common',
|
||||
'auth',
|
||||
'account',
|
||||
'billing/subscriptions',
|
||||
'billing/invoices',
|
||||
'features/projects',
|
||||
];
|
||||
```
|
||||
|
||||
## Translation Workflow Tips
|
||||
|
||||
### Use Placeholders During Development
|
||||
|
||||
When adding new features, start with English placeholders:
|
||||
|
||||
```json
|
||||
{
|
||||
"newFeature": "[TODO] New feature title",
|
||||
"newFeatureDescription": "[TODO] Description of the new feature"
|
||||
}
|
||||
```
|
||||
|
||||
This makes untranslated strings visible and searchable.
|
||||
|
||||
### Maintain Translation Parity
|
||||
|
||||
Keep all language files in sync. When adding a key to one language, add it to all:
|
||||
|
||||
```bash
|
||||
# Check for missing keys (example script)
|
||||
diff <(jq -r 'keys[]' messages/en/common.json | sort) \
|
||||
<(jq -r 'keys[]' messages/es/common.json | sort)
|
||||
```
|
||||
|
||||
### Consider Translation Services
|
||||
|
||||
For production applications, integrate with translation services:
|
||||
|
||||
- [Crowdin](https://crowdin.com/)
|
||||
- [Lokalise](https://lokalise.com/)
|
||||
- [Phrase](https://phrase.com/)
|
||||
|
||||
These services can:
|
||||
- Sync with your JSON files via CLI or CI/CD
|
||||
- Provide translator interfaces
|
||||
- Handle pluralization rules per language
|
||||
- Track translation coverage
|
||||
|
||||
## RTL Language Support
|
||||
|
||||
For right-to-left languages like Arabic (`ar`) or Hebrew (`he`):
|
||||
|
||||
1. Add the language as normal to `packages/i18n/src/locales.tsx`
|
||||
2. Create a client component to detect the current locale and set the `dir` attribute:
|
||||
|
||||
```tsx title="apps/web/components/rtl-provider.tsx"
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
const rtlLanguages = ['ar', 'he', 'fa', 'ur'];
|
||||
|
||||
export function RtlProvider({ children }: { children: React.ReactNode }) {
|
||||
const locale = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
const isRtl = rtlLanguages.includes(locale);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
return children;
|
||||
}
|
||||
```
|
||||
|
||||
3. Wrap your app with the provider in `RootProviders`:
|
||||
|
||||
```tsx title="apps/web/components/root-providers.tsx"
|
||||
import { RtlProvider } from './rtl-provider';
|
||||
|
||||
export function RootProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RtlProvider>
|
||||
{children}
|
||||
</RtlProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
4. Use Tailwind's RTL utilities (`rtl:` prefix) for layout adjustments:
|
||||
|
||||
```tsx
|
||||
<div className="ml-4 rtl:ml-0 rtl:mr-4">
|
||||
{/* Content flows correctly in both directions */}
|
||||
</div>
|
||||
```
|
||||
|
||||
{% faq
|
||||
title="Frequently Asked Questions"
|
||||
items=[
|
||||
{"question": "How do I verify my translations are working?", "answer": "Navigate to your app with the locale prefix in the URL (e.g., /es/home). If you have the Language Selector component configured, you can also use it in account settings to switch languages."},
|
||||
{"question": "Do I need to translate every single key?", "answer": "No. Missing translations fall back to the default language (usually English). During development, you can translate incrementally. For production, ensure all user-facing strings are translated."},
|
||||
{"question": "Can I use nested folders for namespaces?", "answer": "Yes. Create subfolders like billing/subscriptions.json and register them as 'billing/subscriptions' in the namespaces array in apps/web/i18n/request.ts. The resolver will load from the nested path."},
|
||||
{"question": "How do I handle pluralization in different languages?", "answer": "next-intl uses ICU message format for pluralization. Define plural rules like {count, plural, one {# item} other {# items}}. ICU format automatically handles language-specific plural rules (e.g., Russian's complex plural categories)."},
|
||||
{"question": "Should translation files be committed to git?", "answer": "Yes, translation JSON files should be version controlled. If using a translation management service, configure it to sync with your repository via pull requests."}
|
||||
]
|
||||
/%}
|
||||
|
||||
## Upgrading from v2
|
||||
|
||||
{% callout title="Differences with v2" %}
|
||||
In v2, Makerkit used `i18next` for translations. In v3, the system uses `next-intl`. Key differences:
|
||||
|
||||
- Translation files moved from `apps/web/public/locales/{locale}/` to `apps/web/i18n/messages/{locale}/`
|
||||
- Language settings moved from `apps/web/lib/i18n/i18n.settings.ts` to `packages/i18n/src/locales.tsx`
|
||||
- Namespace registration moved from `defaultI18nNamespaces` in `i18n.settings.ts` to `namespaces` in `apps/web/i18n/request.ts`
|
||||
- Translation keys use dot notation (`namespace.key`) instead of colon notation (`namespace:key`)
|
||||
- Interpolation uses single braces (`{var}`) instead of double braces (`{{var}}`)
|
||||
- Pluralization uses ICU format instead of i18next `_one`/`_other` suffixes
|
||||
|
||||
For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration).
|
||||
{% /callout %}
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Using Translations](/docs/next-supabase-turbo/translations/using-translations) - Use translations in your components
|
||||
- [Language Selector](/docs/next-supabase-turbo/translations/language-selector) - Add a language switcher
|
||||
- [Email Translations](/docs/next-supabase-turbo/translations/email-translations) - Translate email templates
|
||||
430
docs/translations/email-translations.mdoc
Normal file
430
docs/translations/email-translations.mdoc
Normal file
@@ -0,0 +1,430 @@
|
||||
---
|
||||
status: "published"
|
||||
title: "Email Template Translations | Next.js Supabase SaaS Kit"
|
||||
label: "Email Translations"
|
||||
description: "Learn how to translate email templates for team invitations, account deletion, and OTP verification in your multi-language SaaS application."
|
||||
order: 3
|
||||
---
|
||||
|
||||
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:
|
||||
|
||||
```json title="packages/email-templates/src/locales/en/invite-email.json"
|
||||
{
|
||||
"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:
|
||||
|
||||
```json title="packages/email-templates/src/locales/en/account-delete-email.json"
|
||||
{
|
||||
"subject": "We have deleted your {productName} account",
|
||||
"previewText": "We have deleted your {productName} account",
|
||||
"hello": "Hello {displayName},",
|
||||
"paragraph1": "This is to confirm that we have processed your request to delete your account with {productName}.",
|
||||
"paragraph2": "We're sorry to see you go. Please note that this action is irreversible, and we'll make sure to delete all of your data from our systems.",
|
||||
"paragraph3": "We thank you again for using {productName}.",
|
||||
"paragraph4": "The {productName} Team"
|
||||
}
|
||||
```
|
||||
|
||||
### OTP Verification Email
|
||||
|
||||
Sent for one-time password verification:
|
||||
|
||||
```json title="packages/email-templates/src/locales/en/otp-email.json"
|
||||
{
|
||||
"subject": "One-time password for {productName}",
|
||||
"heading": "One-time password for {productName}",
|
||||
"otpText": "Your one-time password is: {otp}",
|
||||
"mainText": "You're receiving this email because you need to verify your identity using a one-time password.",
|
||||
"footerText": "Please enter the one-time password in the app to continue."
|
||||
}
|
||||
```
|
||||
|
||||
## Adding Email Translations
|
||||
|
||||
### 1. Create Language Folder
|
||||
|
||||
Create a new folder for your language:
|
||||
|
||||
```bash
|
||||
mkdir packages/email-templates/src/locales/es
|
||||
```
|
||||
|
||||
### 2. Copy and Translate Files
|
||||
|
||||
Copy the English templates:
|
||||
|
||||
```bash
|
||||
cp packages/email-templates/src/locales/en/*.json packages/email-templates/src/locales/es/
|
||||
```
|
||||
|
||||
Translate each file:
|
||||
|
||||
```json title="packages/email-templates/src/locales/es/invite-email.json"
|
||||
{
|
||||
"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:
|
||||
|
||||
```tsx title="packages/email-templates/src/lib/i18n.ts"
|
||||
import type { AbstractIntlMessages } from 'next-intl';
|
||||
import { createTranslator } from 'next-intl';
|
||||
|
||||
export async function initializeEmailI18n(params: {
|
||||
language: string | undefined;
|
||||
namespace: string;
|
||||
}) {
|
||||
const language = params.language ?? 'en';
|
||||
|
||||
try {
|
||||
const messages = (await import(
|
||||
`../locales/${language}/${params.namespace}.json`
|
||||
)) as AbstractIntlMessages;
|
||||
|
||||
const translator = createTranslator({
|
||||
locale: language,
|
||||
messages,
|
||||
});
|
||||
|
||||
const t = translator as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
return { t, language };
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Error loading i18n file: locales/${language}/${params.namespace}.json`,
|
||||
error,
|
||||
);
|
||||
|
||||
const t = (key: string) => key;
|
||||
return { t, language };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 `'en'` if no language specified
|
||||
|
||||
## Sending Translated Emails
|
||||
|
||||
When sending emails, pass the recipient's preferred language:
|
||||
|
||||
```tsx title="apps/web/lib/server/send-invite-email.ts"
|
||||
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:
|
||||
|
||||
```tsx
|
||||
// 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:
|
||||
|
||||
```tsx title="apps/web/lib/server/invite-member.ts"
|
||||
'use server';
|
||||
|
||||
import { getLocale } from 'next-intl/server';
|
||||
|
||||
export async function inviteMember(email: string) {
|
||||
const currentLanguage = await getLocale();
|
||||
|
||||
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
|
||||
|
||||
```json title="packages/email-templates/src/locales/en/welcome-email.json"
|
||||
{
|
||||
"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
|
||||
|
||||
```tsx title="packages/email-templates/src/emails/welcome.email.tsx"
|
||||
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('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('heading')}</Heading>
|
||||
|
||||
<Text>{t('hello', { userName: props.userName })}</Text>
|
||||
|
||||
<Text>
|
||||
{t('mainText', { productName: props.productName })}
|
||||
</Text>
|
||||
|
||||
<Section>
|
||||
<Button href={props.dashboardUrl}>
|
||||
{t('getStarted')}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text>{t('helpText')}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
return { html, subject };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Export from Package
|
||||
|
||||
```tsx title="packages/email-templates/src/index.ts"
|
||||
export * from './emails/welcome.email';
|
||||
```
|
||||
|
||||
## Interpolation in Email Translations
|
||||
|
||||
Email translations support the same interpolation syntax as the main application:
|
||||
|
||||
### Simple Variables
|
||||
|
||||
```json
|
||||
{
|
||||
"hello": "Hello {userName},"
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Tags
|
||||
|
||||
You can use basic HTML for formatting:
|
||||
|
||||
```json
|
||||
{
|
||||
"mainText": "<strong>{inviter}</strong> has invited you to join <strong>{teamName}</strong>."
|
||||
}
|
||||
```
|
||||
|
||||
The email template must render this content appropriately:
|
||||
|
||||
```tsx
|
||||
<Text dangerouslySetInnerHTML={{ __html: t('mainText', { inviter, teamName }) }} />
|
||||
```
|
||||
|
||||
{% callout type="warning" title="Security Warning" %}
|
||||
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.
|
||||
{% /callout %}
|
||||
|
||||
## Testing Email Translations
|
||||
|
||||
### Preview in Development
|
||||
|
||||
Use the email preview feature to test translations:
|
||||
|
||||
```bash
|
||||
# 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:web: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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```tsx
|
||||
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).
|
||||
|
||||
{% faq
|
||||
title="Frequently Asked Questions"
|
||||
items=[
|
||||
{"question": "How do I preview email translations locally?", "answer": "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."},
|
||||
{"question": "Can I use the same translations for app and email?", "answer": "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."},
|
||||
{"question": "How do I add a new email template with translations?", "answer": "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."},
|
||||
{"question": "Do email translations support pluralization?", "answer": "Yes, email translations use next-intl's createTranslator which supports ICU message format for pluralization."},
|
||||
{"question": "How do I comply with email regulations (CAN-SPAM, GDPR)?", "answer": "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."}
|
||||
]
|
||||
/%}
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Using Translations](/docs/next-supabase-turbo/translations/using-translations) - Translation basics
|
||||
- [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) - Add new languages
|
||||
- [Language Selector](/docs/next-supabase-turbo/translations/language-selector) - Let users change language
|
||||
- [Sending Emails](/docs/next-supabase-turbo/emails/sending-emails) - Email sending configuration
|
||||
363
docs/translations/language-selector.mdoc
Normal file
363
docs/translations/language-selector.mdoc
Normal file
@@ -0,0 +1,363 @@
|
||||
---
|
||||
status: "published"
|
||||
title: "Language Selector Component | Next.js Supabase SaaS Kit"
|
||||
label: "Language Selector"
|
||||
description: "Learn how to add and customize the language selector component to let users switch languages in your application."
|
||||
order: 2
|
||||
---
|
||||
|
||||
The `LanguageSelector` component lets users switch between available languages. It automatically displays all languages registered in your i18n settings.
|
||||
|
||||
## Using the Language Selector
|
||||
|
||||
Import and render the component anywhere in your application:
|
||||
|
||||
```tsx
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Language Settings</h2>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The component:
|
||||
- Reads available languages from your i18n configuration
|
||||
- Displays language names in the user's current language using `Intl.DisplayNames`
|
||||
- Navigates to the equivalent URL with the new locale prefix
|
||||
- The new locale is persisted via URL-based routing
|
||||
|
||||
## Default Placement
|
||||
|
||||
The language selector is already included in the personal account settings page when more than one language is configured. You'll find it at:
|
||||
|
||||
```
|
||||
/home/settings → Account Settings → Language
|
||||
```
|
||||
|
||||
If only one locale is registered in `packages/i18n/src/locales.tsx`, the selector is hidden automatically.
|
||||
|
||||
## Adding to Other Locations
|
||||
|
||||
### Marketing Header
|
||||
|
||||
Add the selector to your marketing site header:
|
||||
|
||||
```tsx title="apps/web/app/[locale]/(marketing)/_components/site-header.tsx"
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
import { routing } from '@kit/i18n/routing';
|
||||
|
||||
export function SiteHeader() {
|
||||
const showLanguageSelector = routing.locales.length > 1;
|
||||
|
||||
return (
|
||||
<header>
|
||||
<nav>
|
||||
{/* Navigation items */}
|
||||
</nav>
|
||||
|
||||
{showLanguageSelector && (
|
||||
<LanguageSelector />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Footer
|
||||
|
||||
Add language selection to your footer:
|
||||
|
||||
```tsx title="apps/web/app/[locale]/(marketing)/_components/site-footer.tsx"
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
import { routing } from '@kit/i18n/routing';
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer>
|
||||
<div>
|
||||
{/* Footer content */}
|
||||
</div>
|
||||
|
||||
{routing.locales.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Language:</span>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Sidebar
|
||||
|
||||
Include in the application sidebar:
|
||||
|
||||
```tsx title="apps/web/components/sidebar.tsx"
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
import { routing } from '@kit/i18n/routing';
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside>
|
||||
{/* Sidebar navigation */}
|
||||
|
||||
<div className="mt-auto p-4">
|
||||
{routing.locales.length > 1 && (
|
||||
<LanguageSelector />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Handling Language Changes
|
||||
|
||||
The `onChange` prop lets you run custom logic when the language changes:
|
||||
|
||||
```tsx
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const handleLanguageChange = (locale: string) => {
|
||||
// Track analytics
|
||||
analytics.track('language_changed', { locale });
|
||||
|
||||
// Update user preferences in database
|
||||
updateUserPreferences({ language: locale });
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageSelector onChange={handleLanguageChange} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `onChange` callback fires before navigation, so keep it synchronous or use a fire-and-forget pattern for async operations.
|
||||
|
||||
## How Language Detection Works
|
||||
|
||||
The system uses URL-based locale routing powered by `next-intl` middleware.
|
||||
|
||||
### 1. URL Prefix
|
||||
|
||||
The locale is determined by the URL path prefix:
|
||||
|
||||
```
|
||||
/en/home → English
|
||||
/es/home → Spanish
|
||||
/de/home → German
|
||||
```
|
||||
|
||||
### 2. Browser Preference (New Visitors)
|
||||
|
||||
When a user visits the root URL (`/`), the middleware checks the browser's `Accept-Language` header and redirects to the matching locale:
|
||||
|
||||
```
|
||||
User visits / → Accept-Language: es → Redirect to /es/
|
||||
```
|
||||
|
||||
### 3. Default Fallback
|
||||
|
||||
If no matching locale is found, the system redirects to `NEXT_PUBLIC_DEFAULT_LOCALE`:
|
||||
|
||||
```bash title=".env"
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE=en
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Adding Locales
|
||||
|
||||
Register supported locales in your configuration:
|
||||
|
||||
```tsx title="packages/i18n/src/locales.tsx"
|
||||
export const locales: string[] = ['en', 'es', 'de', 'fr'];
|
||||
```
|
||||
|
||||
When only one locale is registered, the language selector is hidden automatically.
|
||||
|
||||
## Styling the Selector
|
||||
|
||||
The `LanguageSelector` uses Shadcn UI's `Select` component. Customize it through your Tailwind configuration or by wrapping it:
|
||||
|
||||
```tsx title="components/custom-language-selector.tsx"
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
export function CustomLanguageSelector() {
|
||||
return (
|
||||
<div className="[&_button]:w-[180px] [&_button]:bg-muted">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For deeper customization, you can create your own selector using `next-intl` navigation utilities:
|
||||
|
||||
```tsx title="components/language-dropdown.tsx"
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useRouter, usePathname } from '@kit/i18n/navigation';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
import { routing } from '@kit/i18n/routing';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
export function LanguageDropdown() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const languageNames = useMemo(() => {
|
||||
return new Intl.DisplayNames([locale], { type: 'language' });
|
||||
}, [locale]);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(newLocale: string) => {
|
||||
router.replace(pathname, { locale: newLocale });
|
||||
},
|
||||
[router, pathname],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Globe className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
{routing.locales.map((loc) => {
|
||||
const label = languageNames.of(loc) ?? loc;
|
||||
const isActive = loc === locale;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onClick={() => handleLanguageChange(loc)}
|
||||
className={isActive ? 'bg-accent' : ''}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## SEO Considerations
|
||||
|
||||
With URL-based locale routing, each language variant has its own URL, which is optimal for SEO. The `next-intl` middleware automatically handles `hreflang` alternate links.
|
||||
|
||||
For additional control, you can add explicit alternates in your metadata:
|
||||
|
||||
```tsx title="apps/web/app/[locale]/layout.tsx"
|
||||
import { routing } from '@kit/i18n/routing';
|
||||
|
||||
export function generateMetadata() {
|
||||
const baseUrl = 'https://yoursite.com';
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
routing.locales.map((lang) => [lang, `${baseUrl}/${lang}`])
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Language Switching
|
||||
|
||||
To test language switching during development:
|
||||
|
||||
1. **URL method**:
|
||||
- Navigate directly to a URL with the locale prefix (e.g., `/es/home`)
|
||||
- Verify translations appear correctly
|
||||
2. **Component method**:
|
||||
- Navigate to account settings or wherever you placed the selector
|
||||
- Select a different language
|
||||
- Verify the URL updates with the new locale prefix and translations change
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
The default `LanguageSelector` uses Shadcn UI's `Select` component which provides:
|
||||
|
||||
- Keyboard navigation (arrow keys, Enter, Escape)
|
||||
- Screen reader announcements
|
||||
- Focus management
|
||||
|
||||
When creating custom language selectors, ensure you include:
|
||||
|
||||
```tsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Change language"
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Current language: {languageNames.of(locale)}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* ... */}
|
||||
</DropdownMenu>
|
||||
```
|
||||
|
||||
{% faq
|
||||
title="Frequently Asked Questions"
|
||||
items=[
|
||||
{"question": "Why does the page navigate when I change the language?", "answer": "Language switching works via URL-based routing. When you select a new language, the app navigates to the equivalent URL with the new locale prefix (e.g., /en/home to /es/home). This ensures all components render with the correct translations."},
|
||||
{"question": "Can I change the language without navigation?", "answer": "URL-based locale routing requires navigation since the locale is part of the URL. This is the recommended approach as it provides better SEO, shareable URLs per language, and proper server-side rendering of translations."},
|
||||
{"question": "How do I hide the language selector for single-language apps?", "answer": "The selector automatically hides when only one locale is in the locales array. You can also conditionally render it: {routing.locales.length > 1 && <LanguageSelector />}"},
|
||||
{"question": "Can I save language preference to the user's profile?", "answer": "Yes. Use the onChange prop to save to your database when the language changes. On future visits, you can redirect users to their preferred locale server-side in middleware."},
|
||||
{"question": "Does the language selector work with URL-based routing?", "answer": "Yes, v3 uses URL-based locale routing natively via next-intl middleware. Each locale has its own URL prefix (e.g., /en/about, /es/about). The language selector navigates between these URLs automatically."}
|
||||
]
|
||||
/%}
|
||||
|
||||
## Upgrading from v2
|
||||
|
||||
{% callout title="Differences with v2" %}
|
||||
In v2, language switching was cookie-based — changing language set a `lang` cookie and reloaded the page. In v3, language switching uses URL-based routing via `next-intl` middleware. Key differences:
|
||||
|
||||
- Locale is determined by URL prefix (`/en/`, `/es/`) instead of a `lang` cookie
|
||||
- Language change navigates to a new URL instead of `i18n.changeLanguage()` + reload
|
||||
- `languages` from `~/lib/i18n/i18n.settings` is now `routing.locales` from `@kit/i18n/routing`
|
||||
- `useTranslation` from `react-i18next` is now `useTranslations`/`useLocale` from `next-intl`
|
||||
- No custom middleware needed — `next-intl` provides URL-based routing natively
|
||||
|
||||
For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration).
|
||||
{% /callout %}
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Using Translations](/docs/next-supabase-turbo/translations/using-translations) - Learn how to use translations
|
||||
- [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) - Add new languages
|
||||
- [Email Translations](/docs/next-supabase-turbo/translations/email-translations) - Translate email templates
|
||||
386
docs/translations/using-translations.mdoc
Normal file
386
docs/translations/using-translations.mdoc
Normal file
@@ -0,0 +1,386 @@
|
||||
---
|
||||
status: "published"
|
||||
title: "Using translations in your Next.js Supabase project"
|
||||
label: "Using translations"
|
||||
description: "Learn how to use translations in Server Components, Client Components, and Server Actions with Makerkit's next-intl-based translation system."
|
||||
order: 0
|
||||
---
|
||||
|
||||
Makerkit uses `next-intl` for internationalization, abstracted behind the `@kit/i18n` package. This abstraction ensures future changes to the translation library won't break your code.
|
||||
|
||||
{% sequence title="Steps to use translations" description="Learn how to use translations in your Next.js Supabase project." %}
|
||||
|
||||
[Understand the translation architecture](#translation-architecture)
|
||||
|
||||
[Use translations in Server Components](#using-translations-in-server-components)
|
||||
|
||||
[Use translations in Client Components](#using-translations-in-client-components)
|
||||
|
||||
[Work with translation keys and namespaces](#working-with-translation-keys)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
## Translation Architecture
|
||||
|
||||
The translation system supports:
|
||||
|
||||
1. **Server Components (RSC)** - Access translations via `getTranslations` from `next-intl/server`
|
||||
2. **Client Components** - Access translations via `useTranslations` from `next-intl`
|
||||
3. **URL-based locale routing** - Locale is determined by the URL prefix (e.g., `/en/home`, `/es/home`)
|
||||
|
||||
Translation files are stored in `apps/web/i18n/messages/{locale}/`. The default structure includes:
|
||||
|
||||
```
|
||||
apps/web/i18n/messages/
|
||||
└── en/
|
||||
├── common.json # Shared UI strings
|
||||
├── auth.json # Authentication flows
|
||||
├── account.json # Account settings
|
||||
├── teams.json # Team management
|
||||
├── billing.json # Billing and subscriptions
|
||||
└── marketing.json # Marketing pages
|
||||
```
|
||||
|
||||
## Using Translations in Server Components
|
||||
|
||||
Server Components can access translations directly using `getTranslations` from `next-intl/server`.
|
||||
|
||||
### Using getTranslations
|
||||
|
||||
```tsx title="apps/web/app/[locale]/home/page.tsx"
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations('common');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('homeTabLabel')}</h1>
|
||||
<p>{t('homeTabDescription')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Trans Component
|
||||
|
||||
The `Trans` component renders translated strings directly in JSX:
|
||||
|
||||
```tsx title="apps/web/app/[locale]/home/page.tsx"
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>
|
||||
<Trans i18nKey="common.homeTabLabel" />
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<Trans i18nKey="common.homeTabDescription" />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Import the `Trans` component from `@kit/ui/trans`** - the Makerkit wrapper handles server/client differences.
|
||||
|
||||
### Using Translations in Metadata
|
||||
|
||||
For page metadata, use `getTranslations` directly:
|
||||
|
||||
```tsx title="apps/web/app/[locale]/home/page.tsx"
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations('common');
|
||||
|
||||
return {
|
||||
title: t('homeTabLabel'),
|
||||
};
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Trans i18nKey="common.homeTabLabel" />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using Translations in Client Components
|
||||
|
||||
Client Components receive translations through the `NextIntlClientProvider` in the root layout.
|
||||
|
||||
### Using the useTranslations Hook
|
||||
|
||||
The `useTranslations` hook provides access to the translation function:
|
||||
|
||||
```tsx title="components/my-component.tsx"
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function MyComponent() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<button onClick={() => alert(t('common.cancel'))}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Specifying Namespaces
|
||||
|
||||
Load specific namespaces for scoped access:
|
||||
|
||||
```tsx title="components/billing-component.tsx"
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function BillingComponent() {
|
||||
const t = useTranslations('billing');
|
||||
|
||||
// Keys without namespace prefix
|
||||
return <span>{t('subscriptionSettingsTabLabel')}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
### Using Trans in Client Components
|
||||
|
||||
The `Trans` component also works in Client Components:
|
||||
|
||||
```tsx title="components/welcome-message.tsx"
|
||||
'use client';
|
||||
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function WelcomeMessage() {
|
||||
return (
|
||||
<p>
|
||||
<Trans i18nKey="common.signedInAs" />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Working with Translation Keys
|
||||
|
||||
### Key Format
|
||||
|
||||
Translation keys use dot notation `namespace.keyPath`:
|
||||
|
||||
```tsx
|
||||
// Simple key
|
||||
<Trans i18nKey="common.cancel" />
|
||||
|
||||
// Nested key
|
||||
<Trans i18nKey="common.routes.home" />
|
||||
|
||||
// With namespace in useTranslations
|
||||
const t = useTranslations('auth');
|
||||
t('signIn'); // Equivalent to 'auth.signIn'
|
||||
```
|
||||
|
||||
### Interpolation
|
||||
|
||||
Pass dynamic values to translations using single braces:
|
||||
|
||||
```json title="apps/web/i18n/messages/en/common.json"
|
||||
{
|
||||
"pageOfPages": "Page {page} of {total}",
|
||||
"showingRecordCount": "Showing {pageSize} of {totalCount} rows"
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
// Using Trans component
|
||||
<Trans
|
||||
i18nKey="common.pageOfPages"
|
||||
values={{ page: 1, total: 10 }}
|
||||
/>
|
||||
|
||||
// Using t function
|
||||
const t = useTranslations();
|
||||
t('common.showingRecordCount', { pageSize: 25, totalCount: 100 });
|
||||
```
|
||||
|
||||
### Nested Translations
|
||||
|
||||
Access nested objects with dot notation:
|
||||
|
||||
```json title="apps/web/i18n/messages/en/common.json"
|
||||
{
|
||||
"routes": {
|
||||
"home": "Home",
|
||||
"account": "Account",
|
||||
"billing": "Billing"
|
||||
},
|
||||
"roles": {
|
||||
"owner": {
|
||||
"label": "Owner"
|
||||
},
|
||||
"member": {
|
||||
"label": "Member"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Trans i18nKey="common.routes.home" />
|
||||
<Trans i18nKey="common.roles.owner.label" />
|
||||
```
|
||||
|
||||
### HTML in Translations
|
||||
|
||||
For translations containing HTML, use the `Trans` component with components prop:
|
||||
|
||||
```json title="apps/web/i18n/messages/en/auth.json"
|
||||
{
|
||||
"clickToAcceptAs": "Click the button below to accept the invite as <b>{email}</b>"
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Trans
|
||||
i18nKey="auth.clickToAcceptAs"
|
||||
values={{ email: user.email }}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Conditional Translations
|
||||
|
||||
```tsx
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
|
||||
const t = useTranslations();
|
||||
const locale = useLocale();
|
||||
|
||||
// Check current language
|
||||
if (locale === 'en') {
|
||||
// English-specific logic
|
||||
}
|
||||
|
||||
// Translate with values
|
||||
const label = t('optional.key', { name: 'World' });
|
||||
```
|
||||
|
||||
### Pluralization
|
||||
|
||||
next-intl uses ICU message format for pluralization:
|
||||
|
||||
```json title="apps/web/i18n/messages/en/common.json"
|
||||
{
|
||||
"itemCount": "{count, plural, one {# item} other {# items}}"
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
t('common.itemCount', { count: 1 }); // "1 item"
|
||||
t('common.itemCount', { count: 5 }); // "5 items"
|
||||
```
|
||||
|
||||
### Date and Number Formatting
|
||||
|
||||
Use the standard `Intl` APIs alongside translations:
|
||||
|
||||
```tsx
|
||||
const locale = useLocale();
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat(locale).format(date);
|
||||
const formattedNumber = new Intl.NumberFormat(locale).format(1234.56);
|
||||
```
|
||||
|
||||
## Server Actions
|
||||
|
||||
For Server Actions, use `getTranslations` from `next-intl/server`:
|
||||
|
||||
```tsx title="apps/web/lib/server/actions.ts"
|
||||
'use server';
|
||||
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export async function myServerAction() {
|
||||
const t = await getTranslations('common');
|
||||
|
||||
// Use translations
|
||||
const message = t('genericServerError');
|
||||
|
||||
return { error: message };
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure language behavior with these environment variables:
|
||||
|
||||
```bash title=".env"
|
||||
# Default language (fallback when user preference unavailable)
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE=en
|
||||
```
|
||||
|
||||
The locale is determined by the URL prefix (e.g., `/en/`, `/es/`). When a user visits the root URL, they are redirected to their preferred locale based on:
|
||||
|
||||
1. The browser's `Accept-Language` header
|
||||
2. Falls back to `NEXT_PUBLIC_DEFAULT_LOCALE`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Translation Warning
|
||||
|
||||
If you see a missing translation warning, check:
|
||||
|
||||
1. The key exists in your translation file
|
||||
2. All interpolation values are provided
|
||||
3. The namespace is registered in `apps/web/i18n/request.ts`
|
||||
|
||||
### Translations Not Updating
|
||||
|
||||
If translations don't update after editing JSON files:
|
||||
|
||||
1. Restart the development server
|
||||
2. Clear browser cache
|
||||
3. Check for JSON syntax errors in translation files
|
||||
|
||||
{% faq
|
||||
title="Frequently Asked Questions"
|
||||
items=[
|
||||
{"question": "How do I switch languages programmatically?", "answer": "Use router.replace() with the new locale from @kit/i18n/navigation. The locale is part of the URL path (e.g., /en/ to /es/), so changing language means navigating to the equivalent URL with a different locale prefix."},
|
||||
{"question": "Why are my translations not showing?", "answer": "Check that the namespace is registered in the namespaces array in apps/web/i18n/request.ts, the JSON file exists in apps/web/i18n/messages/{locale}/, and verify the key uses dot notation (namespace.key not namespace:key)."},
|
||||
{"question": "Can I use translations in Server Actions?", "answer": "Yes, import getTranslations from next-intl/server and call it at the start of your server action. Then use the returned t() function for translations."},
|
||||
{"question": "What's the difference between Trans component and useTranslations hook?", "answer": "Trans is a React component that renders translated strings directly in JSX, supporting interpolation and HTML. useTranslations is a hook that returns a t() function for programmatic access to translations, useful for attributes, conditionals, or non-JSX contexts."},
|
||||
{"question": "How do I handle missing translations during development?", "answer": "Missing translations log warnings to the console. Use [TODO] prefixes in your JSON values to make untranslated strings searchable. The system falls back to the key name if no translation is found."}
|
||||
]
|
||||
/%}
|
||||
|
||||
## Upgrading from v2
|
||||
|
||||
{% callout title="Differences with v2" %}
|
||||
In v2, Makerkit used `i18next` and `react-i18next` for internationalization. In v3, the system uses `next-intl`. Key differences:
|
||||
|
||||
- Translation keys use dot notation (`namespace.key`) instead of colon notation (`namespace:key`)
|
||||
- Interpolation uses single braces (`{var}`) instead of double braces (`{{var}}`)
|
||||
- Server components use `getTranslations` from `next-intl/server` instead of `withI18n` HOC and `createI18nServerInstance`
|
||||
- Client components use `useTranslations` from `next-intl` instead of `useTranslation` from `react-i18next`
|
||||
- Translation files are in `apps/web/i18n/messages/{locale}/` instead of `apps/web/public/locales/{locale}/`
|
||||
- Pluralization uses ICU format (`{count, plural, one {# item} other {# items}}`) instead of i18next `_one`/`_other` suffixes
|
||||
- Locale is determined by URL prefix, not cookies
|
||||
|
||||
For the full migration guide, see [Upgrading from v2 to v3](/docs/next-supabase-turbo/installation/v3-migration).
|
||||
{% /callout %}
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Adding Translations](/docs/next-supabase-turbo/translations/adding-translations) - Add new languages and namespaces
|
||||
- [Language Selector](/docs/next-supabase-turbo/translations/language-selector) - Let users change their language
|
||||
- [Email Translations](/docs/next-supabase-turbo/translations/email-translations) - Translate email templates
|
||||
Reference in New Issue
Block a user