I built a 150+ tool website with Next.js 15 on Cloudflare Pages (utlkit.com).
Half the traffic was from China, half from elsewhere. Time to add i18n.
I thought it would take a day. It took four, and I rewrote the approach three times.
Why is i18n Harder Than You Think?
A 100+ page tool site needs more than translated text. You need to handle:
| Layer | Problem |
|---|---|
| Routing | Does the URL change on language switch? How does Google distinguish en/zh pages? |
| SEO | Bilingual title/description/keywords? How to set canonical? |
| Static Export | Many i18n approaches are incompatible with output: 'export'
|
| Hydration | Different locale SSR vs CSR causing mismatches |
| Scale | 104 tool pages × 500+ i18n keys — can't do this manually |
I went through query string → subdirectory → path prefix before settling on the [locale] route segment approach.
Approach Comparison
Approach 1: Query String (First Attempt, Quickly Abandoned)
URL format: /tools/bmi-calculator/?lang=zh-CN
Pros:
- Minimal code changes, just read the query parameter
Fatal Flaws:
-
SEO disaster — Search engines treat
?lang=zhand/as the same page. Chinese content becomes duplicate content. - Cache issues — Cloudflare caches by URL. The cached version without query overrides the version with query.
- Social sharing — Users share the link without the lang parameter.
Verdict: Never do this for a multilingual site.
Approach 2: Subdirectory
URL format: /en/tools/bmi-calculator/ and /zh/tools/bmi-calculator/
This is the most common multilingual URL pattern (GitHub, Stripe use this).
Pros:
- SEO-friendly, search engines clearly distinguish language versions
- Industry best practice
Approach 3: Path Prefix [locale] (Final Choice)
URL format: /en/tools/bmi-calculator/ and /zh-CN/tools/bmi-calculator/
Essentially the same as Approach 2, but using Next.js's [locale] dynamic route segment instead of separate /en/ and /zh/ directories.
Why this won:
- Perfect SEO (each language has its own URL)
- Next.js natively supports
[locale]dynamic routing - Works with
generateStaticParams()for static prerendering - Adding a third language later is just adding a string
Pitfall 1: Nested Root Layout
The Problem
Page crashes or goes white after switching language.
Root Cause
In Next.js 15 App Router, both app/layout.tsx and app/[locale]/layout.tsx render. If both include <html> and <body> tags, you get nested HTML:
<html> <!-- app/layout.tsx -->
<html lang="zh-CN"> <!-- app/[locale]/layout.tsx -->
<body>
<body>
<!-- actual content -->
</body>
</body>
</html>
</html>
Double <html> → double Hydration → unpredictable bugs.
Fix
Root layout returns only children:
// app/layout.tsx — outputs metadata only, no tags
export default function RootLayout({ children }) {
return children
}
Locale layout renders complete HTML:
// app/[locale]/layout.tsx — renders full html/head/body
<html lang={locale}>
<head>...</head>
<body>
<I18nProvider locale={locale}>
{children}
</I18nProvider>
</body>
</html>
Pitfall 2: redirect() Doesn't Work with Static Export
The Problem
Wanted to redirect root path / to /en/ or /zh-CN/ based on browser language, but redirect() did nothing.
Root Cause
Next.js redirect() relies on server-side runtime. In output: 'export' (pure static export) mode, there is no server runtime — redirect() is silently skipped.
Fix
Client-side JS detection with redirect:
// app/page.tsx
export default function RootPage() {
return (
<>
<script dangerouslySetInnerHTML={{
__html: `(function(){
var lang = (navigator.language || 'en').toLowerCase();
var target = '/en/';
if (lang.indexOf('zh') === 0) target = '/zh-CN/';
window.location.replace(target);
})()`,
}} />
{/* Fallback for no-JS environments */}
<noscript>
<meta httpEquiv="refresh" content="0;url=/en/" />
</noscript>
</>
)
}
Key points:
-
window.location.replace()instead ofhref— no extra history entry -
<noscript>fallback — prevents being stuck in no-JS environments
Pitfall 3: Passing Locale to generateMetadata
The Problem
All tool page titles showed in English even when visiting /zh-CN/.
Root Cause
In Next.js 15, generateMetadata() receives params as an async promise:
// ❌ Old way — params is a Promise, not a plain object
export async function generateMetadata({ params }) {
const { locale } = params // undefined or error
}
Fix
// ✅ Next.js 15 way: params is a Promise
export async function generateMetadata(props) {
const { locale, slug } = await props.params
const [en, zhCN] = await Promise.all([
import('@/lib/i18n/en'),
import('@/lib/i18n/zh-CN'),
])
const dict = { en: en.default, 'zh-CN': zhCN.default }
const t = (key) => dict[locale]?.[key] || key
return {
title: `${t(tool.nameI18n)} - UtlKit`,
description: t(tool.descI18n),
alternates: {
canonical: `https://utlkit.com/${locale}/tools/${slug}/`,
languages: {
'en': `https://utlkit.com/en/tools/${slug}/`,
'zh-CN': `https://utlkit.com/zh-CN/tools/${slug}/`,
},
},
}
}
Key points:
-
paramsis a Promise — mustawait props.params - Server Components can
await import()translation files (tree-shaking friendly) -
alternates.languagestells search engines about multilingual page variants
Pitfall 4: Content Doesn't Refresh on Language Switch
The Problem
User switches language from Header. URL changes but page content stays the same.
Root Cause
I18nProvider's setLocale initially only changed React state:
// ❌ Only changes state, doesn't refresh the page
const setLocale = (locale) => {
setLocaleState(locale)
setStoredLocale(locale)
}
With [locale] routing, a language change requires navigating to a different URL (from /en/xxx to /zh-CN/xxx), not just changing state — because server-rendered SEO metadata is URL-based.
Fix
setLocale navigates to the new URL:
const setLocale = useCallback((newLocale) => {
if (typeof window === 'undefined') return
const currentPath = window.location.pathname
const parts = currentPath.split('/')
const firstSegment = parts[1]
if (firstSegment === 'en' || firstSegment === 'zh-CN') {
// In [locale] route — replace language segment in URL
const afterLocale = currentPath.slice(firstSegment.length + 1)
const newPath = '/' + newLocale + (afterLocale ? '/' + afterLocale : '')
window.location.href = newPath // Hard navigation, re-SSR
return
}
// Fallback: root route
setLocaleState(newLocale)
setStoredLocale(newLocale)
}, [])
Key decision: Use window.location.href hard navigation instead of router.push — because we need full SSR to update metadata.
Pitfall 5: Sitemap Doesn't Support Multilingual
The Problem
Sitemap only generated English URLs. Chinese pages weren't indexed.
Root Cause
app/sitemap.ts defaulted to single-language URLs:
// ❌ English only
{ url: 'https://utlkit.com/tools/bmi-calculator/' }
Fix
Generate bilingual URLs for every page:
// app/sitemap.ts
export default function sitemap() {
const baseUrl = 'https://utlkit.com'
const locales = ['en', 'zh-CN']
const entries = [{ url: baseUrl, priority: 1 }] // root URL
// Static pages: en + zh-CN
for (const locale of locales) {
for (const slug of ['about', 'privacy', 'terms', 'contact']) {
entries.push({ url: `${baseUrl}/${locale}/${slug}/`, priority: 0.5 })
}
}
// Tool pages: 104 tools × 2 languages = 208 entries
for (const locale of locales) {
for (const tool of tools) {
entries.push({
url: `${baseUrl}/${locale}/tools/${tool.slug}/`,
priority: locale === 'en' ? 0.8 : 0.6,
})
}
}
return entries
}
Final count: 213 URLs (102 tools × 2 + 4 static × 2 + root + sitemap page × 2).
Bonus: The sitemap page's <link> elements also needed to be locale-aware, otherwise they'd link to old paths.
Pitfall 6: 500+ i18n Keys to Translate
The Problem
104 tool pages, each with title, description, placeholder, label, tooltip, FAQ — the translation workload far exceeded expectations.
Solution
Establish a naming convention for i18n keys:
// tools.ts — tool definitions with i18n keys
{
nameI18n: 'tools.bmi.title', // → en: 'BMI Calculator'
descI18n: 'tools.bmi.description', // → en: 'Calculate your BMI...'
}
Batch process with scripts:
// scripts/fix-tool-metadata.js
// Batch-update 102 tool page metadata from hardcoded to i18n
const tools = require('../src/lib/tools')
for (const tool of tools) {
// Generate bilingual metadata template...
}
Phased rollout:
- Global components first (Header/Footer)
- Batch tool page metadata (script-generated)
- Tool component UI text (placeholders, labels, etc.)
- FAQ and special pages last
Total: ~500+ i18n keys covering all user-visible text.
Final Architecture
┌─────────────────────────────────────────────┐
│ Root path / │
│ Detects navigator.language, JS redirects │
├─────────────────────────────────────────────┤
│ /[locale]/layout.tsx │
│ - generateStaticParams: ['en', 'zh-CN'] │
│ - generateMetadata: bilingual metadata │
│ - Renders complete <html lang={locale}> │
├─────────────────────────────────────────────┤
│ /[locale]/page.tsx (home page) │
│ - Bilingual metadata │
│ - Hero section + tool list │
├─────────────────────────────────────────────┤
│ /[locale]/tools/[slug]/page.tsx (tool page) │
│ - generateMetadata: reads translation │
│ - alternates.languages: bilingual canonical │
├─────────────────────────────────────────────┤
│ I18nProvider (client) │
│ - Reads locale from URL path (SSR passed) │
│ - setLocale: navigates to target URL │
│ - t(key): translation function │
│ - href(path): generates locale-aware links │
└─────────────────────────────────────────────┘
Lessons Learned
| Approach | Result | Rating |
|---|---|---|
Query string ?lang=zh
|
❌ SEO disaster | ⭐ |
Subdomain zh.example.com
|
⚠️ Complex deployment | ⭐⭐ |
Path prefix [locale]
|
✅ Perfect SEO | ⭐⭐⭐⭐⭐ |
| Only change React state | ❌ Metadata out of sync | ⭐ |
Hard navigation location.href
|
✅ Full SSR refresh | ⭐⭐⭐⭐ |
Key takeaways:
- Multilingual sites need separate URLs — Search engines must distinguish language versions; query strings won't cut it
-
[locale]route is the most Next.js-native approach —generateStaticParamsworks perfectly with static export -
Don't nest root and locale layouts — Only locale layout should render
<html> -
Use hard navigation for language switching —
location.hrefinstead ofrouter.pushto ensure metadata re-SSR -
generateMetadataparams is a Promise — Next.js 15 change; forgettingawaitmeans no locale - Sitemap must explicitly generate each language's URL — Won't auto-infer
- i18n key naming needs a convention — 500+ keys without one is chaos
-
alternates.languagesis critical for SEO — Tells search engines about all language variants of each page
Project
UtlKit — 150+ free online tools with English and Chinese support. All the above problems were encountered during real development and deployment. Solutions are running in production.
If this helped, feel free to drop a ⭐️. Comments welcome.
This article was originally published by DEV Community and written by Mark.
Read original article on DEV Community