基于 Next.js 15 的国际化项目,只使用URL参数(如 ?hi=zh
)实现语言切换。
项目特性
SEO友好
- 通过URL参数控制语言,如
/apps?hi=zh
显示中文版 - 完整的meta标签和hreflang
- 支持搜索引擎索引
快速开始
安装依赖
pnpm install
启动开发服务器
pnpm dev
访问页面
- 英文版:
http://localhost:3000/apps
- 中文版:
http://localhost:3000/apps?hi=zh
项目结构
next-base/
├── app/
│ ├── apps/ # 示例页面(纯参数控制)
│ │ └── page.tsx # 使用 simpleUrlRedirect
│ ├── components/
│ │ └── LanguageSwitcher.tsx # 语言切换器(纯参数)
│ └── layout.tsx # 根布局
├── i18n/ # 国际化配置
│ ├── index.ts # 语言配置
│ ├── server.ts # 服务端翻译
│ ├── client.ts # 客户端翻译
│ ├── en-US/ # 英文翻译
│ └── zh-Hans/ # 中文翻译
├── lib/
│ └── urlRedirect.ts # URL参数处理工具
└── README.md # 项目说明
使用示例
基本用法
// app/apps/page.tsx
import { simpleUrlRedirect } from '@/lib/urlRedirect'
import { getTranslation } from '@/i18n/server'
import LanguageSwitcher from '@/app/components/LanguageSwitcher'
import { Metadata } from 'next'
import type { Locale } from '@/i18n'
interface AppsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function AppsPage({ searchParams }: AppsPageProps) {
// 一行代码搞定URL参数处理!
const locale = await simpleUrlRedirect(searchParams) as Locale
const { t: tCommon } = await getTranslation(locale, 'common')
const { t: tApps } = await getTranslation(locale, 'apps')
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold">{tApps('title')}</h1>
<p className="text-gray-600 mt-2">{tApps('description')}</p>
</div>
<LanguageSwitcher />
</div>
{/* 页面内容 */}
</div>
)
}
语言切换组件
// /app/components/LanguageSwitcher.tsx
import { simpleUrlRedirect } from '@/lib/urlRedirect'
import { getTranslation } from '@/i18n/server'
import LanguageSwitcher from '@/app/components/LanguageSwitcher'
import { Metadata } from 'next'
import type { Locale } from '@/i18n'
interface AppsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function AppsPage({ searchParams }: AppsPageProps) {
// 一行代码搞定URL参数处理!
const locale = await simpleUrlRedirect(searchParams) as Locale
const { t: tCommon } = await getTranslation(locale, 'common')
const { t: tApps } = await getTranslation(locale, 'apps')
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold">{tApps('title')}</h1>
<p className="text-gray-600 mt-2">{tApps('description')}</p>
</div>
<LanguageSwitcher />
</div>
{/* 页面内容 */}
</div>
)
}
LanguageSwitcher
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { i18n, setLocaleOnClient, type Locale } from '@/i18n'
export default function LanguageSwitcher() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [isOpen, setIsOpen] = useState(false)
// 从URL参数获取当前语言
const getCurrentLocale = (): string => {
const hi = searchParams.get('hi')
return hi === 'zh' ? 'zh-Hans' : 'en-US'
}
const switchLanguage = async (locale: string) => {
// 设置语言偏好到 Cookie
setLocaleOnClient(locale as Locale, false)
// 更新 URL 参数
const newSearchParams = new URLSearchParams(searchParams)
if (locale === 'en-US') {
// 英文版:移除hi参数
newSearchParams.delete('hi')
} else {
// 中文版:设置hi=zh参数
newSearchParams.set('hi', 'zh')
}
const newUrl = newSearchParams.toString()
? `${pathname}?${newSearchParams.toString()}`
: pathname
// 使用 push 而不是 refresh,避免无限循环
router.push(newUrl)
setIsOpen(false)
}
const currentLocale = getCurrentLocale()
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<span>🌐 {currentLocale === 'en-US' ? 'English' : '中文'}</span>
<svg className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute top-full mt-2 right-0 bg-white border border-gray-200 rounded-lg shadow-lg z-50 min-w-[150px]">
{i18n.locales.map((locale) => (
<button
key={locale}
onClick={() => switchLanguage(locale)}
className={`w-full text-left px-4 py-2 hover:bg-gray-100 transition-colors first:rounded-t-lg last:rounded-b-lg ${currentLocale === locale ? 'bg-blue-50 text-blue-600' : ''
}`}
>
{locale === 'en-US' ? '🇺🇸 English' : '🇨🇳 中文'}
</button>
))}
</div>
)}
</div>
)
}
smartUrlRedirect / simpleUrlRedirect
import { redirect } from 'next/navigation'
import { useTranslation } from '@/i18n/server'
/**
* 智能URL参数处理函数
* 1. 优先使用URL参数
* 2. 如果没有参数,检测用户语言偏好并自动跳转
*
* @param searchParams 搜索参数
* @param currentPath 当前路径
*/
export async function smartUrlRedirect(
searchParams: Promise<Record<string, string | string[] | undefined>>,
currentPath: string
) {
const params = await searchParams
const hi = params.hi
// 1. 如果用户主动选择了语言参数,直接返回对应语言
if (hi === 'zh') {
return 'zh-Hans'
}
if (hi === 'en') {
return 'en-US'
}
// 2. 如果没有参数,检测用户语言偏好
const detectedLocale = await useTranslation()
// 3. 如果检测到中文用户,自动跳转到中文版
// 注意:只有在没有 hi 参数时才跳转,避免无限循环
if (detectedLocale === 'zh-Hans' && !hi) {
redirect(`${currentPath}?hi=zh`)
}
// 4. 默认返回英文
return 'en-US'
}
/**
* 简化的URL参数处理函数 - 只处理参数,不跳转路径
*
* @param searchParams 搜索参数
*/
export async function simpleUrlRedirect(
searchParams: Promise<Record<string, string | string[] | undefined>>
) {
const params = await searchParams
const hi = params.hi
// 如果检测到中文参数,返回中文语言标识
if (hi === 'zh') {
return 'zh-Hans'
}
// 默认返回英文
return 'en-US'
}
英文数据集
// /app/i18n/en-US/common.ts
export default {
welcome: 'Welcome',
apps: 'Apps',
home: 'Home',
about: 'About',
contact: 'Contact',
language: 'Language',
english: 'English',
chinese: 'Chinese',
} as const
// /app/i18n/en-US/apps.ts
export default {
title: 'Applications',
description: 'Browse and manage your applications',
features: {
title: 'Features',
list: [
'Easy to use interface',
'Fast performance',
'Secure and reliable',
'24/7 support'
]
},
stats: {
total: 'Total Applications',
active: 'Active',
inactive: 'Inactive'
},
actions: {
create: 'Create New App',
edit: 'Edit',
delete: 'Delete',
view: 'View Details'
}
} as const
中文数据集
// /app/i18n/zh-Hans/common.ts
export default {
welcome: '欢迎',
apps: '应用',
home: '首页',
about: '关于',
contact: '联系',
language: '语言',
english: '英文',
chinese: '中文',
} as const
// /app/i18n/zh-Hans/apps.ts
export default {
title: '应用程序',
description: '浏览和管理您的应用程序',
features: {
title: '功能特性',
list: [
'简单易用的界面',
'快速性能',
'安全可靠',
'24/7 支持'
]
},
stats: {
total: '应用程序总数',
active: '活跃',
inactive: '非活跃'
},
actions: {
create: '创建新应用',
edit: '编辑',
delete: '删除',
view: '查看详情'
}
} as const
Demo
// /app/apps/page.tsx
import { simpleUrlRedirect } from '@/lib/urlRedirect'
import { getTranslation } from '@/i18n/server'
import LanguageSwitcher from '@/app/components/LanguageSwitcher'
import { Metadata } from 'next'
import type { Locale } from '@/i18n'
interface AppsPageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
// 生成元数据
export async function generateMetadata(): Promise<Metadata> {
return {
title: 'Applications',
description: 'Manage your applications',
openGraph: {
title: 'Applications',
description: 'Manage your applications',
},
alternates: {
canonical: '/apps',
languages: {
'en': '/apps',
'zh': '/apps?hi=zh',
'x-default': '/apps',
},
},
}
}
export default async function AppsPage({ searchParams }: AppsPageProps) {
// 使用简化的URL参数处理 - 不跳转路径,只返回语言
const locale = await simpleUrlRedirect(searchParams) as Locale
const { t: tCommon } = await getTranslation(locale, 'common')
const { t: tApps } = await getTranslation(locale, 'apps')
// 结构化数据
const structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": tApps('title'),
"description": tApps('description'),
"inLanguage": locale,
"url": `https://yourdomain.com/apps`,
"alternateName": {
"en": "Applications",
"zh": "应用程序"
}
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
{/* 添加 hreflang 标签 */}
<link rel="alternate" hrefLang="en" href="/apps" />
<link rel="alternate" hrefLang="zh" href="/apps?hi=zh" />
<link rel="alternate" hrefLang="x-default" href="/apps" />
<div className="p-8 max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold">{tApps('title')}</h1>
<p className="text-gray-600 mt-2">{tApps('description')}</p>
</div>
<LanguageSwitcher />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* 统计卡片 */}
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-semibold mb-4">{tApps('stats.total')}</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span>{tApps('stats.active')}:</span>
<span className="font-bold text-green-600">12</span>
</div>
<div className="flex justify-between">
<span>{tApps('stats.inactive')}:</span>
<span className="font-bold text-gray-600">3</span>
</div>
</div>
</div>
{/* 功能特性 */}
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-semibold mb-4">{tApps('features.title')}</h3>
<ul className="space-y-2">
{tApps('features.list.0') && <li className="flex items-center">
<span className="text-green-500 mr-2">✓</span>
{tApps('features.list.0')}
</li>}
{tApps('features.list.1') && <li className="flex items-center">
<span className="text-green-500 mr-2">✓</span>
{tApps('features.list.1')}
</li>}
{tApps('features.list.2') && <li className="flex items-center">
<span className="text-green-500 mr-2">✓</span>
{tApps('features.list.2')}
</li>}
{tApps('features.list.3') && <li className="flex items-center">
<span className="text-green-500 mr-2">✓</span>
{tApps('features.list.3')}
</li>}
</ul>
</div>
</div>
{/* 操作按钮 */}
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-semibold mb-4">操作</h3>
<div className="flex flex-wrap gap-3">
<button className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
{tApps('actions.create')}
</button>
<button className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors">
{tApps('actions.edit')}
</button>
<button className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">
{tApps('actions.view')}
</button>
<button className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
{tApps('actions.delete')}
</button>
</div>
</div>
{/* 语言链接 */}
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<h3 className="text-lg font-medium mb-2">其他语言版本:</h3>
<div className="flex gap-4">
<a href="/apps" className="text-blue-600 hover:underline">🏠 默认 (英文)</a>
<a href="/apps?hi=zh" className="text-blue-600 hover:underline">🇨🇳 中文</a>
</div>
</div>
{/* 调试信息 */}
<div className="mt-8 p-4 bg-gray-100 rounded-lg">
<h3 className="text-lg font-medium mb-2">调试信息:</h3>
<p><strong>当前语言:</strong> {locale}</p>
<p><strong>URL路径:</strong> /apps</p>
<p><strong>页面标题:</strong> {tApps('title')}</p>
<p><strong>欢迎信息:</strong> {tCommon('welcome')}</p>
</div>
</div>
</>
)
}
效果展示
URL示例
URL | 行为 | 结果 |
---|---|---|
/apps |
显示英文版 | 英文内容 |
/apps?hi=zh |
显示中文版 | 中文内容 |
/apps?hi=en |
显示英文版 | 英文内容 |
添加新页面
复制现有页面模板:
// app/newpage/page.tsx import { simpleUrlRedirect } from '@/lib/urlRedirect' import { getTranslation } from '@/i18n/server' import LanguageSwitcher from '@/app/components/LanguageSwitcher' import { Metadata } from 'next' import type { Locale } from '@/i18n' interface NewPageProps { searchParams: Promise<Record<string, string | string[] | undefined>>; } export default async function NewPage({ searchParams }: NewPageProps) { // 一行代码搞定! const locale = await simpleUrlRedirect(searchParams) as Locale const { t: tCommon } = await getTranslation(locale, 'common') const { t: tApps } = await getTranslation(locale, 'apps') return ( <div className="p-8 max-w-4xl mx-auto"> <div className="flex justify-between items-center mb-6"> <div> <h1 className="text-3xl font-bold">New Page</h1> <p className="text-gray-600 mt-2">Your new page</p> </div> <LanguageSwitcher /> </div> {/* 页面内容 */} </div> ) }
访问方式:
- 英文版:
/newpage
- 中文版:
/newpage?hi=zh
- 英文版:
技术栈
- 框架: Next.js 15 (App Router)
- 语言: TypeScript
- 样式: Tailwind CSS
- 国际化: i18next + react-i18next
- 包管理: pnpm
核心优势
1. 极简代码
- 新增页面只需复制模板
- 逻辑清晰,易于理解
- 不需要复杂的路径映射
2. 高度可扩展
- 轻松添加新页面
- 支持自定义参数
- 类型安全
3. SEO友好
- 参数化URL支持
- 完整meta标签
- 搜索引擎优化
开发指南
添加新语言
- 在
i18n/index.ts
中添加语言配置 - 创建对应的翻译文件
- 更新参数处理逻辑
自定义参数
- 修改
simpleUrlRedirect
函数 - 添加新的参数处理
- 更新文档
性能优化
- 翻译资源懒加载
- 缓存策略优化
- 静态生成优化
最佳实践
1. 保持一致性
- 所有页面使用相同的参数化模式
- 统一使用
simpleUrlRedirect
函数 - 保持文件结构一致
2. 参数命名
- 使用简洁明了的参数名(如
hi
) - 避免与现有参数冲突
- 考虑SEO影响
3. 测试覆盖
- 测试不同参数组合
- 测试语言切换逻辑
- 测试SEO元数据
这个项目展示了如何使用纯URL参数实现最简洁、最独立、最易维护的国际化解决方案。