CatLove Cloud 技术团队24 分钟阅读

Next.js 15 多语言 SEO 优化实战:从踩坑到精通

我们团队优化 Next.js 15 多语言站点 SEO 的完整过程,包括真实问题、排查思路和有效解决方案。

#Next.js#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

一开始我们考虑了三个方案:

  1. Next.js 原生 i18n - App Router 已废弃 😢
  2. react-i18next - 太偏前端,SSR 优化不够
  3. 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

改进措施

  1. 所有页面静态生成
export const revalidate = 3600; // 每小时 ISR
  1. 懒加载非关键内容
const DeferredPricing = dynamic(() => import('@/features/Pricing'), {
  loading: () => <Skeleton />,
});
  1. 优化图片
<Image
  src="/hero.jpg"
  width={1200}
  height={600}
  priority // 首屏
  quality={85}
/>;
  1. 减少 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

监控与迭代

我们用的工具

  1. Google Search Console

    • 跟踪索引进度
    • 查找爬取错误
    • 监控搜索查询
  2. Bing Webmaster Tools

    • 提交 sitemap
    • 跟踪必应索引
  3. 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
  1. 自定义分析

跟踪用户语言偏好:

// 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 标签
  • 不要跳过性能优化
  • 生产环境不要依赖自动翻译

下一步

  1. 内容本地化 - 为关键市场雇佣母语者
  2. 区域 SEO - 针对百度(中国)、Naver(韩国)优化
  3. 本地结构化数据 - 添加本地商业 schema
  4. 性能 - 目标 100 分 Lighthouse

所有代码片段都来自我们的生产环境。随便抄,随便改。

有问题? 邮件联系:tech@catlove.cc

C
CatLove Cloud 技术团队
CatLove Cloud 技术团队