Using Translations

How to use translations in Server Components, Client Components, page metadata, and with rich text interpolation using the Trans component.

MakerKit uses next-intl for translations. Use getTranslations() in Server Components (async, returns the t function) and useTranslations() hook in Client Components. For rich text with embedded links or formatting, use the Trans component from @kit/ui/trans.

The translation APIs differ between Server and Client Components, but translation keys and JSON file patterns are the same across both.

Server Components

In React Server Components (the default in the App Router), use getTranslations:

import { getTranslations } from 'next-intl/server';
async function SettingsPage() {
const t = await getTranslations('settings');
return (
<div>
<h1>{t('pageTitle')}</h1>
<p>{t('pageDescription')}</p>
</div>
);
}

The namespace ('settings') maps to apps/web/i18n/messages/{locale}/settings.json.

Multiple Namespaces

Load multiple namespaces when needed:

async function DashboardPage() {
const [tCommon, tBilling] = await Promise.all([
getTranslations('common'),
getTranslations('billing'),
]);
return (
<div>
<h1>{tCommon('dashboardTabLabel')}</h1>
<p>{tBilling('currentPlanLabel')}</p>
</div>
);
}

Nested Keys

Access nested translations with dot notation:

const t = await getTranslations('common');
// Access common.routes.dashboard
t('routes.dashboard'); // "Dashboard"
// Or scope to the nested object
const tRoutes = await getTranslations('common.routes');
tRoutes('dashboard'); // "Dashboard"

Given this JSON structure:

{
"routes": {
"account": "Account",
"members": "Members",
"billing": "Billing",
"dashboard": "Dashboard"
}
}

Page Metadata

Use getTranslations in generateMetadata:

import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('marketing');
return {
title: t('pageTitle'),
description: t('pageDescription'),
};
}

Client Components

In Client Components, use the useTranslations hook:

'use client';
import { useTranslations } from 'next-intl';
function CreateOrganizationDialog() {
const t = useTranslations('organizations');
return (
<Dialog>
<DialogTitle>{t('createOrganizationDialogHeading')}</DialogTitle>
<DialogDescription>
{t('createOrganizationDialogDescription')}
</DialogDescription>
<Button>{t('createButton')}</Button>
</Dialog>
);
}

The hook reads from the context provided by I18nClientProvider in the root layout.

The Trans Component

For complex translations with JSX interpolation, use the Trans component from @kit/ui/trans:

import { Trans } from '@kit/ui/trans';
// Basic usage
<Trans i18nKey="auth.welcomeMessage" />
// With interpolation values
<Trans
i18nKey="common.greeting"
values={{ name: user.name }}
/>
// With component interpolation for rich text
<Trans
i18nKey="auth.acceptTermsAndConditions"
components={{
TermsOfServiceLink: <a href="/terms" className="underline" />,
PrivacyPolicyLink: <a href="/privacy" className="underline" />,
}}
/>

The translation key uses dot notation: namespace.key.

Rich Text Example

Given this translation:

{
"acceptTermsAndConditions": "I accept the <TermsOfServiceLink>Terms of Service</TermsOfServiceLink> and <PrivacyPolicyLink>Privacy Policy</PrivacyPolicyLink>"
}

The Trans component renders the links correctly:

<Trans
i18nKey="auth.acceptTermsAndConditions"
components={{
TermsOfServiceLink: <a href="/legal/terms" className="underline" />,
PrivacyPolicyLink: <a href="/legal/privacy" className="underline" />,
}}
/>

Output: I accept the Terms of Service and Privacy Policy

Trans Component Props

PropTypeDescription
i18nKeystringTranslation key with namespace (e.g., 'auth.signIn')
valuesRecord<string, unknown>Values for interpolation
componentsRecord<string, ReactElement>Components for rich text tags
defaultsReactNodeFallback if translation is missing
nsstringOverride namespace (optional)

Translation Patterns

Simple Keys

{
"signUp": "Sign Up",
"signIn": "Sign In",
"forgotPassword": "Forgot Password?"
}
const t = useTranslations('auth');
t('signUp'); // "Sign Up"

Nested Keys

Organize related translations:

{
"otp": {
"requestVerificationCode": "Request Verification Code",
"sendVerificationCode": "Send Verification Code",
"enterVerificationCode": "Enter Verification Code",
"errors": {
"invalidEmail": "Please enter a valid email address",
"codeLength": "Code must be exactly 6 digits"
}
}
}
const t = useTranslations('common');
t('otp.requestVerificationCode'); // "Request Verification Code"
t('otp.errors.invalidEmail'); // "Please enter a valid email address"

Interpolation

Insert dynamic values:

{
"greeting": "Hello, {name}!",
"itemCount": "You have {count} items",
"getStartedWithPlan": "Get Started with {plan}"
}
t('greeting', { name: 'John' }); // "Hello, John!"
t('itemCount', { count: 5 }); // "You have 5 items"
t('getStartedWithPlan', { plan: 'Pro' }); // "Get Started with Pro"

Pluralization

Use ICU message format for count-dependent text:

{
"invitations": "{count} {count, plural, one {invitation} other {invitations}} pending",
"members": "{count, plural, =0 {No members} one {1 member} other {# members}}"
}
t('invitations', { count: 1 }); // "1 invitation pending"
t('invitations', { count: 5 }); // "5 invitations pending"
t('members', { count: 0 }); // "No members"
t('members', { count: 1 }); // "1 member"
t('members', { count: 10 }); // "10 members"

Select (Gender/Category)

Use ICU select for category-based text:

{
"notification": "{type, select, message {New message from {sender}} mention {You were mentioned by {sender}} other {New notification}}"
}
t('notification', { type: 'message', sender: 'Alice' }); // "New message from Alice"
t('notification', { type: 'mention', sender: 'Bob' }); // "You were mentioned by Bob"

Rich Text with Bold/Emphasis

{
"clickToAcceptAs": "Click the button below to accept the invite as <b>{email}</b>"
}
<Trans
i18nKey="auth.clickToAcceptAs"
values={{ email: 'user@example.com' }}
components={{
b: <strong />,
}}
/>

Output: Click the button below to accept the invite as user@example.com

Getting the Current Locale

Server-Side

import { getLocale } from 'next-intl/server';
async function MyServerComponent() {
const locale = await getLocale();
// locale = 'en', 'es', 'fr', etc.
}

Client-Side

'use client';
import { useLocale } from 'next-intl';
function MyClientComponent() {
const locale = useLocale();
// locale = 'en', 'es', 'fr', etc.
}

Locale-Aware Navigation

Use navigation utilities from @kit/i18n/navigation instead of Next.js defaults:

import { Link, redirect, useRouter, usePathname } from '@kit/i18n/navigation';
// Link automatically handles locale prefix
<Link href="/settings">Settings</Link>
// Renders as /settings (default locale) or /es/settings (Spanish)
// Redirect with locale awareness
redirect('/dashboard');
// Client-side navigation
const router = useRouter();
router.push('/settings');
// Switch locale explicitly
router.replace('/settings', { locale: 'es' });
// Get pathname without locale prefix
const pathname = usePathname();
// Returns '/dashboard' not '/es/dashboard'

Date and Time Formatting

Use next-intl formatters or native Intl APIs for locale-aware formatting:

import { getFormatter, getLocale } from 'next-intl/server';
async function DateDisplay({ date }: { date: Date }) {
const format = await getFormatter();
return (
<time dateTime={date.toISOString()}>
{format.dateTime(date, { dateStyle: 'long' })}
</time>
);
}

Or with native Intl:

import { getLocale } from 'next-intl/server';
async function DateDisplay({ date }: { date: Date }) {
const locale = await getLocale();
const formatted = new Intl.DateTimeFormat(locale, {
dateStyle: 'long',
}).format(date);
return <time dateTime={date.toISOString()}>{formatted}</time>;
}

