Next.js 15 多语言 SEO 优化实战:从踩坑到精通
我们团队优化 Next.js 15 多语言站点 SEO 的完整过程,包括真实问题、排查思路和有效解决方案。
Next.js 15 多语言 SEO 优化实战:从踩坑到精通
先说结论
我们花了3周时间优化 Next.js 15 多语言 SaaS 平台的 SEO。核心发现:
- ❌
next-intl+ App Router = sitemap 是个坑 - ✅
localePrefix: 'always'必须用 - ❌ 动态 sitemap.ts 会覆盖一切
- ✅
next-sitemap静态生成更靠谱 - 📊 成果:SEO 分数 89 → 96,收录页面 12 → 147
时间线:2024年10月15日 - 11月5日 技术栈:Next.js 15.0.2, next-intl 3.20, next-sitemap 4.2 支持语言:8种(中英德西法日韩俄)
为什么选 Next.js 15 + next-intl
一开始我们考虑了三个方案:
- Next.js 原生 i18n - App Router 已废弃 😢
- react-i18next - 太偏前端,SSR 优化不够
- next-intl - 完美适配 App Router
选择很简单。但实施?完全是另一回事。
第一个坑:Sitemap 地狱
官方文档说:创建 app/sitemap.ts 就完事了。
// app/sitemap.ts
export default function sitemap() {
return [
{ url: 'https://example.com', lastModified: new Date() },
{ url: 'https://example.com/about', lastModified: new Date() },
];
}
看起来很简单对吧?大错特错。
问题:硬编码 URL
我们有 8 种语言 × 50+ 页面 = 400 个 URL。手敲?开什么玩笑。
试着动态生成:
export default function sitemap() {
const locales = ['en', 'zh', 'de', 'es', 'fr', 'ja', 'ko', 'ru'];
const pages = ['', '/about', '/pricing', '/products', '/api'];
return locales.flatMap(locale =>
pages.map(page => ({
url: `https://example.com/${locale}${page}`,
lastModified: new Date(),
}))
);
}
结果:Google Search Console 一周后只显示 12 个已索引页面。
为什么?生成的 sitemap 缺少关键属性:
- 没有
<lastmod>标签 - 没有
<changefreq> - 没有
<priority> - 缺少工具页面(29个页面!)
解决方案:next-sitemap
改用 next-sitemap:
// next-sitemap.config.js
module.exports = {
siteUrl: 'https://saas.catlove.cc',
generateRobotsTxt: true,
exclude: ['/api/*', '/dashboard/*'],
// 关键:additionalPaths 处理动态路由
additionalPaths: async (config) => {
const locales = ['en', 'zh', 'de', 'es', 'fr', 'ja', 'ko', 'ru'];
const tools = [
'json-formatter',
'base64-encoder',
'url-encoder',
// ... 还有 26 个工具
];
const paths = [];
for (const locale of locales) {
// 静态页面
paths.push({ loc: `/${locale}`, priority: 1.0, changefreq: 'daily' });
paths.push({ loc: `/${locale}/about`, priority: 0.8 });
// 工具页面
for (const tool of tools) {
paths.push({
loc: `/${locale}/tools/${tool}`,
priority: 0.7,
changefreq: 'weekly',
});
}
}
return paths;
},
transform: async (config, path) => {
// 根据路径自定义优先级
let priority = 0.7;
if (path === '/' || path.endsWith('/en') || path.endsWith('/zh')) {
priority = 1.0;
} else if (path.includes('/tools/')) {
priority = 0.7;
}
return {
loc: path,
changefreq: 'weekly',
priority,
lastmod: new Date().toISOString(),
};
},
};
在 postbuild 中运行:
{
"scripts": {
"build": "next build",
"postbuild": "next-sitemap"
}
}
结果:Sitemap 从 12 个 URL → 232 个 URL,且包含完整元数据。
第二个坑:robots.txt 返回 HTML
Google bot 报错:"robots.txt 是 HTML,不是纯文本"
User-agent: *
<!DOCTYPE html>
<html>...
哈?
根本原因:Middleware 拦截
我们的 middleware 拦截了所有请求,包括 /robots.txt:
// middleware.ts
export default withAuth(
withNextIntl(async (request) => {
// 这会对所有请求运行,包括 robots.txt!
const response = NextResponse.next();
return response;
})
);
解决方案:为 SEO 文件开绿灯
添加早期返回:
export default withAuth(
withNextIntl(async (request: NextRequest) => {
const pathname = request.nextUrl.pathname;
// 为 SEO 文件和爬虫绕过 middleware
if (
pathname.startsWith('/sitemap')
|| pathname.startsWith('/robots.txt')
|| pathname.startsWith('/BingSiteAuth.xml')
|| request.headers.get('user-agent')?.match(
/Googlebot|Bingbot|Slurp|DuckDuckBot|Baiduspider/i
)
) {
return NextResponse.next();
}
// 继续 intl middleware
// ...
})
);
同时创建 app/robots.ts:
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard/', '/admin/'],
},
sitemap: 'https://saas.catlove.cc/sitemap.xml',
};
}
教训:一定要用 curl 测试 middleware:
curl https://saas.catlove.cc/robots.txt
# 应该返回纯文本,不是 HTML
第三个坑:语言切换器 URL 地狱
用户点击"中文" → URL 还是 /en/about 而不是 /zh/about。
组件看起来没问题:
<Link href={pathname} locale="zh">中文</Link>;
但就是不工作。为什么?
根本原因:localePrefix 设置
我们的配置是:
// app/i18n.ts
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../locales/${locale}/common.json`)).default,
}));
// utils/AppConfig.ts
export const AppConfig = {
locales: ['en', 'zh', 'de', 'es', 'fr', 'ja', 'ko', 'ru'],
defaultLocale: 'en',
localePrefix: 'as-needed', // ❌ 这就是问题所在
};
localePrefix: 'as-needed' 的意思是:
- 默认语言 (en):
/about✅ - 其他语言 (zh):
/about❌ (应该是/zh/about)
解决方案:始终使用显式前缀
改成:
export const AppConfig = {
localePrefix: 'always', // ✅ 所有语言都有前缀
};
现在:
- 英文:
/en/about - 中文:
/zh/about - 德文:
/de/about
权衡:URL 更长了,但对 SEO 更好。
Google 可以清楚识别:
<url>
<loc>https://saas.catlove.cc/en/about</loc>
<xhtml:link rel="alternate" hreflang="zh" href="https://saas.catlove.cc/zh/about"/>
<xhtml:link rel="alternate" hreflang="de" href="https://saas.catlove.cc/de/about"/>
</url>
第四个坑:中文内容显示英文
访问 /zh/pricing → 全是英文 😅
检查文件:
// locales/zh/pricing.json
{
"title": "Pricing Plans",
"description": "Choose the right plan for you",
...
}
脸疼。中文文件里是英文内容!
解决方案:批量翻译脚本
创建 scripts/translate-zh-pages.js:
const fs = require('node:fs');
const https = require('node:https');
const API_KEY = process.env.GOOGLE_TRANSLATE_API_KEY;
async function translateText(text, target) {
return new Promise((resolve, reject) => {
const url = `https://translation.googleapis.com/language/translate/v2?key=${API_KEY}`;
const data = JSON.stringify({ q: text, target, format: 'text' });
const req = https.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length,
},
}, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
const result = JSON.parse(body);
resolve(result.data.translations[0].translatedText);
});
});
req.write(data);
req.end();
});
}
async function translateJSON(obj, target) {
if (typeof obj === 'string') {
return await translateText(obj, target);
} else if (Array.isArray(obj)) {
return await Promise.all(obj.map(item => translateJSON(item, target)));
} else if (typeof obj === 'object' && obj !== null) {
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = await translateJSON(value, target);
}
return result;
}
return obj;
}
// 使用
const enContent = require('../src/locales/en/pricing.json');
translateJSON(enContent, 'zh-CN').then((zhContent) => {
fs.writeFileSync(
'./src/locales/zh/pricing.json',
JSON.stringify(zhContent, null, 2)
);
});
10 分钟翻译了 6 个文件 × 8 种语言 = 48 个文件。
专业提示:生产环境用专业翻译。自动翻译只适合 MVP。
性能优化
优化前
- Lighthouse SEO: 89
- FCP: 1.8s
- LCP: 3.2s
改进措施
- 所有页面静态生成
export const revalidate = 3600; // 每小时 ISR
- 懒加载非关键内容
const DeferredPricing = dynamic(() => import('@/features/Pricing'), {
loading: () => <Skeleton />,
});
- 优化图片
<Image
src="/hero.jpg"
width={1200}
height={600}
priority // 首屏
quality={85}
/>;
- 减少 JavaScript
// next.config.js
experimental: {
optimizePackageImports: ['@radix-ui/react-icons'],
}
优化后
- ✅ Lighthouse SEO: 96
- ✅ FCP: 0.9s (-50%)
- ✅ LCP: 1.4s (-56%)
- ✅ Google 收录: 147 页(原来 12 页)
结构化数据提升排名
添加 Organization schema:
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Organization',
'name': 'CatLove Cloud',
'url': 'https://saas.catlove.cc',
'logo': 'https://saas.catlove.cc/logo.png',
'sameAs': [
'https://twitter.com/CatLoveCloud',
'https://github.com/CatLoveCloud',
],
};
博客文章:
{
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.date,
author: { '@type': 'Organization', name: post.author },
keywords: post.tags.join(', '),
}
验证:使用 Google Rich Results Test
监控与迭代
我们用的工具
-
Google Search Console
- 跟踪索引进度
- 查找爬取错误
- 监控搜索查询
-
Bing Webmaster Tools
- 提交 sitemap
- 跟踪必应索引
-
Lighthouse CI
# .github/workflows/lighthouse.yml
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v9
with:
urls: |
https://saas.catlove.cc/en
https://saas.catlove.cc/zh
uploadArtifacts: true
- 自定义分析
跟踪用户语言偏好:
// middleware.ts
const acceptLanguage = request.headers.get('accept-language');
analytics.track('locale_preference', {
detected: acceptLanguage,
chosen: locale,
});
经验教训
要做的 ✅
- 复杂站点用
next-sitemap - 始终设置
localePrefix: 'always' - 为 SEO 文件绕过 middleware
- 添加结构化数据
- 监控 Google Search Console
不要做的 ❌
- 不要硬编码 sitemap URL
- 不要在 middleware 中阻止爬虫
- 不要忘记 hreflang 标签
- 不要跳过性能优化
- 生产环境不要依赖自动翻译
下一步
- 内容本地化 - 为关键市场雇佣母语者
- 区域 SEO - 针对百度(中国)、Naver(韩国)优化
- 本地结构化数据 - 添加本地商业 schema
- 性能 - 目标 100 分 Lighthouse
所有代码片段都来自我们的生产环境。随便抄,随便改。
有问题? 邮件联系:tech@catlove.cc