DEV Community

jidong
jidong

Posted on

How I Added 6 Languages to a Next.js App in One Day

I decided to ship my AI fortune-telling app to 6 countries. Korea, US, Japan, China, Vietnam, India.

Will Korean fortune-telling (saju — four-pillar destiny analysis based on birth date and time) work outside East Asia? No idea. But tarot and astrology are global. "AI analyzes your destiny" probably gets clicks everywhere.

The problem: hardcoded Korean strings were scattered across every page.

Why next-intl

For Next.js 15 App Router, next-intl was the cleanest option. Native App Router support with [locale] dynamic segments. Middleware auto-redirects based on browser language. Same useTranslations() hook works in both Server and Client Components.

apps/web/
├── app/
│   ├── [locale]/          ← all pages live here now
│   │   ├── page.tsx
│   │   ├── result/page.tsx
│   │   └── layout.tsx     ← html lang={locale} here
│   └── layout.tsx          ← empty pass-through
├── i18n/
│   ├── config.ts           ← locales, defaultLocale
│   ├── routing.ts          ← localePrefix: "as-needed"
│   └── navigation.ts       ← i18n-aware Link, useRouter
├── messages/
│   ├── ko.json, en.json, ja.json
│   ├── zh.json, vi.json, hi.json
└── middleware.ts            ← Accept-Language detection
Enter fullscreen mode Exit fullscreen mode

The key setting is localePrefix: "as-needed". Korean is default, so / serves Korean. /en/ serves English. Korean users never see /ko/ in their URL.

The Biggest Gotcha: Nested html Tags

I knew Next.js App Router requires root layout to render <html> and <body>. So I put them in root layout AND in [locale]/layout.tsx with <html lang={locale}>.

Result: html inside html. Browsers silently ignore it, but the DOM is completely wrong.

// app/layout.tsx — the fix
export default function RootLayout({ children }) {
  return children;  // no html/body, just pass through
}

// app/[locale]/layout.tsx — this owns html/body
export default function LocaleLayout({ children, params }) {
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Root layout returning just children works fine in Next.js 15. The [locale] layout provides html/body, so the framework is satisfied.

Localized Pricing

Charging $9.90 in India means zero sales. Each country gets pricing matched to local purchasing power.

// ko.json  "₩12,900"
// en.json  "$9.90"
// ja.json  "¥1,490"
// zh.json  "¥68"
// vi.json  "199.000₫"
// hi.json  "₹799"
Enter fullscreen mode Exit fullscreen mode

Prices are hardcoded in translation files. When payment integration comes later, the server will handle this. But for MVP, this is the fastest path. Even placeholder names are localized — Korea gets "홍길동", Japan gets "山田太郎", India gets "राहुल शर्मा".

Import Path Hell

Moving app/page.tsx to app/[locale]/page.tsx means every import gets one level deeper.

// Before — app/page.tsx
import { types } from "../lib/types";

// After — app/[locale]/page.tsx
import { types } from "../../lib/types";
Enter fullscreen mode Exit fullscreen mode

Fifteen files. Dozens of imports. One wrong path and the build breaks. The deepest page — report/[orderId]/page.tsx — needed ../../../../lib/types. Four levels up. TypeScript caught every mistake, which is the only reason this worked.

The Result

Build output shows pages generated for every locale.

├ /ko/result
├ /en/result
├ /ja/result
├ /zh/result
├ /vi/result
├ /hi/result
Enter fullscreen mode Exit fullscreen mode

Browser set to Japanese? Auto-redirect to /ja/. Header dropdown lets you switch manually. 한국어 ↔ English ↔ 日本語 ↔ 中文 ↔ Tiếng Việt ↔ हिन्दी.

Not bad for a day's work. Visitors from 6 countries each see "AI analyzes your destiny" in their own language, with their own currency, and a name placeholder they recognize.

"Going global isn't translation — it's localization. Different prices, different names, different currencies. The words are the easy part."

Top comments (0)