fe-i18n
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFE Internationalization (i18n)
前端国际化(i18n)
$ARGUMENTS分析以配置多语言设置或应用翻译。
$ARGUMENTS분석 절차
分析流程
- 현재 상태 파악: 프로젝트의 i18n 설정을 확인한다
- 요구사항 파악: 지원할 언어, 기본 언어, 라우팅 전략을 확인한다
- 설정/구현: next-intl 기반으로 다국어를 설정하거나 번역을 적용한다
- 검증: 각 로케일에서 정상 동작하는지 확인한다
- 掌握当前状态:确认项目的i18n设置
- 明确需求:确认要支持的语言、默认语言、路由策略
- 配置/实现:基于next-intl设置多语言或应用翻译
- 验证:确认各本地化环境下的正常运行
next-intl 초기 설정
next-intl 初始配置
1. 패키지 설치
1. 安装包
bash
pnpm add next-intlbash
pnpm add next-intl2. 프로젝트 구조
2. 项目结构
src/
├── app/
│ └── [locale]/ # 로케일별 라우팅
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/page.tsx
├── i18n/
│ ├── request.ts # 서버 사이드 i18n 설정
│ └── routing.ts # 라우팅 설정
├── messages/ # 번역 파일
│ ├── ko.json
│ └── en.json
└── middleware.ts # 로케일 감지 & 리다이렉트src/
├── app/
│ └── [locale]/ # 本地化路由
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/page.tsx
├── i18n/
│ ├── request.ts # 服务端i18n配置
│ └── routing.ts # 路由配置
├── messages/ # 翻译文件
│ ├── ko.json
│ └── en.json
└── middleware.ts # 本地化检测与重定向3. 라우팅 설정
3. 路由配置
typescript
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["ko", "en"],
defaultLocale: "ko",
});typescript
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["ko", "en"],
defaultLocale: "ko",
});4. 미들웨어
4. 中间件
typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};5. 서버 사이드 설정
5. 服务端配置
typescript
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "ko" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});typescript
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "ko" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});6. next.config
6. next.config
typescript
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {};
export default withNextIntl(nextConfig);typescript
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {};
export default withNextIntl(nextConfig);7. Root Layout
7. 根布局
tsx
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params;
if (!routing.locales.includes(locale as "ko" | "en")) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}tsx
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params;
if (!routing.locales.includes(locale as "ko" | "en")) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}번역 파일 구조
翻译文件结构
네임스페이스 기반 구조
基于命名空间的结构
json
// messages/ko.json
{
"common": {
"submit": "제출",
"cancel": "취소",
"save": "저장",
"delete": "삭제",
"loading": "로딩 중...",
"error": "오류가 발생했습니다",
"confirm": "확인"
},
"nav": {
"home": "홈",
"about": "소개",
"blog": "블로그",
"contact": "문의"
},
"auth": {
"login": "로그인",
"logout": "로그아웃",
"signup": "회원가입",
"email": "이메일",
"password": "비밀번호",
"forgotPassword": "비밀번호 찾기"
},
"home": {
"title": "환영합니다",
"description": "최고의 서비스를 제공합니다",
"cta": "시작하기"
},
"blog": {
"title": "블로그",
"readMore": "더 읽기",
"publishedAt": "{date}에 게시됨",
"readingTime": "읽는 시간 {minutes}분"
},
"validation": {
"required": "{field}을(를) 입력해주세요",
"email": "유효한 이메일을 입력해주세요",
"minLength": "{field}은(는) 최소 {min}자 이상이어야 합니다"
}
}json
// messages/en.json
{
"common": {
"submit": "Submit",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"loading": "Loading...",
"error": "An error occurred",
"confirm": "Confirm"
},
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"auth": {
"login": "Log in",
"logout": "Log out",
"signup": "Sign up",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password"
},
"home": {
"title": "Welcome",
"description": "We provide the best service",
"cta": "Get Started"
},
"blog": {
"title": "Blog",
"readMore": "Read more",
"publishedAt": "Published on {date}",
"readingTime": "{minutes} min read"
},
"validation": {
"required": "Please enter {field}",
"email": "Please enter a valid email",
"minLength": "{field} must be at least {min} characters"
}
}json
// messages/ko.json
{
"common": {
"submit": "제출",
"cancel": "취소",
"save": "저장",
"delete": "삭제",
"loading": "로딩 중...",
"error": "오류가 발생했습니다",
"confirm": "확인"
},
"nav": {
"home": "홈",
"about": "소개",
"blog": "블로그",
"contact": "문의"
},
"auth": {
"login": "로그인",
"logout": "로그아웃",
"signup": "회원가입",
"email": "이메일",
"password": "비밀번호",
"forgotPassword": "비밀번호 찾기"
},
"home": {
"title": "환영합니다",
"description": "최고의 서비스를 제공합니다",
"cta": "시작하기"
},
"blog": {
"title": "블로그",
"readMore": "더 읽기",
"publishedAt": "{date}에 게시됨",
"readingTime": "읽는 시간 {minutes}분"
},
"validation": {
"required": "{field}을(를) 입력해주세요",
"email": "유효한 이메일을 입력해주세요",
"minLength": "{field}은(는) 최소 {min}자 이상이어야 합니다"
}
}json
// messages/en.json
{
"common": {
"submit": "Submit",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"loading": "Loading...",
"error": "An error occurred",
"confirm": "Confirm"
},
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"auth": {
"login": "Log in",
"logout": "Log out",
"signup": "Sign up",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot password"
},
"home": {
"title": "Welcome",
"description": "We provide the best service",
"cta": "Get Started"
},
"blog": {
"title": "Blog",
"readMore": "Read more",
"publishedAt": "Published on {date}",
"readingTime": "{minutes} min read"
},
"validation": {
"required": "Please enter {field}",
"email": "Please enter a valid email",
"minLength": "{field} must be at least {min} characters"
}
}번역 사용 패턴
翻译使用模式
Server Component에서 사용
在Server Component中使用
tsx
// src/app/[locale]/page.tsx
import { useTranslations } from "next-intl";
export default function HomePage() {
const t = useTranslations("home");
return (
<main>
<h1>{t("title")}</h1>
<p>{t("description")}</p>
<Button>{t("cta")}</Button>
</main>
);
}tsx
// src/app/[locale]/page.tsx
import { useTranslations } from "next-intl";
export default function HomePage() {
const t = useTranslations("home");
return (
<main>
<h1>{t("title")}</h1>
<p>{t("description")}</p>
<Button>{t("cta")}</Button>
</main>
);
}Client Component에서 사용
在Client Component中使用
tsx
"use client";
import { useTranslations } from "next-intl";
function LoginForm() {
const t = useTranslations("auth");
const tCommon = useTranslations("common");
return (
<form>
<Input placeholder={t("email")} />
<Input placeholder={t("password")} type="password" />
<Button type="submit">{t("login")}</Button>
<Button variant="outline">{tCommon("cancel")}</Button>
</form>
);
}tsx
"use client";
import { useTranslations } from "next-intl";
function LoginForm() {
const t = useTranslations("auth");
const tCommon = useTranslations("common");
return (
<form>
<Input placeholder={t("email")} />
<Input placeholder={t("password")} type="password" />
<Button type="submit">{t("login")}</Button>
<Button variant="outline">{tCommon("cancel")}</Button>
</form>
);
}변수 삽입 (ICU 문법)
变量插入(ICU语法)
tsx
const t = useTranslations("blog");
// 단순 변수
<p>{t("publishedAt", { date: "2024-01-15" })}</p>
// → "2024-01-15에 게시됨" (ko)
// → "Published on 2024-01-15" (en)
// 복수형 (messages에 정의)
// "items": "{count, plural, =0 {항목 없음} one {1개 항목} other {{count}개 항목}}"
<p>{t("items", { count: 5 })}</p>
// → "5개 항목"
// 리치 텍스트
// "terms": "<link>이용약관</link>에 동의합니다"
<p>
{t.rich("terms", {
link: (chunks) => <a href="/terms">{chunks}</a>,
})}
</p>tsx
const t = useTranslations("blog");
// 简单变量
<p>{t("publishedAt", { date: "2024-01-15" })}</p>
// → "2024-01-15에 게시됨"(韩语)
// → "Published on 2024-01-15"(英语)
// 复数形式(在messages中定义)
// "items": "{count, plural, =0 {항목 없음} one {1개 항목} other {{count}개 항목}}"
<p>{t("items", { count: 5 })}</p>
// → "5개 항목"
// 富文本
// "terms": "<link>이용약관</link>에 동의합니다"
<p>
{t.rich("terms", {
link: (chunks) => <a href="/terms">{chunks}</a>,
})}
</p>날짜/숫자 포맷
日期/数字格式化
tsx
import { useFormatter } from "next-intl";
function PriceDisplay({ price, date }: { price: number; date: Date }) {
const format = useFormatter();
return (
<div>
<p>
{format.number(price, { style: "currency", currency: "KRW" })}
</p>
{/* → "₩10,000" (ko) / "$10,000" (en, currency에 따라) */}
<p>
{format.dateTime(date, { year: "numeric", month: "long", day: "numeric" })}
</p>
{/* → "2024년 1월 15일" (ko) / "January 15, 2024" (en) */}
<p>
{format.relativeTime(date)}
</p>
{/* → "3일 전" (ko) / "3 days ago" (en) */}
</div>
);
}tsx
import { useFormatter } from "next-intl";
function PriceDisplay({ price, date }: { price: number; date: Date }) {
const format = useFormatter();
return (
<div>
<p>
{format.number(price, { style: "currency", currency: "KRW" })}
</p>
{/* → "₩10,000"(韩语)/ "$10,000"(英语,根据货币类型) */}
<p>
{format.dateTime(date, { year: "numeric", month: "long", day: "numeric" })}
</p>
{/* → "2024년 1월 15일"(韩语)/ "January 15, 2024"(英语) */}
<p>
{format.relativeTime(date)}
</p>
{/* → "3일 전"(韩语)/ "3 days ago"(英语) */}
</div>
);
}언어 전환 컴포넌트
语言切换组件
tsx
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const localeLabels: Record<string, string> = {
ko: "한국어",
en: "English",
};
function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
function handleChange(newLocale: string) {
router.replace(pathname, { locale: newLocale });
}
return (
<Select value={locale} onValueChange={handleChange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(localeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export { LocaleSwitcher };tsx
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const localeLabels: Record<string, string> = {
ko: "한국어",
en: "English",
};
function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
function handleChange(newLocale: string) {
router.replace(pathname, { locale: newLocale });
}
return (
<Select value={locale} onValueChange={handleChange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(localeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export { LocaleSwitcher };네비게이션 링크
导航链接
tsx
import { Link } from "@/i18n/routing";
// 로케일이 자동으로 URL에 포함됨
<Link href="/about">소개</Link>
// ko → /ko/about
// en → /en/abouttypescript
// src/i18n/routing.ts — navigation 포함
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({
locales: ["ko", "en"],
defaultLocale: "ko",
});
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);tsx
import { Link } from "@/i18n/routing";
// 本地化会自动包含在URL中
<Link href="/about">소개</Link>
// 韩语 → /ko/about
// 英语 → /en/abouttypescript
// src/i18n/routing.ts — 包含导航
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({
locales: ["ko", "en"],
defaultLocale: "ko",
});
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);SEO + i18n 연동
SEO + i18n 联动
로케일별 Metadata
本地化元数据
tsx
// src/app/[locale]/layout.tsx
import { getTranslations } from "next-intl/server";
export async function generateMetadata({ params }: LayoutProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: t("title"),
description: t("description"),
alternates: {
canonical: `https://example.com/${locale}`,
languages: {
ko: "https://example.com/ko",
en: "https://example.com/en",
},
},
};
}tsx
// src/app/[locale]/layout.tsx
import { getTranslations } from "next-intl/server";
export async function generateMetadata({ params }: LayoutProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "metadata" });
return {
title: t("title"),
description: t("description"),
alternates: {
canonical: `https://example.com/${locale}`,
languages: {
ko: "https://example.com/ko",
en: "https://example.com/en",
},
},
};
}로케일별 Sitemap
本地化站点地图
typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
const locales = ["ko", "en"];
export default function sitemap(): MetadataRoute.Sitemap {
const pages = ["", "/about", "/blog"];
return pages.flatMap((page) =>
locales.map((locale) => ({
url: `https://example.com/${locale}${page}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: page === "" ? 1 : 0.8,
}))
);
}typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
const locales = ["ko", "en"];
export default function sitemap(): MetadataRoute.Sitemap {
const pages = ["", "/about", "/blog"];
return pages.flatMap((page) =>
locales.map((locale) => ({
url: `https://example.com/${locale}${page}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: page === "" ? 1 : 0.8,
}))
);
}번역 키 관리 규칙
翻译键管理规则
- 네임스페이스: 페이지/기능별로 분리 (,
nav,auth,home)blog - 공통 키: 네임스페이스에 모아서 재사용
common - 네이밍: camelCase, 구체적인 이름 (>
submitButton)btn1 - 구조화: 2단계까지만 중첩 (깊은 중첩 지양)
- 변수: ICU MessageFormat 사용 (,
{count}){name} - 누락 방지: TypeScript 타입으로 번역 키 검증
- 命名空间:按页面/功能拆分(,
nav,auth,home)blog - 公共键:集中在命名空间中复用
common - 命名规范:使用camelCase,名称具体(>
submitButton)btn1 - 结构限制:最多嵌套2层(避免深层嵌套)
- 变量处理:使用ICU MessageFormat(,
{count}){name} - 避免遗漏:通过TypeScript类型验证翻译键
TypeScript 타입 안전성
TypeScript类型安全性
typescript
// src/types/i18n.d.ts
import ko from "@/messages/ko.json";
type Messages = typeof ko;
declare module "next-intl" {
interface IntlMessages extends Messages {}
}typescript
// src/types/i18n.d.ts
import ko from "@/messages/ko.json";
type Messages = typeof ko;
declare module "next-intl" {
interface IntlMessages extends Messages {}
}실행 규칙
执行规则
- 인자 시 next-intl 초기 설정을 전체 진행한다
setup - 파일 경로가 전달되면 해당 파일에 번역을 적용한다
- 인자 시 새 언어를 추가한다
add-locale [locale] - 번역 파일 생성 시 기존 키 구조를 분석하여 일관성을 유지한다
- 하드코딩된 한국어 문자열을 탐지하여 번역 키로 추출을 제안한다
- 언어 전환 시 URL 구조와 SEO를 함께 고려한다
- 当传入参数时,完整执行next-intl初始配置
setup - 若传入文件路径,则在该文件中应用翻译
- 当传入参数时,添加新语言
add-locale [locale] - 创建翻译文件时,分析现有键结构以保持一致性
- 检测硬编码的韩语字符串,建议将其提取为翻译键
- 切换语言时,同时考虑URL结构和SEO