Loading...
Loading...
Next.js 앱의 다국어(국제화)를 설정하고 구현하는 스킬. next-intl 기반 번역, 로케일 라우팅, 날짜/숫자 포맷, RTL 지원 등. "다국어", "i18n", "internationalization", "번역", "translation", "로케일", "locale", "다언어" 등의 요청 시 사용.
npx skill4agent add ingpdw/pdw-fe-dev-tool fe-i18n$ARGUMENTSpnpm add next-intlsrc/
├── app/
│ └── [locale]/ # 로케일별 라우팅
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/page.tsx
├── i18n/
│ ├── request.ts # 서버 사이드 i18n 설정
│ └── routing.ts # 라우팅 설정
├── messages/ # 번역 파일
│ ├── ko.json
│ └── en.json
└── middleware.ts # 로케일 감지 & 리다이렉트// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["ko", "en"],
defaultLocale: "ko",
});// 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|.*\\..*).*)"],
};// 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,
};
});// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const nextConfig = {};
export default withNextIntl(nextConfig);// 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>
);
}// 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}자 이상이어야 합니다"
}
}// 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"
}
}// 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>
);
}"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>
);
}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>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>
);
}"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 };import { Link } from "@/i18n/routing";
// 로케일이 자동으로 URL에 포함됨
<Link href="/about">소개</Link>
// ko → /ko/about
// en → /en/about// 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);// 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",
},
},
};
}// 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,
}))
);
}navauthhomeblogcommonsubmitButtonbtn1{count}{name}// src/types/i18n.d.ts
import ko from "@/messages/ko.json";
type Messages = typeof ko;
declare module "next-intl" {
interface IntlMessages extends Messages {}
}setupadd-locale [locale]