Technology Aug 24, 2026 · 8 min read

6 Pitfalls of Building a Multilingual Site with Next.js 15: From Query Strings to URL Paths

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...

DE
DEV Community
by Mark
6 Pitfalls of Building a Multilingual Site with Next.js 15: From Query Strings to URL Paths

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 stringsubdirectorypath 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=zh and / 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:

  1. Perfect SEO (each language has its own URL)
  2. Next.js natively supports [locale] dynamic routing
  3. Works with generateStaticParams() for static prerendering
  4. 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 of href — 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:

  • params is a Promise — must await props.params
  • Server Components can await import() translation files (tree-shaking friendly)
  • alternates.languages tells 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:

  1. Global components first (Header/Footer)
  2. Batch tool page metadata (script-generated)
  3. Tool component UI text (placeholders, labels, etc.)
  4. 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:

  1. Multilingual sites need separate URLs — Search engines must distinguish language versions; query strings won't cut it
  2. [locale] route is the most Next.js-native approachgenerateStaticParams works perfectly with static export
  3. Don't nest root and locale layouts — Only locale layout should render <html>
  4. Use hard navigation for language switchinglocation.href instead of router.push to ensure metadata re-SSR
  5. generateMetadata params is a Promise — Next.js 15 change; forgetting await means no locale
  6. Sitemap must explicitly generate each language's URL — Won't auto-infer
  7. i18n key naming needs a convention — 500+ keys without one is chaos
  8. alternates.languages is 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.

DE
Source

This article was originally published by DEV Community and written by Mark.

Read original article on DEV Community
Back to Discover

Reading List