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
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 →2026년 최고의 AI 번역 도구: 개발자 가이드
2026년 최고의 AI 번역 도구: 개발자 가이드 2026년 개발자를 위한 최고의 AI 번역 도구는 DeepL API, Google Cloud Translation, Amazon Translate, Azure Translator, 그리고 better-i18n입니다. 각각...
Read More →