App Router를 지원하는 Next.js i18n
Next.js 애플리케이션을 위한 Server Components, ISR, 엣지 최적화 번역.
middlewaremiddleware.tsgetRequestConfigi18n/request.tsgetMessages()app/[locale]/page.tsxuseTranslations()components/Hero.tsxSetup
Set up in 4 steps
설치
프로젝트에 @better-i18n/next와 next-intl을 추가하세요.
npm install @better-i18n/next next-intl로케일 감지를 위한 미들웨어 추가
미들웨어는 Accept-Language 헤더와 URL 접두사를 읽어 사용자의 로케일을 감지하고 그에 따라 리디렉션합니다.
import { createBetterI18nMiddleware } from '@better-i18n/next';
export default createBetterI18nMiddleware({
project: 'your-org/your-project',
defaultLocale: 'en',
localePrefix: 'always',
});
export const config = { matcher: ['/((?!api|_next).*)'] };Server Component에서 메시지 로드하기
루트 레이아웃에서 getMessages()를 사용해 서버 측에서 번역을 가져와 BetterI18nProvider에 전달하세요.
// app/[locale]/layout.tsx
import { BetterI18nProvider } from '@better-i18n/next/client';
import { getMessages } from '@better-i18n/next/server';
const config = { project: 'your-org/your-project', defaultLocale: 'en' };
export default async function RootLayout({ children, params }) {
const { locale } = await params;
const messages = await getMessages(config, locale);
return (
<html lang={locale}>
<body>
<BetterI18nProvider locale={locale} messages={messages} config={config}>
{children}
</BetterI18nProvider>
</body>
</html>
);
}Client Component에서 번역 사용하기
어떤 Client Component에서든 useTranslations()를 호출하세요. 메시지는 이미 서버에서 하이드레이션되어 있어 추가 요청이 필요 없습니다.
'use client';
import { useTranslations } from 'next-intl';
export function HeroSection() {
const t = useTranslations('home');
return <h1>{t('title')}</h1>;
}Routing
Edge 런타임 및 로케일 감지
전 세계적으로 50밀리초 미만의 응답 시간을 위해 에지에서 로케일 감지 및 메시지 로딩을 실행합니다.
미들웨어 설정
미들웨어 파일 하나로 Next.js 앱에 로케일 감지와 라우팅을 추가하세요.
// middleware.ts — locale detection
import { createBetterI18nMiddleware } from '@better-i18n/next'
export default createBetterI18nMiddleware({
project: 'your-org/your-project',
defaultLocale: 'en',
localePrefix: 'always',
})
export const config = { matcher: ['/((?!api|_next).*)'] }// middleware.ts — Edge-based locale detection
import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'ja', 'es'] as const;
const DEFAULT_LOCALE = 'en';
function getPreferredLocale(request: NextRequest): string {
// 1. Check URL prefix
const pathname = request.nextUrl.pathname;
const urlLocale = SUPPORTED_LOCALES.find(
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
);
if (urlLocale) return urlLocale;
// 2. Check cookie
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as any)) {
return cookieLocale;
}
// 3. Parse Accept-Language header
const acceptLang = request.headers.get('accept-language') ?? '';
const preferred = acceptLang
.split(',')
.map((part) => part.split(';')[0].trim().substring(0, 2))
.find((code) => SUPPORTED_LOCALES.includes(code as any));
return preferred ?? DEFAULT_LOCALE;
}
export function middleware(request: NextRequest) {
const locale = getPreferredLocale(request);
const { pathname } = request.nextUrl;
const hasLocale = SUPPORTED_LOCALES.some(
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
);
if (!hasLocale) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};Edge 호환 메시지 로딩
즉각적인 응답을 위해 가변운 인메모리 TTL 캐시로 엓지에서 번역을 캐싱하세요.
// lib/edge-messages.ts — Edge-compatible message loading
const messageCache = new Map<string, { data: Record<string, string>; ts: number }>();
const TTL = 60_000; // 1 minute cache at edge
export async function getEdgeMessages(
locale: string,
namespace: string
): Promise<Record<string, string>> {
const cacheKey = `${locale}:${namespace}`;
const cached = messageCache.get(cacheKey);
if (cached && Date.now() - cached.ts < TTL) {
return cached.data;
}
const response = await fetch(
`https://cdn.better-i18n.com/your-org/your-project/${locale}/${namespace}.json`,
{ next: { revalidate: 60 } }
);
const data = await response.json();
messageCache.set(cacheKey, { data, ts: Date.now() });
return data;
}i18n을 사용하는 Edge API 경로
최소한의 콜드 스타트로 엔지 함수에서 번역된 API 응답을 반환하세요.
// app/api/translate/route.ts — Edge API route with i18n
import { getEdgeMessages } from '@/lib/edge-messages';
export const runtime = 'edge';
export async function GET(request: Request) {
const url = new URL(request.url);
const locale = url.searchParams.get('locale') ?? 'en';
const key = url.searchParams.get('key') ?? '';
const messages = await getEdgeMessages(locale, 'api-responses');
const translated = messages[key] ?? key;
return Response.json({ text: translated, locale });
}Rendering
정보·감시·정찰(ISR) 및 국제화
증분 정적 재생성과 국제화를 결합하여 빠르고 항상 최신 상태를 유지하는 다국어 페이지를 구현하세요.
revalidate = 3600app/[locale]/layout.tsx~60 minrevalidate = 1800app/[locale]/[slug]/page.tsx~30 minrevalidatePath()app/api/revalidate/route.ts< 1 min빠른 시작
몇 줄의 코드만으로 Next.js 앱에 i18n을 추가하세요.
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'home' });
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
);
}// app/[locale]/layout.tsx — ISR with i18n
import { getMessages } from '@better-i18n/next/server';
import { BetterI18nProvider } from '@better-i18n/next/client';
export const revalidate = 3600; // Revalidate every hour
const config = { project: 'your-org/your-project', defaultLocale: 'en' };
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const messages = await getMessages(config, locale);
return (
<BetterI18nProvider locale={locale} messages={messages} config={config}>
{children}
</BetterI18nProvider>
);
}generateStaticParams를 사용한 ISR
빌드 시점에 모든 로케일에 대한 페이지를 사전 렌더링한 다음, ISR로 일정에 따라 갱신하세요.
// app/[locale]/[slug]/page.tsx — Generate static pages per locale
import { getMessages } from '@better-i18n/next/server';
const config = { project: 'your-org/your-project', defaultLocale: 'en' };
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr', 'ja'];
const slugs = await fetchAllSlugs();
return locales.flatMap((locale) =>
slugs.map((slug) => ({ locale, slug }))
);
}
export const revalidate = 1800; // ISR: refresh every 30 min
export default async function Page({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const messages = await getMessages(config, locale, { namespaces: ['blog'] });
return <article><h1>{messages.blog[slug + '.title']}</h1></article>;
}온디맨드 재검증
번역이 업데이트되면 ISR 재검증을 트리거하세요 — Better I18N 게시 웹훅에 연결하면 됩니다.
// app/api/i18n/revalidate/route.ts — On-demand ISR for translation updates
import { createRevalidateHandler } from '@better-i18n/next/revalidate';
// Called by the Better i18n publish webhook — verifies the HMAC signature,
// then revalidates the paths/tags below.
export const POST = createRevalidateHandler({
secret: process.env.BETTER_I18N_WEBHOOK_SECRET!,
revalidatePaths: ['/'],
revalidateTags: ['i18n-messages'],
});Advanced
고급 패턴
중첩된 레이아웃, 병렬 경로, 타입 안전 번역을 지원하는 서버 액션.
// app/[locale]/dashboard/layout.tsx — Nested layout with namespace
import { getMessages } from '@better-i18n/next/server';
import { BetterI18nProvider } from '@better-i18n/next/client';
import { DashboardNav } from '@/components/DashboardNav';
const config = { project: 'your-org/your-project', defaultLocale: 'en' };
export default async function DashboardLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Load the dashboard-specific namespace alongside common messages
const messages = await getMessages(config, locale, {
namespaces: ['common', 'dashboard'],
});
return (
<BetterI18nProvider locale={locale} messages={messages} config={config}>
<DashboardNav />
<main>{children}</main>
</BetterI18nProvider>
);
}i18n을 사용한 병렬 라우트
모듈식이고 로케일을 인식하는 레이아웃을 위해 병렬 라우트 슬롯마다 독립적으로 번역을 로드하세요.
// app/[locale]/@analytics/page.tsx — Parallel route with i18n
import { getTranslations } from 'next-intl/server';
export default async function AnalyticsSlot({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'analytics' });
return (
<section aria-label={t('title')}>
<h2>{t('title')}</h2>
<p>{t('description')}</p>
</section>
);
}
// app/[locale]/layout.tsx — Consuming parallel routes
export default function Layout({
children,
analytics,
notifications,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div>
<main>{children}</main>
<aside>{analytics}</aside>
<aside>{notifications}</aside>
</div>
);
}번역이 포함된 Server Action
server action에서 번역된 유효성 검사 오류와 성공 메시지를 반환하세요.
// app/[locale]/contact/actions.ts — Server action with i18n
'use server';
import { getTranslations } from 'next-intl/server';
import { headers } from 'next/headers';
export async function submitContactForm(formData: FormData) {
const headersList = await headers();
// Set by createBetterI18nMiddleware — see the routing section above
const locale = headersList.get('x-locale') ?? 'en';
const t = await getTranslations({ locale, namespace: 'contact' });
const email = formData.get('email') as string;
const message = formData.get('message') as string;
if (!email || !message) {
return { error: t('validation.required') };
}
try {
await sendEmail({ email, message, locale });
return { success: t('form.success') };
} catch {
return { error: t('form.error') };
}
}
// app/[locale]/contact/page.tsx — Using the server action
'use client';
import { useTranslations } from 'next-intl';
import { submitContactForm } from './actions';
export default function ContactPage() {
const t = useTranslations('contact');
return (
<form action={submitContactForm}>
<label>{t('form.email')}</label>
<input name="email" type="email" required />
<label>{t('form.message')}</label>
<textarea name="message" required />
<button type="submit">{t('form.submit')}</button>
</form>
);
}일반적인 국제화 문제 해결
수분 불일치, 누락된 로케일 대체 처리, 날짜/숫자 서식 차이를 수정하십시오.
// Fix: Hydration mismatch with date/number formatting
// Problem: Server renders "1,000" but client renders "1.000"
// Solution: BetterI18nProvider already passes an explicit timeZone down to
// NextIntlClientProvider, so server and client share the same formatting locale.
// app/[locale]/layout.tsx
import { getFormatter } from 'next-intl/server';
export default async function Layout({ children, params }: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Pre-format on server with the explicit locale
const format = await getFormatter({ locale });
return (
<html lang={locale} suppressHydrationWarning>
<body>{children}</body>
</html>
);
}
// components/Price.tsx — Client component
'use client';
import { useFormatter } from 'next-intl';
export function Price({ amount }: { amount: number }) {
const format = useFormatter();
// useFormatter automatically uses the locale/timeZone from BetterI18nProvider
// ensuring server and client render the same output
return <span>{format.number(amount, { style: 'currency', currency: 'USD' })}</span>;
}로케일 폴백 체인
pt-BR와 같은 지역 변형이 pt로, 그다음 en으로 대체되도록 폴백 체인을 정의하세요.
// lib/i18n-config.ts — Locale fallback chain
const FALLBACK_CHAIN: Record<string, string[]> = {
'pt-BR': ['pt', 'en'],
'zh-TW': ['zh-CN', 'en'],
'en-GB': ['en'],
'de-AT': ['de', 'en'],
};
export function resolveMessages(
locale: string,
allMessages: Record<string, Record<string, string>>
): Record<string, string> {
const chain = FALLBACK_CHAIN[locale] ?? ['en'];
const primary = allMessages[locale] ?? {};
// Merge fallback messages (primary overrides fallbacks)
return chain.reduceRight(
(merged, fallbackLocale) => ({
...merged,
...(allMessages[fallbackLocale] ?? {}),
}),
primary
);
}일관된 날짜 형식
timeZone를 명시적으로 UTC로 설정해 서버/클라이언트 간 날짜 불일치를 방지하세요.
// components/LocalizedDate.tsx — Consistent date formatting
'use client';
import { useFormatter, useLocale } from 'next-intl';
export function LocalizedDate({ date }: { date: Date | string }) {
const format = useFormatter();
const locale = useLocale();
const dateObj = typeof date === 'string' ? new Date(date) : date;
return (
<time dateTime={dateObj.toISOString()}>
{format.dateTime(dateObj, {
year: 'numeric',
month: 'long',
day: 'numeric',
// Explicitly set timeZone to avoid server/client mismatch
timeZone: 'UTC',
})}
</time>
);
}Capabilities
기능
인기 있는 Next.js i18n 라이브러리와 호환
Better I18N은 좋아하는 i18n 라이브러리를 대체하는 것이 아닙니다 — 더 강력하게 만들어주는 번역 관리 레이어입니다.
Better I18N + next-intl
App Router 지원, 타입 안전 메시지, ICU 구문을 갖춘 가장 인기 있는 Next.js i18n 라이브러리.Better I18N은 next-intl JSON 형식으로 번역을 직접 동기화합니다. 대시보드에서 관리하고 CDN을 통해 즉시 배포하세요.
Better I18N + next-i18next
i18next 기반의 검증된 Next.js i18n 라이브러리. Pages Router와 App Router 마이그레이션에 적합합니다.i18next 호환 네임스페이스 JSON으로 내보내기. Better I18N이 번역 워크플로를, next-i18next가 런타임을 담당합니다.
Better I18N + Lingui
뛰어난 DX와 자동 메시지 추출을 갖춘 가볍고 매크로 기반의 i18n 라이브러리.Lingui CLI로 메시지를 추출하고, Better I18N에서 번역을 관리하고, GitHub 연동으로 자동 동기화합니다.
Related Articles
en vs en-US: How to Name Locale Files Without Breaking Your i18n Pipeline
You are setting up i18n. You create the folder. Then you stop, because you have to name the first file and there are two obvious answers: locales/en.json...
Read More →Introducing the Better i18n CLI: Manage Translations from Your Terminal
Until now, managing translation keys on Better i18n meant using the dashboard UI or connecting an MCP server to your AI assistant. Both work great — but...
Read More →How to Add i18n to Shopify Hydrogen (Complete Guide)
Shopify Hydrogen is the modern way to build custom storefronts — but adding internationalization (i18n) can be challenging. You need locale-aware routing,...
Read More →