Next.js i18n with next-intl: A Complete Guide From Someone Who Actually Uses It

Photo by Unsplash

Photo by Unsplash
This portfolio you're reading right now is fully internationalized in English and Bahasa Indonesia using next-intl. I didn't choose next-intl because a blog post told me to — I chose it after trying next-i18next (incompatible with App Router), trying to roll my own solution with next/navigation (painful), and finally landing on next-intl as the one library that properly supports server components, has good TypeScript types, and doesn't require ejecting from the App Router conventions.
The i18n landscape for Next.js App Router is confusing. The official Next.js docs show you how to implement i18n without any library — using middleware to detect locale and dynamic segments for the locale prefix. That approach works, but it pushes a lot of boilerplate onto you. next-intl handles date formatting, pluralization, and typed translation keys with a clean API. Compared to react-intl, next-intl is designed specifically for Next.js and understands the server/client component boundary.
next-intl uses a locale dynamic segment in the App Router directory. All your pages live under app/locale/. The middleware intercepts requests before routing, detects or negotiates the locale, and either redirects or passes through. The defineRouting function in i18n/routing.ts is the single source of truth for supported locales and the default locale.
In server components, you call getTranslations() — an async function that returns a typed t function scoped to a namespace. In client components, you call useTranslations() — a synchronous hook that reads translations from the NextIntlClientProvider context. The key implication: client components cannot access translations that weren't included in the messages passed to NextIntlClientProvider.
Browser Request: /id/about
│
▼
┌──────────────────────────────────────────────┐
│ middleware.ts (next-intl) │
│ 1. Read Accept-Language / cookie │
│ 2. Match locale: "id" │
│ 3. Rewrite → /id/about (already correct) │
└─────────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ app/[locale]/layout.tsx │
│ getMessages({ locale: "id" }) │
│ → loads messages/id.json │
│ → NextIntlClientProvider wraps children │
└─────────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Server Component │
│ const t = await getTranslations("about") │
│ t("hero.title") → "Tentang Kami" │
└──────────────────────────────────────────────┘Use next-intl's createTranslator in utility functions and service layer code that needs to translate strings but isn't a React component. This is useful for generating translated email subjects, notification messages, or error messages in server-side code.
A flat messages file becomes unmanageable quickly. I structure mine hierarchically by page and component namespace: common.* for shared strings, home.* for the home page, blog.* for the blog section including nested blog.posts.* for individual post content. Each blog post has its own sub-namespace, meaning adding a new blog post never touches any other section of the translation file.
next-intl has excellent TypeScript support when you configure it correctly. Create a global.d.ts file that imports your messages type and declares the module augmentation. Once configured, the t() function is fully typed — autocomplete shows available keys, and TypeScript errors if you use a key that doesn't exist in the messages file.
// 1. middleware.ts — locale detection
import createMiddleware from "next-intl/middleware"
import { routing } from "./i18n/routing"
export default createMiddleware(routing)
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\..*).*)"],
}
// 2. i18n/routing.ts — define supported locales
import { defineRouting } from "next-intl/routing"
export const routing = defineRouting({
locales: ["en", "id"],
defaultLocale: "en",
localePrefix: "always", // /en/... and /id/...
})
// 3. i18n/navigation.ts — locale-aware Link & redirect
import { createNavigation } from "next-intl/navigation"
import { routing } from "./routing"
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing)
// 4. app/[locale]/layout.tsx
import { getMessages } from "next-intl/server"
import { NextIntlClientProvider } from "next-intl"
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode
params: { locale: string }
}) {
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
// 5. Server component usage
import { getTranslations } from "next-intl/server"
export default async function AboutPage() {
const t = await getTranslations("about")
return <h1>{t("hero.title")}</h1>
}
// 6. Client component usage
"use client"
import { useTranslations } from "next-intl"
export function NavBar() {
const t = useTranslations("nav")
return <nav>{t("home")}</nav>
}next-intl wraps the native Intl API for date, number, and plural formatting. In Indonesian, numbers use a period as the thousands separator and a comma as the decimal separator — the opposite of English. next-intl handles this automatically when you use format.number() and format.dateTime() with the locale context — you don't need to write locale-specific formatting code.
By default, next-intl renders an empty string when a translation key is missing in the active locale's messages file. In production with the default error handling, a missing Indonesian translation silently shows nothing to Indonesian users — no fallback to English, just empty space. Configure onError and getMessageFallback in your NextIntlClientProvider to log missing keys and fall back to the English value.
For SEO, every page needs link rel alternate hreflang tags pointing to the other locale versions. In Next.js App Router, this is done in generateMetadata using the alternates.languages metadata field. I generate these programmatically from the routing configuration so they stay in sync automatically as I add locales.
next-intl works with all Next.js deployment targets — Vercel, self-hosted Node.js, Docker containers. The middleware runs at the Edge by default on Vercel, meaning locale detection adds essentially zero latency. One production gotcha: if you use ISR with i18n, each locale generates a separate cached page. Make sure your revalidation strategy accounts for this.