CatLove Cloud Tech Team29 分钟阅读

Next.js 15 Multilingual SEO: Lessons from Real Production (with Pitfalls)

Our team's journey optimizing a Next.js 15 multilingual site for SEO. Real issues, debugging process, and solutions that actually work.

#Next.js#SEO#i18n#Performance#Pitfalls

Next.js 15 Multilingual SEO: Lessons from Real Production (with Pitfalls)

TL;DR

We spent 3 weeks optimizing our Next.js 15 multilingual SaaS platform for SEO. Here's what we learned:

  • next-intl + App Router = sitemap headaches
  • localePrefix: 'always' is a must
  • ❌ Dynamic sitemap.ts overrides everything
  • next-sitemap works better for static generation
  • 📊 Result: 89 → 96 SEO score, indexed pages 12 → 147

Timeline: Oct 15 - Nov 5, 2024 Tech Stack: Next.js 15.0.2, next-intl 3.20, next-sitemap 4.2 Supported Languages: 8 (en, zh, de, es, fr, ja, ko, ru)

Why We Chose Next.js 15 + next-intl

Initially we considered:

  1. Native Next.js i18n - Deprecated in App Router 😢
  2. react-i18next - Too frontend-heavy, no SSR optimization
  3. next-intl - Perfect fit for App Router

The decision was easy. But the implementation? Not so much.

Issue #1: Sitemap Nightmare

Official docs say: create app/sitemap.ts and you're done.

// app/sitemap.ts
export default function sitemap() {
  return [
    { url: 'https://example.com', lastModified: new Date() },
    { url: 'https://example.com/about', lastModified: new Date() },
  ];
}

Seems simple, right? Wrong.

Problem: Hardcoded URLs

We have 8 languages × 50+ pages = 400 URLs. Manually typing them? No way.

Tried dynamic generation:

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(),
    }))
  );
}

Result: Google Search Console showed only 12 indexed pages after 1 week.

Why? The generated sitemap lacked crucial attributes:

  • No <lastmod> tags
  • No <changefreq>
  • No <priority>
  • Missing tool pages (29 pages!)

Solution: next-sitemap

Switched to next-sitemap:

// next-sitemap.config.js
module.exports = {
  siteUrl: 'https://saas.catlove.cc',
  generateRobotsTxt: true,
  exclude: ['/api/*', '/dashboard/*'],

  // Key: additionalPaths for dynamic routes
  additionalPaths: async (config) => {
    const locales = ['en', 'zh', 'de', 'es', 'fr', 'ja', 'ko', 'ru'];
    const tools = [
      'json-formatter',
      'base64-encoder',
      'url-encoder',
      // ... 26 more tools
    ];

    const paths = [];

    for (const locale of locales) {
      // Static pages
      paths.push({ loc: `/${locale}`, priority: 1.0, changefreq: 'daily' });
      paths.push({ loc: `/${locale}/about`, priority: 0.8 });

      // Tool pages
      for (const tool of tools) {
        paths.push({
          loc: `/${locale}/tools/${tool}`,
          priority: 0.7,
          changefreq: 'weekly',
        });
      }
    }

    return paths;
  },

  transform: async (config, path) => {
    // Custom priority based on 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(),
    };
  },
};

Run in postbuild:

{
  "scripts": {
    "build": "next build",
    "postbuild": "next-sitemap"
  }
}

Result: Sitemap went from 12 URLs → 232 URLs with proper metadata.

Issue #2: robots.txt Returning HTML

Google bot reported: "robots.txt is HTML, not plain text"

User-agent: *
<!DOCTYPE html>
<html>...

WTF?

Root Cause: Middleware Interception

Our middleware was redirecting everything, including /robots.txt:

// middleware.ts
export default withAuth(
  withNextIntl(async (request) => {
    // This runs for ALL requests, including robots.txt!
    const response = NextResponse.next();
    return response;
  })
);

Solution: Bypass for SEO Files

Added early return:

export default withAuth(
  withNextIntl(async (request: NextRequest) => {
    const pathname = request.nextUrl.pathname;

    // Bypass middleware for SEO files and crawlers
    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();
    }

    // Continue with intl middleware
    // ...
  })
);

Also created app/robots.ts:

export default function robots() {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/api/', '/dashboard/', '/admin/'],
    },
    sitemap: 'https://saas.catlove.cc/sitemap.xml',
  };
}

Lesson: Always test your middleware with curl:

curl https://saas.catlove.cc/robots.txt
# Should return plain text, not HTML

Issue #3: Language Switcher URL Hell

Users clicked "中文" → URL stayed /en/about instead of /zh/about.

The component looked fine:

<Link href={pathname} locale="zh">中文</Link>;

But it didn't work. Why?

Root Cause: localePrefix Setting

Our config had:

// 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', // ❌ THIS WAS THE PROBLEM
};

localePrefix: 'as-needed' means:

  • Default locale (en): /about
  • Other locales (zh): /about ❌ (should be /zh/about)

Solution: Always Use Explicit Prefixes

Changed to:

export const AppConfig = {
  localePrefix: 'always', // ✅ All locales get prefix
};

Now:

  • English: /en/about
  • Chinese: /zh/about
  • German: /de/about

Trade-off: Longer URLs, but much better for SEO.

Google can clearly identify:

<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>

Issue #4: Chinese Content Showing English

Visited /zh/pricing → All text in English 😅

Checked the file:

// locales/zh/pricing.json
{
  "title": "Pricing Plans",
  "description": "Choose the right plan for you",
  ...
}

Face palm. The Chinese file had English content!

Solution: Batch Translation Script

Created 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;
}

// Usage
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)
  );
});

Translated 6 files × 8 locales = 48 files in 10 minutes.

Pro tip: For production, use professional translators. Auto-translation is OK for MVP.

Performance Optimization

Before

  • Lighthouse SEO: 89
  • FCP: 1.8s
  • LCP: 3.2s

Changes

  1. Static Generation for All Pages
export const revalidate = 3600; // ISR every hour
  1. Lazy Load Non-Critical Content
const DeferredPricing = dynamic(() => import('@/features/Pricing'), {
  loading: () => <Skeleton />,
});
  1. Optimize Images
<Image
  src="/hero.jpg"
  width={1200}
  height={600}
  priority // Above fold
  quality={85}
/>;
  1. Minimize JavaScript
// next.config.js
experimental: {
  optimizePackageImports: ['@radix-ui/react-icons'],
}

After

  • ✅ Lighthouse SEO: 96
  • ✅ FCP: 0.9s (-50%)
  • ✅ LCP: 1.4s (-56%)
  • ✅ Google indexed: 147 pages (was 12)

Structured Data for Better Rankings

Added 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',
  ],
};

For blog posts:

{
  '@type': 'BlogPosting',
  headline: post.title,
  datePublished: post.date,
  author: { '@type': 'Organization', name: post.author },
  keywords: post.tags.join(', '),
}

Verification: Use Google Rich Results Test

Monitoring & Iteration

Tools We Use

  1. Google Search Console

    • Track indexing progress
    • Find crawl errors
    • Monitor search queries
  2. Bing Webmaster Tools

    • Submit sitemap
    • Track Bing indexing
  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. Custom Analytics

Track which language users prefer:

// middleware.ts
const acceptLanguage = request.headers.get('accept-language');
analytics.track('locale_preference', {
  detected: acceptLanguage,
  chosen: locale,
});

Lessons Learned

Do's ✅

  • Use next-sitemap for complex sites
  • Always set localePrefix: 'always'
  • Bypass middleware for SEO files
  • Add structured data
  • Monitor Google Search Console

Don'ts ❌

  • Don't hardcode sitemap URLs
  • Don't block crawlers in middleware
  • Don't forget hreflang tags
  • Don't skip performance optimization
  • Don't rely on auto-translation for production

Next Steps

  1. Content Localization - Hire native speakers for key markets
  2. Regional SEO - Optimize for Baidu (China), Naver (Korea)
  3. Local Structured Data - Add local business schemas
  4. Performance - Target 100 Lighthouse score

All code snippets are from our production environment. Feel free to steal and adapt.

Questions? Email us: tech@catlove.cc

C
CatLove Cloud Tech Team
CatLove Cloud 技术团队