Next.js 15 静态导出中实现动态路由多语言支持
关键词:
Next.js 15·Static Export·Dynamic Routes·i18n·generateStaticParams·Server Components·Client Components·多语言
前言
在使用 Next.js 15 构建个人作品集网站时,实现多语言支持是常见需求。然而,当项目配置为静态导出模式(output: "export")时,传统的服务端国际化方案(如 next-intl)无法使用,这给开发者带来了挑战。
本文将详细介绍如何在 Next.js 15 静态导出模式下,通过动态路由实现简洁优雅的多语言支持方案,包括完整的问题分析、解决方案和关键实现细节。
项目背景
项目配置:
- 框架: Next.js 15 with App Router
- 输出模式: Static Export(
output: "export") - 部署平台: GitHub Pages
- UI 库: HeroUI (基于 React Context)
- 数据源: 本地 JSON 文件(
resume-zh.json,resume-en.json)
核心需求:
- 支持中英文简历切换(
/resume/zh,/resume/en) - URL 路径清晰直观,便于分享
- 兼容静态导出,可部署到 GitHub Pages
- 提供友好的语言切换界面
问题分析
1. 静态导出的限制
Next.js 的静态导出模式有以下限制:
// next.config.ts
const nextConfig = {
output: "export", // 启用静态导出
images: {
unoptimized: true // 必须禁用图片优化
}
}
主要限制:
- ❌ 不支持服务端 API Routes
- ❌ 不支持服务端渲染(SSR)
- ❌ 不支持基于 middleware 的国际化
- ❌ 动态路由需要
generateStaticParams()
2. Next.js 15 的新特性
在 Next.js 15 中,动态路由参数从同步变为异步:
// Next.js 14 及更早版本
export default function Page({ params }: { params: { lang: string } }) {
const lang = params.lang; // 直接访问
}
// Next.js 15
export default async function Page({
params
}: {
params: Promise<{ lang: string }>
}) {
const { lang } = await params; // 需要 await
}
这个变化会导致直接迁移代码时出现运行时错误。
3. UI 库的客户端依赖
HeroUI 等现代 UI 库依赖 React Context,必须在客户端组件中使用:
// ❌ 这样会报错
import { Link } from '@heroui/react';
export default async function Page() {
return <Link href="/">Home</Link>;
// Error: createContext only works in Client Components
}
解决方案设计
基于以上分析,采用服务端组件 + 客户端组件混合架构的方案:
架构设计
app/resume/
├── page.tsx # 根路由重定向(Client Component)
├── [lang]/
│ ├── page.tsx # 动态路由入口(Server Component)
│ └── ResumeContent.tsx # UI 渲染层(Client Component)
└── layout.tsx
分层职责:
- 根路由 (
page.tsx):处理默认语言重定向 - 服务端层 (
[lang]/page.tsx):处理数据加载和静态生成 - 客户端层 (
ResumeContent.tsx):处理 UI 渲染和交互
完整实现步骤
步骤 1:创建动态路由结构
# 创建动态路由目录
mkdir -p app/resume/[lang]
注意: 目录名必须使用方括号 [lang],这是 Next.js 的动态路由约定。
步骤 2:实现服务端组件
创建 app/resume/[lang]/page.tsx:
import resumeZh from '@/config/data/resume-zh.json'
import resumeEn from '@/config/data/resume-en.json'
import ResumeContent from './ResumeContent'
// 关键:为静态导出生成参数列表
export function generateStaticParams() {
return [
{ lang: 'zh' },
{ lang: 'en' },
]
}
// Next.js 15 中 params 是 Promise
export default async function ResumePage({
params
}: {
params: Promise<{ lang: string }>
}) {
const { lang } = await params
const resumeData = lang === 'en' ? resumeEn : resumeZh
return <ResumeContent resumeData={resumeData} lang={lang} />
}
关键点解析:
generateStaticParams(): 告诉 Next.js 在构建时生成哪些静态页面async函数: Next.js 15 要求使用await params- 数据预加载: 在服务端完成数据选择,传递给客户端组件
步骤 3:实现客户端组件
创建 app/resume/[lang]/ResumeContent.tsx:
'use client';
import { Link } from '@heroui/react'
import { getCommonIcon, getSocialIcon } from '@/lib/icons'
import { formatDateToShort, formatDateRange, formatDateToDetailed } from '@/lib/date-utils'
type ResumeData = {
basics: any;
skills: any[];
work: any[];
projects: any[];
education: any[];
languages: any[];
}
export default function ResumeContent({
resumeData,
lang
}: {
resumeData: ResumeData,
lang: string
}) {
const { basics, skills, work, projects, education, languages } = resumeData
const isEnglish = lang === 'en'
return (
<div className="max-w-6xl mx-auto px-8 py-10 print:py-0">
{/* 语言切换器 */}
<div className="flex justify-end gap-3 mb-6 no-print">
<Link
href="/resume/zh"
className={`text-sm ${
!isEnglish
? 'font-bold text-text-primary'
: 'text-text-primary opacity-60 hover:opacity-100'
} transition-opacity`}
>
中文
</Link>
<span className="text-text-primary opacity-40">|</span>
<Link
href="/resume/en"
className={`text-sm ${
isEnglish
? 'font-bold text-text-primary'
: 'text-text-primary opacity-60 hover:opacity-100'
} transition-opacity`}
>
English
</Link>
</div>
{/* 简历内容 */}
<div className="grid grid-cols-1 md:grid-cols-10">
{/* 左侧栏 */}
<div className="md:col-span-2">
<h1 className="text-3xl text-black mb-1">
{basics.name}
</h1>
<h2 className="text-md font-light text-text-primary opacity-60 mb-1">
{basics.label}
</h2>
{/* 更多内容... */}
</div>
{/* 右侧内容 */}
<div className="md:col-span-6">
{/* 各个板块... */}
</div>
</div>
</div>
)
}
关键点解析:
'use client'指令: 声明为客户端组件,可使用 React Context- Props 传递: 从服务端接收数据和语言参数
- 条件样式: 根据当前语言高亮对应的切换按钮
步骤 4:实现根路由重定向
创建 app/resume/page.tsx:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Resume() {
const router = useRouter();
useEffect(() => {
// 默认重定向到中文版
router.replace('/resume/zh');
}, [router]);
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-text-primary opacity-60">Redirecting...</p>
</div>
);
}
说明: 当用户访问 /resume 时,自动跳转到 /resume/zh。
常见问题与解决方案
问题 1:缺少 generateStaticParams 错误
错误信息:
Error: Page "/resume/[lang]/page" is missing exported function
"generateStaticParams()", which is required with "output: export" config.
解决方案:
必须导出 generateStaticParams 函数:
export function generateStaticParams() {
return [
{ lang: 'zh' },
{ lang: 'en' },
]
}
问题 2:createContext 错误
错误信息:
TypeError: createContext only works in Client Components.
Add the "use client" directive at the top of the file to use it.
原因分析:
在服务端组件中直接使用了需要 React Context 的 UI 库组件(如 HeroUI 的 Link)。
解决方案: 分离服务端和客户端组件:
- 服务端组件:负责数据加载
- 客户端组件:负责 UI 渲染
问题 3:params 类型错误
错误信息:
Property 'lang' does not exist on type 'Promise<{ lang: string }>'
解决方案: 在 Next.js 15 中,需要 await params:
// ❌ 错误写法
export default function Page({ params }: { params: { lang: string } }) {
const lang = params.lang;
}
// ✅ 正确写法
export default async function Page({
params
}: {
params: Promise<{ lang: string }>
}) {
const { lang } = await params;
}
问题 4:构建时缓存问题
如果修改代码后仍然报错,可能是缓存问题:
# 清除 Next.js 缓存并重启
rm -rf .next
npm run dev
性能优化建议
1. 静态生成优化
通过 generateStaticParams,构建时会生成所有语言版本的静态 HTML:
# 构建输出示例
out/
├── resume/
│ ├── zh.html # 预生成的中文版
│ ├── en.html # 预生成的英文版
│ └── index.html # 重定向页面
2. 代码分割
客户端组件会自动进行代码分割:
// ResumeContent.tsx 会被打包为独立的 chunk
// 只有访问简历页面时才会加载
3. 字体优化
使用 CSS 变量统一管理文字颜色,方便调整:
/* styles/globals.css */
:root {
--text-primary: #374151; /* 柔和的深灰色 */
}
.text-text-primary {
color: var(--text-primary);
}
方案对比分析
本方案 vs URL 参数方案
| 特性 | 动态路由方案 | URL 参数方案 |
|---|---|---|
| URL 格式 | /resume/zh | /resume?lang=zh |
| SEO 友好度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 静态预生成 | ✅ 支持 | ❌ 单页面 |
| 实现复杂度 | 中等 | 简单 |
| 分享友好度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
本方案 vs 子目录方案
| 特性 | 动态路由方案 | 子目录方案 |
|---|---|---|
| 代码复用 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 维护成本 | 低 | 中等 |
| 文件结构 | /resume/[lang] | /zh/resume, /en/resume |
| 扩展性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
扩展思路
1. 添加更多语言
只需更新 generateStaticParams:
export function generateStaticParams() {
return [
{ lang: 'zh' },
{ lang: 'en' },
{ lang: 'ja' }, // 日语
{ lang: 'ko' }, // 韩语
]
}
2. 语言检测与自动跳转
在根路由中添加浏览器语言检测:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Resume() {
const router = useRouter();
useEffect(() => {
// 检测浏览器语言
const browserLang = navigator.language.startsWith('en') ? 'en' : 'zh';
router.replace(`/resume/${browserLang}`);
}, [router]);
return <div>Redirecting...</div>;
}
3. 集成到更大的项目
将该模式应用到整个网站:
app/
├── [lang]/
│ ├── page.tsx # 首页
│ ├── about/
│ │ └── page.tsx # 关于页
│ ├── projects/
│ │ └── page.tsx # 项目页
│ └── resume/
│ └── page.tsx # 简历页
总结
本文介绍了在 Next.js 15 静态导出模式下实现多语言支持的完整方案。通过合理的架构设计,成功解决了静态导出的限制、Next.js 15 的 API 变化、以及 UI 库的客户端依赖等多个技术挑战。
核心要点:
- 使用动态路由
[lang]实现清晰的 URL 结构 - 通过
generateStaticParams()支持静态导出 - 采用服务端组件 + 客户端组件分离架构
- 正确处理 Next.js 15 的异步 params
适用场景:
- 需要静态部署的多语言网站(GitHub Pages、Netlify 等)
- SEO 要求较高的项目
- 内容相对稳定的展示型网站
源码参考:
完整的实现代码可参考项目仓库中的 app/resume/[lang] 目录。
相关文章:
参考资料: