DEV Community

Propfirmkey
Propfirmkey

Posted on

Scaling to 10 Languages: Building a Multi-Language Next.js App with next-intl

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
Enter fullscreen mode Exit fullscreen mode

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'
});
Enter fullscreen mode Exit fullscreen mode
// 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
  };
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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')} />;
}
Enter fullscreen mode Exit fullscreen mode

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}`]
        )
      )
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

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}`} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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];
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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'}>
Enter fullscreen mode Exit fullscreen mode

2. Massive client bundles. Split by namespace:

const clientMessages = { search: messages.search, common: messages.common };
<NextIntlClientProvider messages={clientMessages}>
Enter fullscreen mode Exit fullscreen mode

3. Hardcoded strings. Search regularly:

rg '"[A-Z][a-z]+ [a-z]+"' src/components/ --glob '*.tsx'
Enter fullscreen mode Exit fullscreen mode

4. Middleware catching static files:

export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)']
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 })
  );
}
Enter fullscreen mode Exit fullscreen mode

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)