next-intl Translations
Architecture Overview
- •Locales:
en(default),es— defined ini18n/locales.ts - •Messages: Split into 11 domain files per locale under
messages/{locale}/ - •Merge:
i18n/request.tsloads all files viaPromise.alland merges them. Survey keys are deep-merged from 3 files. - •Root layout:
messages={null}onNextIntlClientProvider— zero translation JSON shipped globally - •Scoped providers: Only routes with client-side translation needs get a
NextIntlClientProviderwithpick()
Message Files
messages/ ├── en/ │ ├── common.json (navigation, footer, metadata) │ ├── auth.json (auth) │ ├── home.json (home.hero, reviews) │ ├── servers.json (server, categories, regions, servers) │ ├── creators.json (creators) │ ├── branding.json (branding) │ ├── profile.json (profile, settings) │ ├── submit.json (submit) │ ├── survey-common.json (survey.listing, survey.common) │ ├── survey-player.json (survey.player) │ └── survey-owner.json (survey.owner) └── es/ (mirrors en/ structure exactly)
Rules for Message Files
- •Both locales must have identical key structure — run
npx @lingual/i18n-check@latest --source en --locales messagesto verify - •Each file has unique top-level keys except survey files which share the
surveytop-level key with different sub-keys - •Adding a new domain file requires updating
i18n/request.tsto import and spread it into the merged object - •Never use
experimental.messagesprecompilation — it bypasses the custom deep-merge inrequest.tsand breaks survey translations
Component Patterns
Server Components (preferred)
Use useTranslations from next-intl directly — translations resolve on the server, zero JS shipped.
// No 'use client' directive
import { useTranslations } from 'next-intl';
export function Footer() {
const t = useTranslations('footer');
return <p>{t('copyright', { year: 2026 })}</p>;
}
Client Components — Donut Pattern
When a component needs interactivity AND translations, split into server wrapper + client shell. Pass pre-translated strings as props.
// header.tsx (Server Component — resolves translations)
import { useTranslations } from 'next-intl';
import { HeaderClient } from './header-client';
export function Header() {
const t = useTranslations('navigation');
return <HeaderClient labels={{ signIn: t('signIn'), surveys: t('surveys') }} />;
}
// header-client.tsx (Client Component — receives string props)
'use client';
export function HeaderClient({ labels }: { labels: { signIn: string; surveys: string } }) {
// useState, useAuth, etc. — no useTranslations needed
}
Client Components — Scoped Provider
When client components must call useTranslations themselves (e.g., dynamic survey rendering), wrap with a scoped NextIntlClientProvider in a parent server layout/page.
// app/[locale]/survey/[slug]/layout.tsx
import { pick } from 'es-toolkit';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
export default async function SurveySlugLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const messages = await getMessages({ locale });
return (
<NextIntlClientProvider messages={pick(messages as Record<string, unknown>, ['survey'])}>
{children}
</NextIntlClientProvider>
);
}
Decision Flowchart
- •Component has NO interactivity? → Server Component with
useTranslations - •Component has interactivity but few labels? → Donut pattern (server wrapper passes string props)
- •Component has interactivity AND many dynamic translation keys? → Scoped provider in parent layout/page
Critical Rules
- •Every layout/page in
app/[locale]/must callsetRequestLocale(locale)— required for static rendering - •Always pass
{ locale }explicitly togetMessages()andgetTranslations()in layouts/pages - •Root layout uses
messages={null}— never pass all messages globally - •Use
pick()fromes-toolkitto scope provider messages to only needed keys (e.g.,['survey']) - •Localized pathnames must be configured in BOTH
i18n/routing.tsANDnext.config.tsrewrites — seereferences/pathnames.md
Verification
After any translation change, run:
npx @lingual/i18n-check@latest --source en --locales messages
Expected output: "No missing keys found!" — fix any reported gaps before committing.