I18N
next-intl setup: locale routing, message catalogs (ICU format), server/client translation hooks, language switcher, and SEO hreflang/sitemap considerations.
1. Choose a Library
For Next.js App Router, the two strongest options:
- next-intl — App-Router native, supports server + client components, ICU message format, type-safe message keys. Recommended for most apps.
- next-i18next — Mature ecosystem, Pages Router heritage. Use if you have existing i18n infra to port.
This guide uses next-intl.
2. Install
yarn add next-intl3. Locale Routing
next-intl uses an [locale] dynamic segment at the root of the App Router. URLs become /en/about, /id/about, etc.
src/
app/
[locale]/
layout.tsx # locale provider
page.tsx # /en, /id
about/
page.tsx # /en/about, /id/about
i18n/
routing.ts # locale config
request.ts # message loader
messages/
en.json
id.json4. Routing Config
1 // src/i18n/routing.ts 2 import { defineRouting } from "next-intl/routing"; 3 import { createSharedPathnamesNavigation } from "next-intl/navigation"; 4 5 export const routing = defineRouting({ 6 // To add a language: add its code here + create src/messages/<code>.json 7 locales: ["en", "id"], 8 defaultLocale: "en", 9 localePrefix: "as-needed", // /about for en, /id/about for id 10 }); 11 12 export const { Link, redirect, usePathname, useRouter } = 13 createSharedPathnamesNavigation(routing);
5. Message Loader
1 // src/i18n/request.ts 2 import { getRequestConfig } from "next-intl/server"; 3 import { routing } from "./routing"; 4 5 export default getRequestConfig(async ({ requestLocale }) => { 6 let locale = await requestLocale; 7 if (!locale || !routing.locales.includes(locale as "en" | "id")) { 8 locale = routing.defaultLocale; 9 } 10 return { 11 locale, 12 messages: (await import(`@/messages/${locale}.json`)).default, 13 }; 14 }); 15 16 // next.config.ts 17 import createNextIntlPlugin from "next-intl/plugin"; 18 const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); 19 20 export default withNextIntl({ /* your existing config */ });
6. Message Catalogs
Use ICU message format for variables, plurals, and gender. Keep keys grouped by feature, not by page.
1 // src/messages/en.json 2 { 3 "Common": { 4 "appName": "Turmerrific", 5 "tagline": "Production-ready Next.js starter" 6 }, 7 "Home": { 8 "title": "Welcome, {name}", 9 "ctaPrimary": "Get started", 10 "ctaSecondary": "View on GitHub" 11 }, 12 "Pricing": { 13 "perMonth": "{price}/mo", 14 "userCount": "{count, plural, =0 {No users} one {# user} other {# users}}" 15 } 16 } 17 18 // src/messages/id.json 19 { 20 "Common": { 21 "appName": "Turmerrific", 22 "tagline": "Starter Next.js siap produksi" 23 }, 24 "Home": { 25 "title": "Selamat datang, {name}", 26 "ctaPrimary": "Mulai sekarang", 27 "ctaSecondary": "Lihat di GitHub" 28 }, 29 "Pricing": { 30 "perMonth": "{price}/bulan", 31 "userCount": "{count, plural, =0 {Tidak ada pengguna} other {# pengguna}}" 32 } 33 }
7. Using Translations
1 // Server Component 2 import { getTranslations } from "next-intl/server"; 3 4 export default async function HomePage() { 5 const t = await getTranslations("Home"); 6 7 return ( 8 <main> 9 <h1>{t("title", { name: "Erik" })}</h1> 10 <button>{t("ctaPrimary")}</button> 11 </main> 12 ); 13 } 14 15 // Client Component 16 "use client"; 17 18 import { useTranslations } from "next-intl"; 19 20 export function UserCounter({ count }: { count: number }) { 21 const t = useTranslations("Pricing"); 22 return <p>{t("userCount", { count })}</p>; 23 } 24 25 // Locale-aware Link 26 import { Link } from "@/i18n/routing"; 27 28 <Link href="/about" locale="id">Tentang kami</Link>
8. Language Switcher
1 // src/shared/layout/language-switcher.tsx 2 "use client"; 3 4 import { useRouter, usePathname } from "@/i18n/routing"; 5 import { useLocale } from "next-intl"; 6 7 const LANG_LABELS = { en: "English", id: "Bahasa Indonesia" }; 8 9 export function LanguageSwitcher() { 10 const router = useRouter(); 11 const pathname = usePathname(); 12 const locale = useLocale(); 13 14 return ( 15 <select 16 value={locale} 17 onChange={(e) => router.replace(pathname, { locale: e.target.value })} 18 className="bg-card border-border rounded-md border px-2 py-1 text-sm" 19 > 20 {Object.entries(LANG_LABELS).map(([code, label]) => ( 21 <option key={code} value={code}>{label}</option> 22 ))} 23 </select> 24 ); 25 }
9. SEO Considerations
- Add
hreflangalternates togenerateMetadataso search engines surface the right locale per region. - Include every locale variant in
sitemap.ts— crawlers index URLs, not language preferences. - Translate
openGraph.titleanddescriptionper page. A localized URL with English meta is half a translation. - Set
html langon each rendered locale layout. Accessibility tools (screen readers, translation engines) rely on this.