Internationalization in Next.js: 11-Locale Implementation

Build a production-ready 11-locale Next.js app with next-intl v4. Covers routing, RTL support, translation workflows, hreflang SEO, and server/client component patterns.

E
ECOSIRE Research and Development Team
|19. März 20269 Min. Lesezeit1.9k Wörter|

Internationalisierung in Next.js: 11-Locale-Implementierung

Um ein globales Publikum anzusprechen, ist mehr erforderlich, als Ihre Texte durch Google Translate laufen zu lassen. Eine Implementierung der Produktionsinternationalisierung umfasst Routing-Strategie, RTL-Layoutunterstützung (von rechts nach links), serverseitige Gebietsschemaauflösung, Übersetzungsschlüsselorganisation, SEO-Hreflang-Signale und einen nachhaltigen Übersetzungsworkflow. Wenn Sie einen dieser Punkte falsch verstehen, haben Sie entweder fehlerhafte Layouts für Arabisch-/Urdu-Benutzer, fehlende Hreflang-Tags, die Ihre Suchrankings aufteilen, oder eine Übersetzungspipeline, die unter der Last des Content-Wachstums zusammenbricht.

Dieses Handbuch dokumentiert die vollständige 11-Gebietsschema-Einrichtung, die auf ECOSIRE.COM verwendet wird – Englisch plus Chinesisch, Spanisch, Arabisch, Portugiesisch, Französisch, Deutsch, Japanisch, Türkisch, Hindi und Urdu – basierend auf next-intl v4.8. Jedes Muster hier wird anhand von 5.577 MDX-Inhaltsdateien und 12.543 Übersetzungsschlüsseln produktionsgetestet.

Wichtige Erkenntnisse

– Verwenden Sie localePrefix: 'as-needed' – Englisch hat kein Präfix, andere Gebietsschemas erhalten /zh/, /ar/ usw. – next-intl v4 verwendet das Muster routing.ts + navigation.ts – importiert niemals aus next/navigation direkt in länderspezifische Komponenten

  • Arabisch (ar) und Urdu (ur) erfordern dir="rtl" auf <html> und eine RTL-kompatible Schriftart (Noto Sans Arabic)
  • en.json-Schlüssel MÜSSEN verschachtelte Objekte sein – flache, durch Punkte getrennte Schlüssel unterbrechen die Namespace-Auflösung
  • generateMetadata() (niemals statisch export const metadata) mit alternates.languages für hreflang auf jeder Seite – Serverkomponenten verwenden getTranslations('namespace'), Clientkomponenten verwenden useTranslations('namespace') – Admin-Seiten verwenden zwei Übersetzungs-Hooks: t für modulspezifische Schlüssel, tc für gemeinsame admin.common-Schlüssel
  • Automatisieren Sie die Übersetzung neuer Schlüssel mit der Google Translate API; Blockieren Sie niemals die Entwicklung manueller Übersetzungen

Projekt-Setup

pnpm add next-intl@4

Verzeichnisstruktur für jede App:

src/
  i18n/
    routing.ts      # defineRouting() — locale list, prefix strategy
    navigation.ts   # createNavigation() — locale-aware Link, useRouter
    request.ts      # getRequestConfig() — server-side message loading
  messages/
    en.json         # Source of truth (~12,543 keys)
    zh.json
    es.json
    ar.json
    pt.json
    fr.json
    de.json
    ja.json
    tr.json
    hi.json
    ur.json

Routing-Konfiguration

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';

export const locales = [
  'en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur',
] as const;

export type Locale = (typeof locales)[number];

export const rtlLocales: Locale[] = ['ar', 'ur'];

export const localeNames: Record<Locale, string> = {
  en: 'English',
  zh: '中文',
  es: 'Español',
  ar: 'العربية',
  pt: 'Português',
  fr: 'Français',
  de: 'Deutsch',
  ja: '日本語',
  tr: 'Türkçe',
  hi: 'हिन्दी',
  ur: 'اردو',
};

export const routing = defineRouting({
  locales,
  defaultLocale: 'en',
  localePrefix: 'as-needed', // /en/blog → /blog, /zh/blog → /zh/blog
});
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';

