Ask your question and get a summary of the document by referencing this page and the AI provider of your choice
By integrating the Intlayer MCP Server to your favourite AI assistant can retrieve all the doc directly from ChatGPT, DeepSeek, Cursor, VSCode, etc.
See MCP Server docThe content of this page was translated using an AI.
See the last version of the original content in EnglishIf you have an idea for improving this documentation, please feel free to contribute by submitting a pull request on GitHub.
GitHub link to the documentationCopy doc Markdown to clipboard
Internationalizing (i18n) with next-intl to make your Next.js 15 app multilingual
This guide walks you through next-intl best practices in a Next.js 15 (App Router) app, and shows how to layer Intlayer on top for robust translation management and automation.
See the comparison in next-i18next vs next-intl vs Intlayer.
- For juniors: follow step-by-step sections to get a working multilingual app.
- For mid-level devs: pay attention to payload optimization and server/client separation.
- For seniors: note static generation, middleware, SEO integration, and automation hooks.
What we’ll cover:
- Setup and file structure
- Optimizing how messages are loaded
- Client and server component usage
- Metadata, sitemap, robots for SEO
- Middleware for locale routing
- Adding Intlayer on top (CLI and automation)
Set up your application using next-intl
Install the next-intl dependencies:
npm install next-intl
.├── locales│ ├── en│ │ ├── common.json│ │ └── about.json│ ├── fr│ │ ├── common.json│ │ └── about.json│ └── es│ ├── common.json│ └── about.json└── src ├── i18n.ts ├── middleware.ts ├── app │ └── [locale] │ ├── layout.tsx │ └── about │ └── page.tsx └── components ├── ClientComponentExample.tsx └── ServerComponent.tsx
Setup and Loading Content
Load only the namespaces your routes need and validate locales early. Keep server components synchronous when possible and push only the required messages to the client.
Copy the code to the clipboard
import { getRequestConfig } from "next-intl/server";import { notFound } from "next/navigation";export const locales = ["en", "fr", "es"] as const;export const defaultLocale = "en" as const;async function loadMessages(locale: string) { // Load only the namespaces your layout/pages need const [common, about] = await Promise.all([ import(`../locales/${locale}/common.json`).then((m) => m.default), import(`../locales/${locale}/about.json`).then((m) => m.default), ]); return { common, about } as const;}export default getRequestConfig(async ({ locale }) => { if (!locales.includes(locale as any)) notFound(); return { messages: await loadMessages(locale), };});
Copy the code to the clipboard
import type { ReactNode } from "react";import { locales } from "@/i18n";import { getLocaleDirection, unstable_setRequestLocale,} from "next-intl/server";export const dynamic = "force-static";export function generateStaticParams() { return locales.map((locale) => ({ locale }));}export default async function LocaleLayout({ children, params,}: { children: ReactNode; params: { locale: string };}) { const { locale } = params; // Set the active request locale for this server render (RSC) unstable_setRequestLocale(locale); const dir = getLocaleDirection(locale); return ( <html lang={locale} dir={dir}> <body>{children}</body> </html> );}
Copy the code to the clipboard
import { getTranslations, getMessages, getFormatter } from "next-intl/server";import { NextIntlClientProvider } from "next-intl";import pick from "lodash/pick";import ServerComponent from "@/components/ServerComponent";import ClientComponentExample from "@/components/ClientComponentExample";export const dynamic = "force-static";export default async function AboutPage({ params,}: { params: { locale: string };}) { const { locale } = params; // Messages are loaded server-side. Push only what's needed to the client. const messages = await getMessages(); const clientMessages = pick(messages, ["common", "about"]); // Strictly server-side translations/formatting const tAbout = await getTranslations("about"); const tCounter = await getTranslations("about.counter"); const format = await getFormatter(); const initialFormattedCount = format.number(0); return ( <NextIntlClientProvider locale={locale} messages={clientMessages}> <main> <h1>{tAbout("title")}</h1> <ClientComponentExample /> <ServerComponent formattedCount={initialFormattedCount} label={tCounter("label")} increment={tCounter("increment")} /> </main> </NextIntlClientProvider> );}
Usage in a client component
Let's take an example of a client component rendering a counter.
Translations (shape reused; load them into next-intl messages as you prefer)
Copy the code to the clipboard
{ "counter": { "label": "Counter", "increment": "Increment" }}
Copy the code to the clipboard
{ "counter": { "label": "Compteur", "increment": "Incrémenter" }}
Client component
Copy the code to the clipboard
"use client";import React, { useState } from "react";import { useTranslations, useFormatter } from "next-intl";const ClientComponentExample = () => { // Scope directly to the nested object const t = useTranslations("about.counter"); const format = useFormatter(); const [count, setCount] = useState(0); return ( <div> <p>{format.number(count)}</p> <button aria-label={t("label")} onClick={() => setCount((count) => count + 1)} > {t("increment")} </button> </div> );};
Don't forget to add "about" message on the page client message (only include the namespaces your client actually needs).
Usage in a server component
This UI component is a server component and can be rendered under a client component (page → client → server). Keep it synchronous by passing precomputed strings.
Copy the code to the clipboard
type ServerComponentProps = { formattedCount: string; label: string; increment: string;};const ServerComponent = ({ formattedCount, label, increment,}: ServerComponentProps) => { return ( <div> <p>{formattedCount}</p> <button aria-label={label}>{increment}</button> </div> );};
Notes:
- Compute formattedCount server-side (e.g., const initialFormattedCount = format.number(0)).
- Avoid passing functions or non-serializable objects into server components.
Copy the code to the clipboard
import type { Metadata } from "next";import { locales, defaultLocale } from "@/i18n";import { getTranslations } from "next-intl/server";function localizedPath(locale: string, path: string) { return locale === defaultLocale ? path : "/" + locale + path;}export async function generateMetadata({ params,}: { params: { locale: string };}): Promise<Metadata> { const { locale } = params; const t = await getTranslations({ locale, namespace: "about" }); const url = "/about"; const languages = Object.fromEntries( locales.map((locale) => [locale, localizedPath(locale, url)]) ); return { title: t("title"), description: t("description"), alternates: { canonical: localizedPath(locale, url), languages: { ...languages, "x-default": url }, }, };}// ... Rest of the page code
Copy the code to the clipboard
import type { MetadataRoute } from "next";import { locales, defaultLocale } from "@/i18n";const origin = "https://example.com";const formatterLocalizedPath = (locale: string, path: string) => locale === defaultLocale ? origin + path : origin + "/" + locale + path;export default function sitemap(): MetadataRoute.Sitemap { const aboutLanguages = Object.fromEntries( locales.map((l) => [l, formatterLocalizedPath(l, "/about")]) ); return [ { url: formatterLocalizedPath(defaultLocale, "/about"), lastModified: new Date(), changeFrequency: "monthly", priority: 0.7, alternates: { languages: aboutLanguages }, }, ];}
Copy the code to the clipboard
import type { MetadataRoute } from "next";import { locales, defaultLocale } from "@/i18n";const origin = "https://example.com";const withAllLocales = (path: string) => [ path, ...locales .filter((locale) => locale !== defaultLocale) .map((locale) => "/" + locale + path),];export default function robots(): MetadataRoute.Robots { const disallow = [ ...withAllLocales("/dashboard"), ...withAllLocales("/admin"), ]; return { rules: { userAgent: "*", allow: ["/"], disallow }, host: origin, sitemap: origin + "/sitemap.xml", };}
Middleware for locale routing
Add a middleware to handle locale detection and routing:
Copy the code to the clipboard
import createMiddleware from "next-intl/middleware";import { locales, defaultLocale } from "@/i18n";export default createMiddleware({ locales: [...locales], defaultLocale, localeDetection: true,});export const config = { // Skip API, Next internals and static assets matcher: ["/((?!api|_next|.*\\..*).*)"],};
Best practices
- Set html lang and dir: In src/app/[locale]/layout.tsx, compute dir via getLocaleDirection(locale) and set <html lang={locale} dir={dir}>.
- Split messages by namespace: Organize JSON per locale and namespace (e.g., common.json, about.json).
- Minimize client payload: On pages, send only required namespaces to NextIntlClientProvider (e.g., pick(messages, ['common', 'about'])).
- Prefer static pages: Export export const dynamic = 'force-static' and generate static params for all locales.
- Synchronous server components: Pass precomputed strings (translated labels, formatted numbers) rather than async calls or non-serializable functions.
Implement Intlayer on top of next-intl
Install the intlayer dependencies:
npm install intlayer @intlayer/sync-json-plugin -D
Create the intlayer configuration file:
Copy the code to the clipboard
import { type IntlayerConfig, Locales } from "intlayer";import { syncJSON } from "@intlayer/sync-json-plugin";const config: IntlayerConfig = { internationalization: { locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH], defaultLocale: Locales.ENGLISH, }, ai: { apiKey: process.env.OPENAI_API_KEY, }, plugins: [ // Keep your per-namespace folder structure in sync with Intlayer syncJSON({ source: ({ key, locale }) => `./locales/${locale}/${key}.json`, }), ],};export default config;
Add package.json scripts:
Copy the code to the clipboard
{ "scripts": { "i18n:fill": "intlayer fill", "i18n:test": "intlayer test" }}
Notes:
- intlayer fill: uses your AI provider to fill missing translations based on your configured locales.
- intlayer test: checks for missing/invalid translations (use it in CI).
You can configure arguments and providers; see Intlayer CLI.