Number Formatting

import { getFormatter } from 'next-intl/server';
async function PriceDisplay({ amount }: { amount: number }) {
const format = await getFormatter();
return <span>{format.number(amount, { style: 'currency', currency: 'USD' })}</span>;
}

Error Handling

Missing translations fall back to the key name and log a warning in development:

// If 'auth.unknownKey' doesn't exist:
t('unknownKey'); // Returns "unknownKey" and logs warning

Checking if a Key Exists

const t = useTranslations('auth');
if (t.has('specialMessage')) {
return <p>{t('specialMessage')}</p>;
}

Custom Fallback

Use the defaults prop with Trans:

<Trans
i18nKey="feature.newLabel"
defaults="New Feature"
/>

Common Patterns

Conditional Translations

async function StatusBadge({ status }: { status: string }) {
const t = await getTranslations('common');
const statusKey = `status.${status}`;
const label = t.has(statusKey) ? t(statusKey) : status;
return <Badge>{label}</Badge>;
}

Form Validation Messages

'use client';
import { useTranslations } from 'next-intl';
import { z } from 'zod';
function useAuthValidation() {
const t = useTranslations('auth.errors');
return z.object({
password: z
.string()
.min(8, t('minPasswordLength', { minLength: 8 }))
.regex(/[A-Z]/, t('uppercasePassword'))
.regex(/[0-9]/, t('minPasswordNumbers')),
});
}

Translations with HTML Content

<Trans
i18nKey="auth.existingAccountHint"
values={{ methodName: 'Email' }}
components={{
method: <strong />,
signInLink: <Link href="/auth/sign-in" className="underline" />,
}}
/>

For the translation:

{
"existingAccountHint": "You previously signed in with <method>{methodName}</method>. <signInLink>Already have an account?</signInLink>"
}

When to Use Each API

ScenarioUse ThisWhy
Server ComponentgetTranslations()Async, direct access to messages
Client ComponentuseTranslations()Hook-based, reads from context
Rich text with linksTrans componentHandles JSX interpolation safely
Page metadatagetTranslations()Works in generateMetadata()
Form validationuseTranslations()Client-side, returns sync messages
Email templatesinitializeEmailI18n()Works outside React context

Avoid:

  • Using useTranslations in Server Components (it won't work)
  • Using Trans for simple text without formatting (overhead)
  • Hardcoding strings instead of using translation keys

Frequently Asked Questions

What is the difference between getTranslations and useTranslations?
getTranslations is an async function for Server Components that directly loads messages from the server. useTranslations is a React hook for Client Components that reads from the context provided by I18nClientProvider. Use getTranslations in pages and server components, useTranslations in 'use client' components.
When should I use the Trans component?
Use the Trans component from @kit/ui/trans when you need to embed JSX elements like links, bold text, or other components inside translated strings. For simple text without formatting, use the t() function directly as it has less overhead.
How do I handle pluralization in translations?
Use ICU message format in your JSON files. For example: "{count, plural, one {1 item} other {# items}}" will output '1 item' for count=1 and '5 items' for count=5. Pass the count value when calling t('key', { count: 5 }).
Why are my translations showing the key name instead of the translation?
This happens when the translation key doesn't exist in your JSON file. Check that: 1) The namespace matches the filename, 2) The key path is correct (use dot notation for nested keys), 3) The JSON file exists for the current locale. Missing translations log warnings in development.
How do I get the current locale in my component?
In Server Components, use await getLocale() from next-intl/server. In Client Components, use the useLocale() hook from next-intl. Both return the current locale string like 'en' or 'es'.
Can I use translations in generateMetadata?
Yes, use getTranslations in generateMetadata just like in Server Components. It's async, so await it: const t = await getTranslations('marketing'); return { title: t('pageTitle') };

Previous: Setup Guide | Next: Managing Translations