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-intl

3. 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.json

4. Routing Config

1// src/i18n/routing.ts
2import { defineRouting } from "next-intl/routing";
3import { createSharedPathnamesNavigation } from "next-intl/navigation";
4
5export 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
12export const { Link, redirect, usePathname, useRouter } =
13 createSharedPathnamesNavigation(routing);

5. Message Loader

1// src/i18n/request.ts
2import { getRequestConfig } from "next-intl/server";
3import { routing } from "./routing";
4
5export 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
17import createNextIntlPlugin from "next-intl/plugin";
18const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
19
20export 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
2import { getTranslations } from "next-intl/server";
3
4export 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
18import { useTranslations } from "next-intl";
19
20export function UserCounter({ count }: { count: number }) {
21 const t = useTranslations("Pricing");
22 return <p>{t("userCount", { count })}</p>;
23}
24
25// Locale-aware Link
26import { 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
4import { useRouter, usePathname } from "@/i18n/routing";
5import { useLocale } from "next-intl";
6
7const LANG_LABELS = { en: "English", id: "Bahasa Indonesia" };
8
9export 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 hreflang alternates to generateMetadata so search engines surface the right locale per region.
  • Include every locale variant in sitemap.ts — crawlers index URLs, not language preferences.
  • Translate openGraph.title and description per page. A localized URL with English meta is half a translation.
  • Set html lang on each rendered locale layout. Accessibility tools (screen readers, translation engines) rely on this.

Built with Next.js, deployed from a couch in Indonesia.

© 2025 Erik Yuntantyo · turmerrific