export const { Link, redirect, useRouter, usePathname, getPathname } =
  createNavigation(routing);

Middleware/Proxy

next-intl v4 verwendet eine Middleware-Funktion zur Erkennung und Weiterleitung des Gebietsschemas:

// src/proxy.ts (not middleware.ts — naming matters for Next.js 16)
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextRequest, NextResponse } from 'next/server';

const intlMiddleware = createMiddleware(routing);

export function middleware(request: NextRequest) {
  // Auth protection for dashboard routes
  const isDashboard = request.nextUrl.pathname.includes('/dashboard');
  const isPortal = request.nextUrl.pathname.includes('/portal');
  const authCookie = request.cookies.get('ecosire_auth');

  if ((isDashboard || isPortal) && !authCookie) {
    const loginUrl = new URL('/auth/login', request.url);
    // Prevent open redirect: only allow relative redirect paths
    const from = request.nextUrl.pathname;
    if (from.startsWith('/') && !from.startsWith('//')) {
      loginUrl.searchParams.set('from', from);
    }
    return NextResponse.redirect(loginUrl);
  }

  return intlMiddleware(request);
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|.*\\..*|_vercel|favicon.ico).*)',
  ],
};

Serverseitiges Laden von Nachrichten

// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  // Validate locale is supported; fall back to default
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Gebietsschema-Layout mit RTL- und Schriftartenunterstützung

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing, rtlLocales, type Locale } from '@/i18n/routing';
import { Noto_Sans, Noto_Sans_Arabic } from 'next/font/google';

const notoSans = Noto_Sans({ subsets: ['latin'], variable: '--font-sans' });
const notoArabic = Noto_Sans_Arabic({
  subsets: ['arabic'],
  variable: '--font-arabic',
});

export async function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'metadata' });
  return {
    title: { template: `%s | ${t('siteName')}`, default: t('defaultTitle') },
    other: { 'content-language': locale }, // For Bing/AI engine language detection
  };
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;

  if (!routing.locales.includes(locale as Locale)) {
    notFound();
  }

  const messages = await getMessages();
  const isRtl = rtlLocales.includes(locale as Locale);

  return (
    <html
      lang={locale}
      dir={isRtl ? 'rtl' : 'ltr'}
      className={`${notoSans.variable} ${notoArabic.variable}`}
    >
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Übersetzungsschlüsselorganisation

Die en.json-Struktur muss verschachtelte Objekte verwenden, keine flachen, durch Punkte getrennten Schlüssel:

// WRONG — flat keys break next-intl namespace resolution
{
  "nav.home": "Home",
  "nav.blog": "Blog",
  "admin.common.save": "Save"
}

// CORRECT — nested objects
{
  "nav": {
    "home": "Home",
    "blog": "Blog"
  },
  "admin": {
    "common": {
      "save": "Save",
      "cancel": "Cancel",
      "delete": "Delete",
      "edit": "Edit",
      "status": "Status"
    },
    "contacts": {
      "title": "Contacts",
      "addContact": "Add Contact",
      "searchPlaceholder": "Search contacts..."
    }
  }
}

Serverkomponenten

// Server component: use getTranslations (async)
import { getTranslations } from 'next-intl/server';

export default async function ServicesPage({ params }: Props) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'services.odoo' });

  return (
    <main>
      <h1>{t('hero.title')}</h1>
      <p>{t('hero.description')}</p>
    </main>
  );
}

// generateMetadata must also fetch translations for locale-aware metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'services.odoo' });

  return {
    title: t('meta.title'),
    description: t('meta.description'),
    alternates: {
      canonical: `https://ecosire.com${locale === 'en' ? '' : `/${locale}`}/services/odoo`,
      languages: {
        'x-default': 'https://ecosire.com/services/odoo',
        en: 'https://ecosire.com/services/odoo',
        zh: 'https://ecosire.com/zh/services/odoo',
        ar: 'https://ecosire.com/ar/services/odoo',
        // ... all 11 locales
      },
    },
  };
}

Client-Komponenten

// Client component: use useTranslations (synchronous)
'use client';

import { useTranslations } from 'next-intl';

