You shipped your app. Traffic is growing. Then you check analytics and realize 60% of visitors speak a language other than English. Internationalization isn't a nice-to-have — it's the difference between serving your users and losing them.
I recently scaled PropFirm Key — a Next.js application — from English-only to 10 languages: English, French, Spanish, German, Portuguese, Japanese, Chinese, Korean, Arabic, and Russian. This article covers architecture decisions, pitfalls, and working code.
Why i18n Matters More Than You Think
SEO multiplier. Each locale generates its own indexable URLs. 50 pages in 10 languages = 500 indexable URLs, each competing in regional search results.
Lower bounce rates. Users who land on content in their native language stay longer and convert better.
Why next-intl
| Library | App Router Support | Server Components | Type Safety |
|---|---|---|---|
| next-intl | Full | Native | Excellent |
| next-translate | Partial | Limited | Basic |
| react-intl | Partial | Requires wrappers | Good |
next-intl was built for the App Router from the ground up. It handles Server Components natively and integrates cleanly with Next.js middleware.
Project Structure
src/
app/
[locale]/
layout.tsx
page.tsx
firms/[slug]/page.tsx
messages/
en.json
fr.json
es.json
de.json
pt.json
ja.json
zh.json
ko.json
ar.json
ru.json
i18n/
request.ts
routing.ts
middleware.ts
Every route lives under [locale]/. This gives you /en/about, /fr/about, /ja/about — clean, crawlable, and unambiguous.
Routing Configuration
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'fr', 'es', 'de', 'pt', 'ja', 'zh', 'ko', 'ar', 'ru'],
defaultLocale: 'en',
localePrefix: 'always'
});
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});
Why localePrefix: 'always'
Cookie-based locale detection creates caching nightmares. If your CDN caches /about for an English user, the next French user gets English from cache. With localePrefix: 'always', every locale has a distinct URL. Your CDN caches them independently.
Server Components vs Client Components
Server: getTranslations() — Zero client JavaScript
import { getTranslations } from 'next-intl/server';
export default async function HomePage() {
const t = await getTranslations('home');
return (
<main>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
</main>
);
}
Strings resolved at build/request time, rendered as plain HTML. Zero JS shipped to the client for translations.
Client: useTranslations() — For interactive components
'use client';
import { useTranslations } from 'next-intl';
export function SearchBar() {
const t = useTranslations('search');
return <input type="search" placeholder={t('placeholder')} />;
}
Rule of thumb: use Server Components by default. Only reach for useTranslations() when you need interactivity.
Dynamic Metadata with Translations
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
const t = await getTranslations({ locale, namespace: 'firms' });
return {
title: t('meta.title', { firm: slug }),
description: t('meta.description', { firm: slug }),
alternates: {
canonical: `https://example.com/${locale}/firms/${slug}`,
languages: Object.fromEntries(
['en', 'fr', 'es', 'de', 'pt', 'ja', 'zh', 'ko', 'ar', 'ru'].map(
(l) => [l, `https://example.com/${l}/firms/${slug}`]
)
)
}
};
}
The alternates.languages field generates correct hreflang tags automatically.
Hreflang Tags
export function HreflangTags({ path }: { path: string }) {
const baseUrl = 'https://example.com';
return (
<>
{routing.locales.map((locale) => (
<link key={locale} rel="alternate" hrefLang={locale}
href={`${baseUrl}/${locale}${path}`} />
))}
<link rel="alternate" hrefLang="x-default" href={`${baseUrl}/en${path}`} />
</>
);
}
Common mistakes: forgetting x-default, using zh when Google expects zh-Hans, non-bidirectional hreflang references.
Managing 10 Locale Files
English is always the source of truth. Run a validation script in CI:
// scripts/validate-translations.ts
const enKeys = getKeys(en);
for (const locale of locales) {
const messages = JSON.parse(fs.readFileSync(`src/messages/${locale}.json`, 'utf-8'));
const localeKeys = getKeys(messages);
const missing = enKeys.filter((k) => !localeKeys.includes(k));
const extra = localeKeys.filter((k) => !enKeys.includes(k));
if (missing.length > 0) {
console.error(`[${locale}] Missing keys:`, missing);
process.exitCode = 1;
}
if (extra.length > 0) {
console.warn(`[${locale}] Extra keys (stale?):`, extra);
}
}
function getKeys(obj: Record<string, any>, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object' && value !== null
? getKeys(value, fullKey) : [fullKey];
});
}
AI-Powered Bulk Translation
Translating 200+ keys across 9 locales by hand is a full-time job. I use Gemini API for first-pass translations:
async function translateLocale(
sourceMessages: Record<string, any>,
targetLocale: string
): Promise<Record<string, any>> {
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const prompt = `Translate the following JSON from English to ${targetLocale}.
Rules:
- Preserve ALL JSON keys exactly as-is
- Preserve interpolation variables like {count}, {name} exactly
- Use natural, fluent phrasing — not word-for-word
- For technical terms (API, SEO), keep the English term
- Return ONLY valid JSON
${JSON.stringify(sourceMessages, null, 2)}`;
const result = await model.generateContent(prompt);
const text = result.response.text().trim();
const cleaned = text.replace(/^```
{% endraw %}
json?\n?/, '').replace(/\n?
{% raw %}
```$/, '');
return JSON.parse(cleaned);
}
AI handles roughly 90% of translations correctly. The remaining 10% — idiomatic expressions, CJK character length issues — requires human review.
Common Pitfalls
1. Forgetting RTL support for Arabic:
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
2. Massive client bundles. Split by namespace:
const clientMessages = { search: messages.search, common: messages.common };
<NextIntlClientProvider messages={clientMessages}>
3. Hardcoded strings. Search regularly:
rg '"[A-Z][a-z]+ [a-z]+"' src/components/ --glob '*.tsx'
4. Middleware catching static files:
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)']
};
5. Hydration mismatches. Make sure your layout wraps children properly:
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Performance
Server Components: zero client runtime cost. For Client Components, keep messages JSON under 20 KB per page.
For static pages, generateStaticParams pre-renders every locale at build time:
export function generateStaticParams() {
return ['en', 'fr', 'es', 'de', 'pt', 'ja', 'zh', 'ko', 'ar', 'ru'].map(
(locale) => ({ locale })
);
}
Fully static HTML per locale — the fastest possible serving path.
Final Thoughts
Internationalization compounds. Every page you translate is a new entry point for organic traffic. Start with your highest-traffic pages. Get the architecture right with next-intl. Automate the tedious parts with AI translation scripts. Validate everything in CI.
Then watch your analytics as traffic from 9 new language markets rolls in.
Resources:
Top comments (0)