Next.js 15 multilingual sites: routing, SEO, and delivery details
The hard part of a multilingual site is usually not translating a few strings. It is whether routing, SEO, content structure, and performance details can still hold together. In Next.js 15, App Router puts those building blocks in clearer places, but the setup still needs deliberate choices.
Project Initialization
First, let's create a new Next.js 15 project:
npx create-next-app@latest my-multilingual-site
cd my-multilingual-site
npm install next-intl
Internationalization Configuration
1. Create Language Configuration File
Create src/lib/i18n.ts:
export const locales = ['zh', 'en', 'fr', 'de'] as const;
export type Locale = typeof locales[number];
export const defaultLocale: Locale = 'en';
export const localeNames: Record<Locale, string> = {
zh: '中文',
en: 'English',
fr: 'Français',
de: 'Deutsch'
};
2. Configure Middleware
Create middleware.ts:
import { createMiddleware } from 'next-intl/middleware';
import { locales, defaultLocale } from './src/lib/i18n';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'always'
});
export const config = {
matcher: [
'/((?!api|_next|_vercel|.*\..*).*)',
'/([\w-]+)?/users/(.+)'
]
};
Route Structure Design
Using App Router's dynamic routing features:
src/app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/
│ │ └── page.tsx
│ ├── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ └── contact/
│ └── page.tsx
├── globals.css
└── layout.tsx
Translation File Management
Create Translation Files
messages/zh.json:
{
"navigation": {
"home": "首页",
"about": "关于我们",
"blog": "博客",
"contact": "联系我们"
},
"home": {
"title": "欢迎来到我们的网站",
"description": "这是一个多语言网站示例"
}
}
messages/en.json:
{
"navigation": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"home": {
"title": "Welcome to Our Website",
"description": "This is a multilingual website example"
}
}
Component Internationalization
Create Navigation Component
'use client';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import Link from 'next/link';
export default function Navigation() {
const t = useTranslations('navigation');
const params = useParams();
const locale = params.locale as string;
const navItems = [
{ key: 'home', href: `/${locale}` },
{ key: 'about', href: `/${locale}/about` },
{ key: 'blog', href: `/${locale}/blog` },
{ key: 'contact', href: `/${locale}/contact` }
];
return (
<nav className="flex space-x-6">
{navItems.map(item => (
<Link
key={item.key}
href={item.href}
className="hover:text-blue-600 transition-colors"
>
{t(item.key)}
</Link>
))}
</nav>
);
}
SEO Optimization
Multilingual SEO Configuration
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
interface Props {
params: { locale: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const t = await getTranslations({ locale: params.locale, namespace: 'metadata' });
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `https://example.com/${params.locale}`,
languages: {
'zh': 'https://example.com/zh',
'en': 'https://example.com/en',
'fr': 'https://example.com/fr',
'de': 'https://example.com/de'
}
},
openGraph: {
title: t('title'),
description: t('description'),
locale: params.locale,
alternateLocale: ['zh', 'en', 'fr', 'de'].filter(l => l !== params.locale)
}
};
}
Performance Optimization
1. Dynamic Import of Translation Files
async function getMessages(locale: string) {
try {
return (await import(`../messages/${locale}.json`)).default;
} catch (error) {
return (await import('../messages/en.json')).default;
}
}
2. Static Generation Optimization
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
Language Switcher
'use client';
import { useParams, usePathname } from 'next/navigation';
import Link from 'next/link';
import { locales, localeNames } from '@/lib/i18n';
export default function LanguageSwitcher() {
const params = useParams();
const pathname = usePathname();
const currentLocale = params.locale as string;
const getLocalizedPath = (locale: string) => {
const segments = pathname.split('/');
segments[1] = locale;
return segments.join('/');
};
return (
<div className="relative">
<select
value={currentLocale}
onChange={(e) => {
const newPath = getLocalizedPath(e.target.value);
window.location.href = newPath;
}}
className="bg-white border border-gray-300 rounded px-3 py-1"
>
{locales.map(locale => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</div>
);
}
Implementation Notes
1. Translation Key Naming Conventions
- Use nested structures to organize translation keys
- Keep key names concise and descriptive
- Use consistent naming conventions
2. Content Management
- Consider using CMS for multilingual content management
- Implement translation pipelines
- Regularly review and update translations
3. Testing Strategy
// Test multilingual routing
describe('Multilingual Routing', () => {
test('should redirect to default locale', () => {
// Test logic
});
test('should preserve locale in navigation', () => {
// Test logic
});
});
Conclusion
In Next.js 15, the multilingual pieces are easier to reason about once routing, message files, metadata, and static generation each have a clear place in the structure.
Key takeaways:
- Use middleware for language detection and redirection
- Properly organize translation files and route structure
- Focus on SEO optimization and performance improvement
- Establish comprehensive testing and maintenance processes
If you already know the site needs to go multilingual, settle the routing layer, message files, and metadata structure first. Once those three stay stable, CMS integration and dynamic translation are much less likely to turn into rework.