이 페이지와 원하는 AI 어시스턴트를 사용하여 문서를 요약합니다
Intlayer MCP 서버를 통해 ChatGPT, DeepSeek, Cursor, VSCode 등에서 직접 문서를 검색할 수 있습니다.
MCP 서버 문서 보기이 페이지의 콘텐츠는 AI를 사용하여 번역되었습니다.
영어 원본 내용의 최신 버전을 보기이 문서를 개선할 아이디어가 있으시면 GitHub에 풀 리퀘스트를 제출하여 자유롭게 기여해 주세요.
문서에 대한 GitHub 링크문서의 Markdown을 클립보드에 복사
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.
코드를 클립보드에 복사
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), };});
코드를 클립보드에 복사
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> );}
코드를 클립보드에 복사
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)
코드를 클립보드에 복사
{ "counter": { "label": "Counter", "increment": "Increment" }}
코드를 클립보드에 복사
{ "counter": { "label": "Compteur", "increment": "Incrémenter" }}
Client component
코드를 클립보드에 복사
"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.
코드를 클립보드에 복사
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.
코드를 클립보드에 복사
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
코드를 클립보드에 복사
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 }, }, ];}
코드를 클립보드에 복사
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:
코드를 클립보드에 복사
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:
코드를 클립보드에 복사
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:
코드를 클립보드에 복사
{ "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.