Next.js Internationalization with Static Site Generation: Building Multilingual SSG Sites

To be honest, when I first tried implementing multilingual static generation in a Next.js App Router project, I really ran into a ton of issues. You might have had similar experiences: everything seems configured correctly according to the docs, but then the build fails. Or it finally builds successfully, but takes 15 minutes to generate all the pages…
Let me share a few typical scenarios I’ve encountered - see if they sound familiar.
Have You Encountered These Issues?
Scenario 1: Build-Time Errors
I remember once excitedly running npm run build, and the terminal just threw an error at me:
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.I was completely confused, thinking: “What the heck? I clearly configured i18n in next.config.js!” Later I discovered that internationalization in App Router works completely differently from Pages Router - the old configuration simply doesn’t work anymore.
Scenario 2: Excessive Build Time
Another time, my project supported 6 languages with about 50 pages per language. The build took:
✓ Generating static pages (152/152) - 15m 32s15 minutes! You read that right. Having to wait this long every time I made a small change was crushing the developer experience. I kept thinking: “If this goes to production, won’t CI/CD wait forever?”
Scenario 3: Translation Updates Not Taking Effect
The most frustrating issue was this: I updated the zh-CN.json translation file, rebuilt and redeployed, but the website still showed the old translations! I had to clear the browser cache to see the new content. This is a disaster in production - users see outdated content.
What’s the Root Cause?
After spending considerable time researching, I finally understood the essence of these problems:
App Router no longer supports Pages Router’s i18n configuration - This is the biggest gotcha. Configuring the
i18nfield innext.config.jssimply doesn’t work with App Router.Conflict between static export and dynamic rendering - When you set
output: 'export', Next.js requires all pages to be determined at build time. If you use dynamic APIs likecookies()orheaders(), it will fail.Translation file caching mechanism - Next.js caches imported JSON files. When you update translations during development, the cache doesn’t invalidate, so you can’t see the latest content.
If you’ve encountered these issues, this article is for you. I’ll walk you through step-by-step how to properly implement multilingual static generation in Next.js App Router and avoid these pitfalls.
Understanding App Router’s New i18n Paradigm
Before we start coding, I think it’s important to understand App Router’s internationalization approach. It’s really quite different from Pages Router.
Pages Router vs App Router: Two Completely Different Approaches
I’ve created a comparison table so you can see the differences at a glance:
| Feature | Pages Router | App Router |
|---|---|---|
| Configuration | i18n field in next.config.js | middleware + dynamic route [lang] |
| Route structure | Auto-generates /en/, /zh/ prefixes | Manually create app/[lang]/page.tsx |
| Static generation | Uses getStaticPaths | Uses generateStaticParams |
| Translation loading | serverSideTranslations function | Server components directly import JSON |
See? Almost everything has changed. When I first encountered this, I honestly felt like “I must have learned fake Next.js.”
What Exactly is generateStaticParams?
This is one of the core concepts in App Router. Simply put, its purpose is to tell Next.js: “I need to generate static pages for these parameters.”
Here’s an example:
// app/[lang]/layout.tsx
export async function generateStaticParams() {
// Return all language parameters that need pre-rendering
return [
{ lang: 'en' },
{ lang: 'zh' },
{ lang: 'ja' }
]
}Next.js executes this function during build, gets the returned parameter list, and then generates a static HTML file for each parameter combination. The final output is:
out/
├── en/
│ └── index.html
├── zh/
│ └── index.html
└── ja/
└── index.htmlKey point: This function must be defined in layout.tsx or page.tsx, and the function name must match exactly (not getStaticParams, not generateParams, must be generateStaticParams).
How to Load Translation Files?
In the Pages Router era, we used the serverSideTranslations function from the next-i18next library. But in App Router, you can directly import translation files in server components:
// Server components can do this directly
import enTranslations from '@/i18n/locales/en/common.json'
import zhTranslations from '@/i18n/locales/zh-CN/common.json'
const translations = {
'en': enTranslations,
'zh-CN': zhTranslations,
}
export default function Page({ params }: { params: { lang: string } }) {
const t = translations[params.lang]
return <h1>{t.title}</h1>
}But there’s a problem with this approach: all language translations get bundled, making the file larger. So in real projects, we usually write a loader function:
// i18n/utils.ts
export async function loadTranslations(locale: string, namespaces: string[]) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
try {
const translation = await import(`@/i18n/locales/${locale}/${ns}.json`)
translations[ns] = translation.default
} catch (error) {
console.warn(`Translation file not found: ${locale}/${ns}`)
translations[ns] = {}
}
}
return translations
}This way you can load on-demand, only loading the translation namespaces needed for the current page.
Hands-On: Building a Multilingual SSG Project from Scratch
Alright, theory’s done - now let’s code. I’ll walk you through building a complete multilingual static site from start to finish.
Step 1: Design the Project Structure
First, we need to establish a clear directory structure. This is the structure I use in real projects - tested and proven:
app/
├── [lang]/ # Language dynamic route (core)
│ ├── layout.tsx # Root layout, contains generateStaticParams
│ ├── page.tsx # Home page
│ ├── about/
│ │ └── page.tsx # About page
│ └── blog/
│ ├── page.tsx # Blog list
│ └── [slug]/
│ └── page.tsx # Blog details (nested dynamic route)
├── i18n/
│ ├── locales/ # Translation files directory
│ │ ├── en/
│ │ │ ├── common.json # Common translations
│ │ │ ├── home.json # Home page translations
│ │ │ └── blog.json # Blog translations
│ │ ├── zh-CN/
│ │ │ ├── common.json
│ │ │ ├── home.json
│ │ │ └── blog.json
│ │ └── ja/
│ │ ├── common.json
│ │ ├── home.json
│ │ └── blog.json
│ ├── config.ts # i18n configuration file
│ └── utils.ts # Translation utility functions
└── middleware.ts # Language detection and redirectionWhy this design?
[lang]folder: This is the core of dynamic routing - Next.js passes the language parameter from the URL to page components.- Namespace-based translation files: Avoid one huge translation file, split by page functionality, load on-demand.
- Centralized
config.ts: All language-related configuration in one place for easy maintenance.
Step 2: Configure Core i18n Files
Let’s write the configuration file - this is the foundation of the entire system:
// i18n/config.ts
export const i18nConfig = {
// Supported languages list
locales: ['en', 'zh-CN', 'ja'],
// Default language
defaultLocale: 'en',
// Path prefix strategy
// 'always': All languages get prefix /en/, /zh-CN/
// 'as-needed': Default language has no prefix, others do
localePrefix: 'always',
// [IMPORTANT] Only pre-render major languages (optimize build time)
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // Production: only pre-render English and Chinese
: ['en'], // Development: only render default language
} as const
// Export types for TypeScript type checking
export type Locale = (typeof i18nConfig)['locales'][number]
// Translation namespaces (for code splitting)
export const namespaces = ['common', 'home', 'about', 'blog'] as const
export type Namespace = (typeof namespaces)[number]Key Points Explained:
as const: This is TypeScript syntax ensuring the type is precise literal types, not broadstring[].localesToPrerender: This is crucial! If you support 10 languages but only pre-render 2 major ones, build time can be reduced by 80%. Other languages can use ISR (Incremental Static Regeneration) or on-demand generation.- Namespaces: Split translation files into multiple JSONs to avoid downloading one huge translation file on first load.
Step 3: Implement Translation Loading Utility
This is a simple but practical translation loader:
// i18n/utils.ts
import type { Locale, Namespace } from './config'
// Translation file cache (avoid repeated reads)
const translationsCache = new Map<string, any>()
/**
* Load translation files for specified language
*
* @param locale Language code like 'en', 'zh-CN'
* @param namespaces Translation namespace array like ['common', 'home']
* @returns Translation object { common: {...}, home: {...} }
*/
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
const translations: Record<string, any> = {}
for (const namespace of namespaces) {
const cacheKey = `${locale}-${namespace}`
// Check cache to avoid repeated loading
if (!translationsCache.has(cacheKey)) {
try {
// Dynamic import of translation file
const translation = await import(
`@/i18n/locales/${locale}/${namespace}.json`
)
translationsCache.set(cacheKey, translation.default)
} catch (error) {
console.warn(`⚠️ Translation file not found: ${locale}/${namespace}.json`)
translationsCache.set(cacheKey, {})
}
}
translations[namespace] = translationsCache.get(cacheKey)
}
return translations
}
/**
* Create type-safe translation function
*
* Usage:
* const t = createTranslator(translations)
* t('common.nav.home')
* t('home.welcome', { name: 'John' }) // Supports variable replacement
*/
export function createTranslator(translations: any) {
return (key: string, params?: Record<string, string>) => {
const keys = key.split('.')
let value = translations
// Access nested properties layer by layer
for (const k of keys) {
value = value?.[k]
}
// Return key itself when translation not found (useful for debugging)
if (!value) {
console.warn(`⚠️ Translation missing: ${key}`)
return key
}
// Support variable replacement: replace {{name}} with actual value
if (params) {
return Object.entries(params).reduce(
(str, [key, val]) => str.replace(`{{${key}}}`, val),
value
)
}
return value
}
}Highlights of this utility:
- Caching mechanism: Cache after first load to avoid repeated file reads.
- Error handling: When translation file not found, doesn’t crash - just warns and returns empty object.
- Variable replacement: Supports using
{{variableName}}placeholders in translations. - Type-friendly: Works with TypeScript for type-safe translation key checking.
Step 4: Create Root Layout (Most Critical)
This is the core file of the entire multilingual system:
// app/[lang]/layout.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
/**
* [CORE] Generate static parameters for all languages
*
* This function executes at build time, Next.js generates corresponding static pages based on return value
*
* Important notes:
* 1. Function name must be generateStaticParams (can't misspell)
* 2. Must be defined in layout.tsx or page.tsx
* 3. Returned parameter names must match route folder names ([lang] → lang)
*/
export async function generateStaticParams() {
console.log(`🌍 Generating static params for ${i18nConfig.localesToPrerender.length} locales...`)
return i18nConfig.localesToPrerender.map((locale) => ({
lang: locale, // ⚠️ Note: Must be 'lang' not 'locale'
}))
}
/**
* Root layout component
*
* This component wraps all pages for setting global configuration
*/
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: { lang: string }
}) {
// Load common translations (navigation, footer, etc.)
const translations = await loadTranslations(params.lang as Locale, ['common'])
return (
<html
lang={params.lang}
// Set right-to-left layout for Arabic
dir={params.lang === 'ar' ? 'rtl' : 'ltr'}
>
<head>
{/* Add global meta tags here */}
</head>
<body>
{/* Place global components like navbar, footer here */}
{children}
</body>
</html>
)
}
/**
* Generate metadata (SEO)
*
* This function generates page <title>, <meta> tags, etc.
*/
export async function generateMetadata({ params }: { params: { lang: string } }) {
return {
// Set language-related meta tags
alternates: {
canonical: `https://example.com/${params.lang}`,
languages: {
'en': 'https://example.com/en',
'zh-CN': 'https://example.com/zh-CN',
'ja': 'https://example.com/ja',
},
},
// Open Graph tags (for social media sharing)
openGraph: {
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}Some Easy Pitfalls Here:
⚠️ Pitfall 1: Parameter names must match
// ❌ Wrong: parameter name is locale, but route folder is [lang]
export async function generateStaticParams() {
return [{ locale: 'en' }] // This will error
}
// ✅ Correct: parameter name matches folder name
export async function generateStaticParams() {
return [{ lang: 'en' }] // Must be lang
}⚠️ Pitfall 2: Can’t use dynamic APIs
// ❌ Wrong: using cookies in statically generated page
export default async function Layout({ children }) {
const locale = cookies().get('NEXT_LOCALE') // This causes build failure
return <html lang={locale}>{children}</html>
}
// ✅ Correct: use route parameters
export default async function Layout({ children, params }) {
return <html lang={params.lang}>{children}</html>
}Step 5: Create Translation Files
The structure of translation files is also important. Here’s my recommended format:
// i18n/locales/en/common.json
{
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"footer": {
"copyright": "© {{year}} All rights reserved",
"privacy": "Privacy Policy",
"terms": "Terms of Service"
},
"actions": {
"readMore": "Read More",
"backToTop": "Back to Top",
"share": "Share",
"edit": "Edit"
},
"messages": {
"loading": "Loading...",
"error": "Something went wrong",
"success": "Success",
"noResults": "No results found"
}
}// i18n/locales/en/blog.json
{
"title": "Blog Posts",
"publishedAt": "Published on",
"author": "Author",
"tags": "Tags",
"relatedPosts": "Related Posts",
"readingTime": "Reading time: {{minutes}} minutes",
"shareOn": "Share on {{platform}}"
}Best Practices for Translation Files:
- Hierarchical structure: Use nested objects to organize translations, don’t put all keys at the top level.
- Variable placeholders: Use
{{variableName}}format for consistent handling. - Keep keys consistent: All language translation files should have the same key structure.
- Add comments: Add comments next to complex translations explaining usage scenarios.
Step 6: Handle Nested Dynamic Routes
If your project has a blog or product detail pages, you need to handle nested dynamic routes. This was one of my biggest pitfalls.
// app/[lang]/blog/[slug]/page.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations, createTranslator } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
// Assume you have these helper functions (need to implement yourself in real projects)
async function getBlogSlugs(): Promise<string[]> {
// Get all blog post slugs from filesystem or CMS
return ['getting-started', 'advanced-tips', 'performance-guide']
}
async function getBlogPost(slug: string, locale: Locale) {
// Get blog post content for specific language
// ...
}
/**
* [KEY] generateStaticParams for nested routes
*
* Need to generate all combinations of language × posts
* For example: en/getting-started, zh-CN/getting-started, en/advanced-tips...
*/
export async function generateStaticParams() {
const startTime = Date.now()
console.log('📝 Generating blog post params...')
// Get all post slugs (only need one request)
const slugs = await getBlogSlugs()
// Use flatMap to generate all combinations of languages and posts
const params = i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({
lang: locale,
slug: slug,
}))
)
const duration = Date.now() - startTime
console.log(`✅ Generated ${params.length} blog post params in ${duration}ms`)
return params
}
/**
* Blog post page component
*/
export default async function BlogPost({
params,
}: {
params: { lang: string; slug: string }
}) {
// Load translations and post content
const [translations, post] = await Promise.all([
loadTranslations(params.lang as Locale, ['common', 'blog']),
getBlogPost(params.slug, params.lang as Locale),
])
const t = createTranslator(translations)
return (
<article className="prose">
<h1>{post.title}</h1>
<p className="text-gray-600">
{t('blog.publishedAt')}: {new Date(post.date).toLocaleDateString(params.lang)}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}Performance Optimization Key Point:
There’s a common mistake here. You might be tempted to fetch data separately for each language:
// ❌ Wrong approach: multiple requests, slow build
export async function generateStaticParams() {
const results = []
for (const locale of i18nConfig.localesToPrerender) {
// Query database or CMS once per language - too slow!
const slugs = await getBlogSlugs(locale)
results.push(...slugs.map(slug => ({ lang: locale, slug })))
}
return results
}The correct approach is to request data once, then use flatMap to generate combinations:
// ✅ Correct approach: one request, fast generation
export async function generateStaticParams() {
// Only one data request
const slugs = await getBlogSlugs()
// Use flatMap to generate all language × post combinations
return i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({ lang: locale, slug }))
)
}In my project, this optimization reduced build time from 18 minutes to 6 minutes - very noticeable effect!
Step 7: Implement Middleware for Language Detection
The middleware’s purpose is to automatically detect the user’s language preference and redirect to the corresponding language version.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from './i18n/config'
/**
* Middleware
*
* This function executes before each request for:
* 1. Detecting user's language preference
* 2. Redirecting to corresponding language path
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Check if path already includes language prefix
const pathnameHasLocale = i18nConfig.locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
// If language prefix already exists, pass through
if (pathnameHasLocale) return
// Get user's preferred language
const locale = getLocale(request) ?? i18nConfig.defaultLocale
// Redirect to path with language prefix
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
/**
* Language detection function
*
* Priority:
* 1. Language preference saved in Cookie
* 2. Accept-Language request header
* 3. Return null, use default language
*/
function getLocale(request: NextRequest): string | null {
// Priority 1: Check Cookie
const localeCookie = request.cookies.get('NEXT_LOCALE')?.value
if (localeCookie && i18nConfig.locales.includes(localeCookie as any)) {
return localeCookie
}
// Priority 2: Check Accept-Language request header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
// Accept-Language format: zh-CN,zh;q=0.9,en;q=0.8
const preferred = acceptLanguage.split(',')[0].split('-')[0]
const match = i18nConfig.locales.find(locale =>
locale.toLowerCase().startsWith(preferred.toLowerCase())
)
if (match) return match
}
// No matching language found, return null
return null
}
/**
* Middleware configuration
*
* matcher defines which paths need to execute middleware
*/
export const config = {
// Match all paths except:
// - API routes starting with /api
// - Static files at /_next/static
// - Images at /_next/image
// - Static resources like /favicon.ico
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Middleware Use Cases:
Suppose a user directly visits https://example.com/blog, the middleware will:
- Check if there’s a saved language preference in Cookie (e.g., user previously selected Chinese)
- If not, check the browser’s
Accept-Languageheader (browser automatically sends user’s system language) - Based on detection results, redirect to
https://example.com/zh-CN/blogorhttps://example.com/en/blog
This achieves automatic language detection for better user experience.
Step 8: Create Language Switcher Component
Finally, we need a language switcher so users can manually change languages:
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { i18nConfig } from '@/i18n/config'
import type { Locale } from '@/i18n/config'
// Language display name mapping
const localeNames: Record<Locale, string> = {
'en': 'English',
'zh-CN': '简体中文',
'ja': '日本語',
}
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname()
const router = useRouter()
const handleLocaleChange = (newLocale: Locale) => {
// Save language preference to Cookie
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`
// Replace language prefix in path
// Example: /zh-CN/blog → /en/blog
const newPathname = pathname.replace(`/${currentLocale}`, `/${newLocale}`)
// Navigate to new language version
router.push(newPathname)
}
return (
<div className="relative">
<select
value={currentLocale}
onChange={(e) => handleLocaleChange(e.target.value as Locale)}
className="px-4 py-2 border rounded-lg"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</div>
)
}Usage in navigation:
// components/Navigation.tsx
import { LanguageSwitcher } from './LanguageSwitcher'
export function Navigation({ lang }: { lang: string }) {
return (
<nav className="flex items-center justify-between p-4">
<div className="flex gap-4">
<a href={`/${lang}/`}>Home</a>
<a href={`/${lang}/about`}>About</a>
<a href={`/${lang}/blog`}>Blog</a>
</div>
<LanguageSwitcher currentLocale={lang} />
</nav>
)
}Performance Optimization: Making Builds Fly
Now the basic functionality is implemented, but if your website supports multiple languages, build time could be quite long. Let me share some practical optimization techniques.
Optimization 1: Selective Pre-rendering
This is the most effective optimization. If you support 10 languages but actual traffic is concentrated on 2-3 major ones, just pre-render the major languages:
// i18n/config.ts
export const i18nConfig = {
// All supported languages
locales: ['en', 'zh-CN', 'ja', 'ko', 'de', 'fr', 'es', 'pt'],
defaultLocale: 'en',
// [KEY] Only pre-render major languages
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // Production: only pre-render English and Chinese
: ['en'], // Development: only render default language (faster development)
}Performance Comparison:
| Configuration | Build Time | Description |
|---|---|---|
| Pre-render 8 languages | ~24 minutes | All languages generate static pages |
| Pre-render 2 languages | ~6 minutes | Other languages generated on first visit |
| Render only 1 language | ~3 minutes | Recommended for development |
Saved 75% of build time!
Optimization 2: Use Incremental Static Regeneration (ISR)
For less important languages or infrequently accessed pages, you can use ISR for on-demand generation:
// app/[lang]/blog/[slug]/page.tsx
// Enable ISR, revalidate after 1 hour
export const revalidate = 3600
export async function generateStaticParams() {
const slugs = await getBlogSlugs()
// Only pre-render popular posts in major languages
const topSlugs = slugs.slice(0, 10) // Only pre-render top 10
return i18nConfig.localesToPrerender.flatMap((locale) =>
topSlugs.map((slug) => ({ lang: locale, slug }))
)
}
// [IMPORTANT] Allow dynamic generation of non-pre-rendered pages
export const dynamicParams = trueWith this configuration:
- Build only generates 2 languages × 10 posts = 20 pages
- When users visit non-pre-rendered pages, Next.js generates and caches them in real-time
- Cache auto-updates after 1 hour
Optimization 3: Parallel Data Fetching
In generateStaticParams, if you need to fetch multiple types of data, definitely process in parallel:
// ❌ Wrong: serial fetching (slow)
export async function generateStaticParams() {
const posts = await getBlogPosts() // Wait 2 seconds
const categories = await getCategories() // Wait 1 second
// Total: 3 seconds
}
// ✅ Correct: parallel fetching (fast)
export async function generateStaticParams() {
const [posts, categories] = await Promise.all([
getBlogPosts(), // Execute simultaneously
getCategories(), // Execute simultaneously
])
// Total: 2 seconds (whichever is longest)
}In my project, this optimization reduced data fetching time by 40%.
Optimization 4: Solving Translation Cache Issues
The most annoying thing during development is updating translation files but the page doesn’t refresh. This is because Next.js caches imported JSON files.
Solution: Disable cache in development environment
// i18n/utils.ts
import fs from 'fs/promises'
import path from 'path'
const isDev = process.env.NODE_ENV === 'development'
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
// Development environment: re-read file each time
if (isDev) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
const filePath = path.join(
process.cwd(),
'i18n',
'locales',
locale,
`${ns}.json`
)
try {
const content = await fs.readFile(filePath, 'utf-8')
translations[ns] = JSON.parse(content)
} catch (error) {
console.warn(`Translation file not found: ${filePath}`)
translations[ns] = {}
}
}
return translations
}
// Production environment: use cache
return loadTranslationsWithCache(locale, namespaces)
}Now, during development, refreshing the page after updating translation files shows the latest content.
Common Issues Troubleshooting Guide
In actual development, you may encounter other issues. Here I’ve compiled the most common ones and my solutions.
Issue 1: Build Error “generateStaticParams not found”
Error message:
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.Troubleshooting steps:
- ✅ Check if
generateStaticParamsis defined inlayout.tsxorpage.tsx - ✅ Confirm function name spelling is correct (not
getStaticParams, notgenerateParams) - ✅ Confirm function is properly exported (must be
export async function) - ✅ Check if parameter names match route folder names
// ❌ Wrong example
export async function getStaticParams() { // Wrong function name
return [{ locale: 'en' }] // Wrong parameter name too
}
// ✅ Correct example
export async function generateStaticParams() {
return [{ lang: 'en' }] // Parameter name must match [lang]
}Issue 2: Dynamic rendering detected
Error message:
Error: Route /[lang]/about couldn't be rendered statically
because it used `headers` or `cookies`.Cause: Used dynamic APIs (headers(), cookies(), searchParams) in statically generated pages.
Solution:
// ❌ Wrong: using cookies in server component
export default async function Page() {
const locale = cookies().get('NEXT_LOCALE') // Triggers dynamic rendering
return <div>...</div>
}
// ✅ Solution 1: Handle in middleware
// middleware.ts
export function middleware(request: NextRequest) {
const locale = request.cookies.get('NEXT_LOCALE')
// Processing logic...
}
// ✅ Solution 2: Use client component
'use client'
export function LanguageSwitcher() {
const [locale, setLocale] = useState(() => {
// Read Cookie on client side
return getCookie('NEXT_LOCALE')
})
// ...
}Issue 3: Translation file not found
Error message:
Error: Cannot find module './locales/en/common.json'Checklist:
- ✅ Check if file path is correct (note case sensitivity, Linux is case-sensitive)
- ✅ Confirm JSON file syntax is correct (can validate with online tools)
- ✅ Check
tsconfig.jsonpath alias configuration:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}- ✅ Confirm translation files are properly included at build time:
// next.config.js
module.exports = {
// Ensure JSON files are included
webpack: (config) => {
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}Issue 4: Route Parameters Lost After Language Switch
Symptom: When switching from /zh-CN/blog/my-post to English, it redirects to /en/ instead of /en/blog/my-post.
Cause: Language switcher doesn’t properly preserve route parameters.
Solution:
// ❌ Wrong: hardcoded path
<Link href="/about">About</Link>
// ✅ Solution 1: manually concatenate language parameter
<Link href={`/${params.lang}/about`}>About</Link>
// ✅ Solution 2: wrap a smart Link component
// components/LocalizedLink.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export function LocalizedLink({
href,
children,
...props
}: {
href: string
children: React.ReactNode
[key: string]: any
}) {
const pathname = usePathname()
// Extract language from current path
const locale = pathname.split('/')[1]
// Automatically add language prefix
const localizedHref = `/${locale}${href}`
return (
<Link href={localizedHref} {...props}>
{children}
</Link>
)
}Issue 5: Missing or Incorrect SEO Tags
Problem: Multilingual page SEO tags (hreflang, canonical) are improperly configured, affecting search engine indexing.
Solution: Properly configure in each page’s generateMetadata:
// app/[lang]/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { lang: string; slug: string }
}) {
const baseUrl = 'https://example.com'
return {
// Page title and description
title: 'My Blog Post',
description: 'This is a blog post',
// Canonical URL
alternates: {
canonical: `${baseUrl}/${params.lang}/blog/${params.slug}`,
// hreflang tags (tell search engines about other language versions)
languages: {
'en': `${baseUrl}/en/blog/${params.slug}`,
'zh-CN': `${baseUrl}/zh-CN/blog/${params.slug}`,
'ja': `${baseUrl}/ja/blog/${params.slug}`,
'x-default': `${baseUrl}/en/blog/${params.slug}`, // Default language
},
},
// Open Graph tags (for social media sharing)
openGraph: {
title: 'My Blog Post',
description: 'This is a blog post',
url: `${baseUrl}/${params.lang}/blog/${params.slug}`,
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}Best Practices Summary
After all this practice, I’ve compiled a checklist of best practices you can execute directly.
Project Initialization Checklist
Before starting development, ensure these configurations are complete:
- Determine supported language list and default language
- Create
app/[lang]directory structure - Configure
i18n/config.tsand translation file directories - Implement
middleware.tslanguage detection - Add
generateStaticParamsto root layout - Configure
next.config.js(if static export needed, setoutput: 'export')
Development Phase Recommendations
- Development environment only pre-renders default language (
localesToPrerender: ['en']) - Use TypeScript to ensure type safety of translation keys
- Split translation namespaces by functional module (common, home, blog…)
- Disable translation cache in development environment (use
fs.readFilefor real-time reading) - Add warning logs for missing translations (makes issues easier to find)
Production Deployment Checklist
- Selective pre-rendering of major languages (optimize build time)
- Configure ISR strategy (minor languages generated on-demand, set
revalidate) - Use parallel data fetching (
Promise.all) - Configure correct hreflang and canonical tags
- Set CDN cache strategy (consider multilingual paths)
- Monitor traffic and build time for each language version
Complete next.config.js Configuration Example
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Static export (if needed)
output: 'export',
// Image optimization config
images: {
unoptimized: true, // Required for static export
},
// Environment variables
env: {
BUILD_TIME: new Date().toISOString(),
},
// Custom build ID (for cache invalidation)
generateBuildId: async () => {
return `build-${Date.now()}`
},
// Webpack configuration
webpack: (config, { isServer }) => {
// Ensure JSON files are handled correctly
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}
module.exports = nextConfigRecommended Tools and Libraries
If you don’t want to implement from scratch, consider these ready-made libraries:
| Tool/Library | Purpose | Rating | Notes |
|---|---|---|---|
| next-intl | Complete i18n solution | ⭐⭐⭐⭐⭐ | Officially recommended, most comprehensive, supports App Router |
| next-international | Lightweight i18n library | ⭐⭐⭐⭐ | Lightweight and clean, type-safe |
| @formatjs/intl | Internationalization formatting | ⭐⭐⭐⭐ | Handles date, number, currency formats |
| typesafe-i18n | Type-safe translations | ⭐⭐⭐⭐ | Auto-generates type definitions |
| i18next | Veteran i18n library | ⭐⭐⭐ | Powerful but needs adaptation for App Router |
I personally recommend next-intl - it’s specifically designed for Next.js App Router, works out of the box, and doesn’t require much configuration. But if you want to deeply understand i18n implementation principles or need high customization, manual implementation (like in this article) is also a good choice.
Conclusion
To recap, we’ve implemented a complete Next.js App Router multilingual static generation solution, including:
Core Functionality
- Multilingual structure based on
[lang]dynamic routing - Static page generation using
generateStaticParams - Translation file system split by namespace
- Middleware automatic language detection and redirection
- Multilingual structure based on
Performance Optimization
- Selective pre-rendering of major languages (reduced build time by 75%)
- Using ISR for on-demand generation of minor languages
- Parallel data fetching
- Disabling cache in development environment
Problem Solving
generateStaticParamsconfiguration errors- Dynamic APIs causing build failures
- Translation file caching issues
- Route parameter loss on language switch
- SEO tag configuration
Key Takeaways:
- i18n in App Router needs manual implementation, can’t use Pages Router configuration
generateStaticParamsmust be defined in layout or page, parameter names must match- Statically generated pages can’t use dynamic APIs like
cookies(),headers() - Use selective pre-rendering and ISR wisely to avoid excessive build time
If you’re also building multilingual sites with Next.js App Router, I hope this article helps you avoid some pitfalls. Internationalization itself isn’t complex - the key is understanding Next.js’s build mechanism and configuring according to its rules.
Finally, if you find manual implementation too cumbersome, remember to try the next-intl library - it can save a lot of work.
FAQ
Why does build fail with 'missing generateStaticParams'?
Solution:
• Define generateStaticParams in layout or page
• Return all locale combinations
• Parameter names must match route structure
Example:
export async function generateStaticParams() {
return locales.map(locale => ({ locale }))
}
How do I reduce build time for multilingual sites?
• Use selective pre-rendering (only important pages)
• Use ISR for frequently updated content
• Cache translations
• Parallelize build process
• Minimize number of pages per locale
Example: Pre-render homepage in all languages, use ISR for blog posts.
Why don't translation updates take effect?
• Build cache not cleared
• Browser cache
• CDN cache
• Static generation caching
Solutions:
• Clear build cache
• Use cache busting
• Implement ISR with revalidation
• Check CDN cache settings
How do I use generateStaticParams for multilingual routes?
Example:
export async function generateStaticParams() {
const locales = ['en', 'zh']
const posts = await getPosts()
return locales.flatMap(locale =>
posts.map(post => ({ locale, slug: post.slug }))
)
}
This generates all combinations: /en/post-1, /zh/post-1, etc.
Can I use dynamic APIs in static generation?
• cookies()
• headers()
• Dynamic APIs
Workarounds:
• Use Server Components for dynamic data
• Use ISR instead of SSG
• Move dynamic logic to Client Components
For i18n, use middleware for locale detection, not cookies in static pages.
How do I implement selective pre-rendering?
1) Only generate important pages statically
2) Use ISR for others
3) Use dynamic rendering for user-specific pages
Example:
• Homepage: SSG (all locales)
• Blog posts: ISR (revalidate: 3600)
• User dashboard: Dynamic rendering
This reduces build time while maintaining performance.
What's the difference between SSG and ISR for i18n?
• Pre-generates all pages at build time
• Fastest performance
• But requires rebuild for updates
ISR (Incremental Static Regeneration):
• Pre-generates, but can revalidate
• Good balance of performance and freshness
• Better for frequently updated content
For multilingual sites, use SSG for stable content, ISR for dynamic content.
11 min read · Published on: Dec 25, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment