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 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-sitemapworks 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:
- Native Next.js i18n - Deprecated in App Router 😢
- react-i18next - Too frontend-heavy, no SSR optimization
- 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
- Static Generation for All Pages
export const revalidate = 3600; // ISR every hour
- Lazy Load Non-Critical Content
const DeferredPricing = dynamic(() => import('@/features/Pricing'), {
loading: () => <Skeleton />,
});
- Optimize Images
<Image
src="/hero.jpg"
width={1200}
height={600}
priority // Above fold
quality={85}
/>;
- 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
-
Google Search Console
- Track indexing progress
- Find crawl errors
- Monitor search queries
-
Bing Webmaster Tools
- Submit sitemap
- Track Bing indexing
-
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
- 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-sitemapfor 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
- Content Localization - Hire native speakers for key markets
- Regional SEO - Optimize for Baidu (China), Naver (Korea)
- Local Structured Data - Add local business schemas
- 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