// Admin pages use two hooks: module-specific + shared admin.common
export default function ContactsPage() {
  const t  = useTranslations('admin.contacts'); // Module-specific
  const tc = useTranslations('admin.common');   // Shared terms

  // IMPORTANT: stateConfig must be inside component (after hooks init)
  const stateConfig = {
    active:   { label: tc('statusActive'),   className: 'bg-green-100 text-green-800' },
    inactive: { label: tc('statusInactive'), className: 'bg-gray-100 text-gray-700'  },
  };

  return (
    <div>
      <h1>{t('title')}</h1>
      <button>{tc('addNew')}</button>
      {/* ... */}
    </div>
  );
}

Sprachumschalter-Komponente

// components/language-switcher.tsx
'use client';

import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation'; // NOT next/navigation
import { locales, localeNames } from '@/i18n/routing';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Globe } from 'lucide-react';

export function LanguageSwitcher() {
  const locale = useLocale();
  const pathname = usePathname();
  const router = useRouter();

  const handleLocaleChange = (newLocale: string) => {
    router.replace(pathname, { locale: newLocale });
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <button aria-label="Change language">
          <Globe className="h-4 w-4" />
          <span>{localeNames[locale as keyof typeof localeNames]}</span>
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {locales.map((loc) => (
          <DropdownMenuItem
            key={loc}
            onClick={() => handleLocaleChange(loc)}
            className={loc === locale ? 'font-bold' : ''}
          >
            {localeNames[loc]}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Übersetzungsworkflow

Neue Schlüssel hinzufügen

  1. Zuerst zu en.json hinzufügen (Quelle der Wahrheit)
  2. Führen Sie das Übersetzungsskript aus:
node apps/web/scripts/translate-missing.mjs

Das Skript findet Schlüssel, die in en.json vorhanden sind, aber in anderen Gebietsschemadateien fehlen, und ruft die Google Translate-API auf:

// scripts/translate-missing.mjs (simplified)
import { Translate } from '@google-cloud/translate/build/src/v2/index.js';
import { readFileSync, writeFileSync } from 'fs';

const translate = new Translate({ key: process.env.GOOGLE_TRANSLATE_API_KEY });

async function translateMissingKeys(sourceLocale, targetLocale, sourceMessages, targetMessages) {
  const missing = findMissingKeys(sourceMessages, targetMessages);

  for (const [keyPath, value] of missing) {
    const [translated] = await translate.translate(value, targetLocale);
    setNestedKey(targetMessages, keyPath, translated);
  }

  return targetMessages;
}

Validierung der Übersetzungsabdeckung

# Check for missing keys across all locales
node apps/web/scripts/validate-blog-i18n.mjs

# Fix flat keys accidentally added
node apps/web/scripts/fix-en-flat-keys.mjs

Übersetzung von Blog-Inhalten

Bei MDX-Inhaltsdateien bleiben die Frontmatter-Titel auf Englisch (Titel sind in i18n JSON), aber der Hauptinhalt wird je nach Gebietsschema übersetzt:

src/content/blog/
  en/        # English originals (or root directory)
    odoo-erp-guide.mdx
  zh/
    odoo-erp-guide.mdx  # Translated body
  ar/
    odoo-erp-guide.mdx
  # ... 10 locale directories
// src/lib/blog.ts — locale-aware loader
export async function getPost(slug: string, locale: string) {
  // Try locale-specific file first
  const localePath = path.join(contentDir, locale, `${slug}.mdx`);
  const englishPath = path.join(contentDir, `${slug}.mdx`);

  const filePath = fs.existsSync(localePath) ? localePath : englishPath;

  const raw = fs.readFileSync(filePath, 'utf8');
  const { data: frontmatter, content } = matter(raw);
  return { frontmatter, content, locale };
}

Häufig gestellte Fragen

Warum localePrefix: 'as-needed' anstelle von always verwenden?

Mit as-needed haben englische URLs kein Präfix (/blog/post), während andere Gebietsschemas ein Präfix (/zh/blog/post) erhalten. Dies ist die Standardkonvention – Englisch ist die Standardeinstellung und verfügt über saubere URLs. Mit always erhält Englisch auch /en/blog/post, wodurch unnötige Weiterleitungen entstehen und Ihr vorhandenes englisches Linkkapital aufgeteilt wird, wenn Sie migrieren.

Warum müssen en.json-Schlüssel verschachtelte Objekte und keine flachen, durch Punkte getrennten Zeichenfolgen sein?

Die Namespace-Auflösung von next-intl teilt die Namespace-Zeichenfolge nach Punkten auf, um den JSON-Baum zu durchlaufen. Wenn es sich bei Ihren Schlüsseln um flache, durch Punkte getrennte Zeichenfolgen wie "admin.contacts.title" handelt, sucht next-intl nach messages.admin.contacts.title als verschachteltem Pfad, stellt jedoch fest, dass der Schlüssel buchstäblich die Zeichenfolge "admin.contacts.title" auf der Stammebene ist, was zu einem Suchfehler führt. Verwenden Sie immer tatsächlich verschachtelte Objekte.

Wie gehe ich mit sprachübergreifender Pluralisierung um?

next-intl verwendet das ICU-Nachrichtenformat zur Pluralisierung. Englisch hat zwei Pluralformen (one/other), Arabisch hat sechs (null, eins, zwei, wenige, viele, andere). Verwenden Sie die ICU-Syntax {count, plural, one {# item} other {# items}} in Ihren Übersetzungswerten und next-intl wendet automatisch die richtigen Pluralregeln für jedes Gebietsschema an.

Kann ich next-intl mit dem Pages Router verwenden?

next-intl v4 ist hauptsächlich für den App Router konzipiert. Verwenden Sie für Pages Router next-intl v2 oder v3. Die Muster unterscheiden sich erheblich: Pages Router verwendet getStaticProps + NextIntlProvider, während App Router getRequestConfig + NextIntlClientProvider im Layout verwendet. Wenn Sie den Pages Router verwenden, sollten Sie eine Migration zum App Router in Betracht ziehen, bevor Sie i18n in großem Maßstab implementieren.

Wie richte ich die RTL-Unterstützung für Arabisch und Urdu ein?

Legen Sie dir="rtl" für das Element <html> fest, wenn das Gebietsschema ar oder ur ist. Verwenden Sie das Variantenpräfix rtl: von Tailwind für richtungsspezifische Stile (rtl:mr-4 wird in RTL zu ml-4). Laden Sie eine kompatible Schriftart – Noto Sans Arabic deckt sowohl arabische als auch Urdu-Schriften ab. Vermeiden Sie hartcodierte CSS-Werte left/right; Verwenden Sie die logischen Eigenschaften start/end (ms-4, me-4 in Tailwind v4).

Was ist der beste Ansatz, um große Mengen an Inhalten schnell zu übersetzen?

Verwenden Sie die Google Translate API für die erste maschinelle Übersetzung aller Schlüssel und Inhalte und lassen Sie anschließend gut sichtbare Inhalte (Homepage, Produktbeschreibungen, CTA-Kopie) von Muttersprachlern überprüfen. Für Blog-Inhalte ist die maschinelle Übersetzung als Ausgangspunkt akzeptabel, sollte jedoch auf technische Richtigkeit überprüft werden. Budget für Muttersprachlerbewertung von mindestens den drei bis fünf beliebtesten Orten nach Verkehr.


Nächste Schritte

Richtig durchgeführte Internationalisierung ist ein Wettbewerbsvorteil – Ihre Inhalte erreichen 11-mal mehr potenzielle Kunden, Ihre SEO-Hreflang-Signale werden konsolidiert, anstatt das Link-Equity aufzuteilen, und Benutzer in ihrer Muttersprache konvertieren deutlich schneller.

ECOSIRE hat eine vollständige Implementierung mit 11 Gebietsschemata erstellt und verwaltet diese, die 5.577 Inhaltsdateien, 12.543 Übersetzungsschlüssel und RTL-Layoutunterstützung umfasst. Wenn Sie eine Internationalisierung für Ihre Next.js-Anwendung benötigen, [entdecken Sie unsere Frontend-Engineering-Dienste] (/services), um zu erfahren, wie wir sie für Ihre Codebasis implementieren können.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp