Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
692 lines
19 KiB
Markdown
692 lines
19 KiB
Markdown
# International SEO & i18n
|
|
|
|
**Meta-Description:** Internationales SEO mit Next.js. hreflang Tags, URL-Strukturen, Content-Lokalisierung und Multi-Language Routing.
|
|
|
|
**Keywords:** International SEO, i18n, hreflang, Multilingual, Localization, Next.js i18n, URL Structure, Language Targeting
|
|
|
|
---
|
|
|
|
## Einführung
|
|
|
|
**Internationales SEO** erfordert mehr als nur Übersetzung. Richtige **hreflang-Implementation**, **URL-Strukturen** und **Lokalisierung** sind entscheidend. Dieser Guide zeigt Best Practices für mehrsprachige Websites mit Next.js 15.
|
|
|
|
---
|
|
|
|
## International SEO Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ INTERNATIONAL SEO ARCHITECTURE │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ URL Strategies: │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ 1. Subdirectories (Recommended): │ │
|
|
│ │ example.com/de/ │ │
|
|
│ │ example.com/en/ │ │
|
|
│ │ example.com/fr/ │ │
|
|
│ │ ✓ Easy to set up │ │
|
|
│ │ ✓ Single domain authority │ │
|
|
│ │ ✓ Clear language indication │ │
|
|
│ │ │ │
|
|
│ │ 2. Subdomains: │ │
|
|
│ │ de.example.com │ │
|
|
│ │ en.example.com │ │
|
|
│ │ ✗ Separate domain authority │ │
|
|
│ │ ✓ Easy geo-targeting │ │
|
|
│ │ │ │
|
|
│ │ 3. ccTLDs (Country-Specific): │ │
|
|
│ │ example.de │ │
|
|
│ │ example.co.uk │ │
|
|
│ │ ✓ Strong geo-signal │ │
|
|
│ │ ✗ Higher cost, separate SEO │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ hreflang Implementation: │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ <link rel="alternate" hreflang="de" │ │
|
|
│ │ href="https://example.com/de/page" /> │ │
|
|
│ │ <link rel="alternate" hreflang="en" │ │
|
|
│ │ href="https://example.com/en/page" /> │ │
|
|
│ │ <link rel="alternate" hreflang="x-default" │ │
|
|
│ │ href="https://example.com/page" /> │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ Content Strategy: │
|
|
│ ├── Translation (Basic) │
|
|
│ ├── Localization (Cultural Adaptation) │
|
|
│ └── Transcreation (Full Adaptation) │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Next.js i18n Setup
|
|
|
|
```typescript
|
|
// next.config.js - i18n Configuration
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
// Built-in i18n (für Pages Router, App Router nutzt Middleware)
|
|
// i18n: {
|
|
// locales: ['de', 'en', 'fr', 'es'],
|
|
// defaultLocale: 'de',
|
|
// localeDetection: true
|
|
// }
|
|
};
|
|
|
|
module.exports = nextConfig;
|
|
|
|
// middleware.ts - App Router i18n
|
|
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
import { match } from '@formatjs/intl-localematcher';
|
|
import Negotiator from 'negotiator';
|
|
|
|
const locales = ['de', 'en', 'fr', 'es'];
|
|
const defaultLocale = 'de';
|
|
|
|
function getLocale(request: NextRequest): string {
|
|
// Check cookie first
|
|
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
|
|
if (cookieLocale && locales.includes(cookieLocale)) {
|
|
return cookieLocale;
|
|
}
|
|
|
|
// Then check Accept-Language header
|
|
const headers = { 'accept-language': request.headers.get('accept-language') || '' };
|
|
const languages = new Negotiator({ headers }).languages();
|
|
|
|
try {
|
|
return match(languages, locales, defaultLocale);
|
|
} catch {
|
|
return defaultLocale;
|
|
}
|
|
}
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const pathname = request.nextUrl.pathname;
|
|
|
|
// Skip for static files and API routes
|
|
if (
|
|
pathname.startsWith('/_next') ||
|
|
pathname.startsWith('/api') ||
|
|
pathname.startsWith('/static') ||
|
|
pathname.includes('.') // files with extensions
|
|
) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Check if pathname has locale
|
|
const pathnameHasLocale = locales.some(
|
|
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
|
);
|
|
|
|
if (pathnameHasLocale) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Redirect to locale
|
|
const locale = getLocale(request);
|
|
const newUrl = new URL(`/${locale}${pathname}`, request.url);
|
|
|
|
return NextResponse.redirect(newUrl);
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ['/((?!_next|api|static|.*\\..*).*)', '/']
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## App Router Structure
|
|
|
|
```typescript
|
|
// app/[locale]/layout.tsx - Locale Layout
|
|
import { notFound } from 'next/navigation';
|
|
import { NextIntlClientProvider } from 'next-intl';
|
|
import { getMessages, getTranslations } from 'next-intl/server';
|
|
|
|
const locales = ['de', 'en', 'fr', 'es'];
|
|
|
|
export function generateStaticParams() {
|
|
return locales.map(locale => ({ locale }));
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params
|
|
}: {
|
|
params: Promise<{ locale: string }>
|
|
}) {
|
|
const { locale } = await params;
|
|
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
|
|
return {
|
|
title: {
|
|
template: `%s | ${t('siteName')}`,
|
|
default: t('siteName')
|
|
},
|
|
description: t('siteDescription'),
|
|
alternates: {
|
|
canonical: `https://example.com/${locale}`,
|
|
languages: {
|
|
'de': 'https://example.com/de',
|
|
'en': 'https://example.com/en',
|
|
'fr': 'https://example.com/fr',
|
|
'es': 'https://example.com/es',
|
|
'x-default': 'https://example.com'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export default async function LocaleLayout({
|
|
children,
|
|
params
|
|
}: {
|
|
children: React.ReactNode;
|
|
params: Promise<{ locale: string }>;
|
|
}) {
|
|
const { locale } = await params;
|
|
|
|
if (!locales.includes(locale)) {
|
|
notFound();
|
|
}
|
|
|
|
const messages = await getMessages();
|
|
|
|
return (
|
|
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
|
|
<body>
|
|
<NextIntlClientProvider messages={messages}>
|
|
{children}
|
|
</NextIntlClientProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// app/[locale]/page.tsx - Localized Page
|
|
import { getTranslations } from 'next-intl/server';
|
|
import { Link } from '@/navigation';
|
|
|
|
interface PageProps {
|
|
params: Promise<{ locale: string }>;
|
|
}
|
|
|
|
export default async function HomePage({ params }: PageProps) {
|
|
const { locale } = await params;
|
|
const t = await getTranslations('Home');
|
|
|
|
return (
|
|
<main>
|
|
<h1>{t('title')}</h1>
|
|
<p>{t('description')}</p>
|
|
|
|
<Link href="/about">{t('aboutLink')}</Link>
|
|
</main>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## hreflang Implementation
|
|
|
|
```typescript
|
|
// lib/i18n/hreflang.ts - hreflang Generator
|
|
interface HreflangConfig {
|
|
locales: string[];
|
|
defaultLocale: string;
|
|
baseUrl: string;
|
|
}
|
|
|
|
interface HreflangLink {
|
|
hrefLang: string;
|
|
href: string;
|
|
}
|
|
|
|
export function generateHreflangLinks(
|
|
path: string,
|
|
config: HreflangConfig
|
|
): HreflangLink[] {
|
|
const { locales, defaultLocale, baseUrl } = config;
|
|
|
|
const links: HreflangLink[] = locales.map(locale => ({
|
|
hrefLang: locale,
|
|
href: `${baseUrl}/${locale}${path}`
|
|
}));
|
|
|
|
// x-default für Sprachauswahl / Default
|
|
links.push({
|
|
hrefLang: 'x-default',
|
|
href: `${baseUrl}/${defaultLocale}${path}`
|
|
});
|
|
|
|
return links;
|
|
}
|
|
|
|
// components/Hreflang.tsx - Hreflang Component
|
|
import { headers } from 'next/headers';
|
|
|
|
const config: HreflangConfig = {
|
|
locales: ['de', 'en', 'fr', 'es'],
|
|
defaultLocale: 'de',
|
|
baseUrl: 'https://example.com'
|
|
};
|
|
|
|
export async function Hreflang() {
|
|
const headersList = await headers();
|
|
const pathname = headersList.get('x-pathname') || '/';
|
|
|
|
// Remove locale from pathname
|
|
const path = pathname.replace(/^\/(de|en|fr|es)/, '');
|
|
|
|
const links = generateHreflangLinks(path, config);
|
|
|
|
return (
|
|
<>
|
|
{links.map(link => (
|
|
<link
|
|
key={link.hrefLang}
|
|
rel="alternate"
|
|
hrefLang={link.hrefLang}
|
|
href={link.href}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Usage in layout.tsx
|
|
export default function RootLayout({ children }) {
|
|
return (
|
|
<html>
|
|
<head>
|
|
<Hreflang />
|
|
</head>
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// app/[locale]/[...slug]/page.tsx - Dynamic hreflang
|
|
import { Metadata } from 'next';
|
|
|
|
interface PageProps {
|
|
params: Promise<{ locale: string; slug: string[] }>;
|
|
}
|
|
|
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
const { locale, slug } = await params;
|
|
const path = `/${slug.join('/')}`;
|
|
|
|
const locales = ['de', 'en', 'fr', 'es'];
|
|
const baseUrl = 'https://example.com';
|
|
|
|
// Generate language alternates
|
|
const languages: Record<string, string> = {};
|
|
locales.forEach(loc => {
|
|
languages[loc] = `${baseUrl}/${loc}${path}`;
|
|
});
|
|
languages['x-default'] = `${baseUrl}/de${path}`;
|
|
|
|
return {
|
|
alternates: {
|
|
canonical: `${baseUrl}/${locale}${path}`,
|
|
languages
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Translation Management
|
|
|
|
```typescript
|
|
// messages/de.json
|
|
{
|
|
"Metadata": {
|
|
"siteName": "Meine Website",
|
|
"siteDescription": "Die beste Website für..."
|
|
},
|
|
"Home": {
|
|
"title": "Willkommen",
|
|
"description": "Entdecken Sie unsere Produkte und Dienstleistungen.",
|
|
"aboutLink": "Über uns"
|
|
},
|
|
"Navigation": {
|
|
"home": "Startseite",
|
|
"about": "Über uns",
|
|
"products": "Produkte",
|
|
"contact": "Kontakt"
|
|
},
|
|
"Common": {
|
|
"readMore": "Mehr lesen",
|
|
"learnMore": "Mehr erfahren",
|
|
"submit": "Absenden",
|
|
"cancel": "Abbrechen"
|
|
}
|
|
}
|
|
|
|
// messages/en.json
|
|
{
|
|
"Metadata": {
|
|
"siteName": "My Website",
|
|
"siteDescription": "The best website for..."
|
|
},
|
|
"Home": {
|
|
"title": "Welcome",
|
|
"description": "Discover our products and services.",
|
|
"aboutLink": "About us"
|
|
},
|
|
"Navigation": {
|
|
"home": "Home",
|
|
"about": "About",
|
|
"products": "Products",
|
|
"contact": "Contact"
|
|
},
|
|
"Common": {
|
|
"readMore": "Read more",
|
|
"learnMore": "Learn more",
|
|
"submit": "Submit",
|
|
"cancel": "Cancel"
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// lib/i18n/index.ts - Translation Utilities
|
|
import { getRequestConfig } from 'next-intl/server';
|
|
|
|
export default getRequestConfig(async ({ locale }) => ({
|
|
messages: (await import(`@/messages/${locale}.json`)).default
|
|
}));
|
|
|
|
// i18n.ts - Configuration
|
|
export const locales = ['de', 'en', 'fr', 'es'] as const;
|
|
export type Locale = (typeof locales)[number];
|
|
|
|
export const localeNames: Record<Locale, string> = {
|
|
de: 'Deutsch',
|
|
en: 'English',
|
|
fr: 'Français',
|
|
es: 'Español'
|
|
};
|
|
|
|
export const localeFlags: Record<Locale, string> = {
|
|
de: '🇩🇪',
|
|
en: '🇬🇧',
|
|
fr: '🇫🇷',
|
|
es: '🇪🇸'
|
|
};
|
|
|
|
export const defaultLocale: Locale = 'de';
|
|
|
|
// Check if locale is valid
|
|
export function isValidLocale(locale: string): locale is Locale {
|
|
return locales.includes(locale as Locale);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Language Switcher
|
|
|
|
```tsx
|
|
// components/LanguageSwitcher.tsx
|
|
'use client';
|
|
|
|
import { useLocale } from 'next-intl';
|
|
import { usePathname, useRouter } from '@/navigation';
|
|
import { locales, localeNames, localeFlags, Locale } from '@/lib/i18n';
|
|
|
|
export function LanguageSwitcher() {
|
|
const locale = useLocale();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
const handleChange = (newLocale: Locale) => {
|
|
// Set cookie for persistence
|
|
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
|
|
|
|
// Navigate to new locale
|
|
router.replace(pathname, { locale: newLocale });
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<select
|
|
value={locale}
|
|
onChange={(e) => handleChange(e.target.value as Locale)}
|
|
className="appearance-none bg-white border rounded-md px-4 py-2 pr-8"
|
|
aria-label="Select language"
|
|
>
|
|
{locales.map((loc) => (
|
|
<option key={loc} value={loc}>
|
|
{localeFlags[loc]} {localeNames[loc]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Alternative: Link-based Switcher
|
|
export function LanguageSwitcherLinks() {
|
|
const locale = useLocale();
|
|
const pathname = usePathname();
|
|
|
|
return (
|
|
<nav aria-label="Language selection">
|
|
<ul className="flex gap-2">
|
|
{locales.map((loc) => (
|
|
<li key={loc}>
|
|
<Link
|
|
href={pathname}
|
|
locale={loc}
|
|
className={`px-3 py-1 rounded ${
|
|
locale === loc
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 hover:bg-gray-200'
|
|
}`}
|
|
aria-current={locale === loc ? 'true' : undefined}
|
|
>
|
|
{localeFlags[loc]} {loc.toUpperCase()}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Localized Sitemap
|
|
|
|
```typescript
|
|
// app/sitemap.ts - Multilingual Sitemap
|
|
import { MetadataRoute } from 'next';
|
|
import { locales, defaultLocale } from '@/lib/i18n';
|
|
import { getAllPages } from '@/lib/content';
|
|
|
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
const baseUrl = 'https://example.com';
|
|
const pages = await getAllPages();
|
|
|
|
const sitemap: MetadataRoute.Sitemap = [];
|
|
|
|
// Static pages for each locale
|
|
const staticPaths = ['', '/about', '/contact', '/products'];
|
|
|
|
for (const path of staticPaths) {
|
|
for (const locale of locales) {
|
|
const alternates: Record<string, string> = {};
|
|
|
|
locales.forEach(loc => {
|
|
alternates[loc] = `${baseUrl}/${loc}${path}`;
|
|
});
|
|
alternates['x-default'] = `${baseUrl}/${defaultLocale}${path}`;
|
|
|
|
sitemap.push({
|
|
url: `${baseUrl}/${locale}${path}`,
|
|
lastModified: new Date(),
|
|
changeFrequency: path === '' ? 'daily' : 'weekly',
|
|
priority: path === '' ? 1.0 : 0.8,
|
|
alternates: {
|
|
languages: alternates
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Dynamic pages (blog posts, products)
|
|
for (const page of pages) {
|
|
for (const locale of locales) {
|
|
// Check if translation exists
|
|
const hasTranslation = page.translations?.includes(locale);
|
|
if (!hasTranslation && locale !== defaultLocale) continue;
|
|
|
|
const alternates: Record<string, string> = {};
|
|
const availableLocales = page.translations || [defaultLocale];
|
|
|
|
availableLocales.forEach(loc => {
|
|
alternates[loc] = `${baseUrl}/${loc}/${page.slug}`;
|
|
});
|
|
alternates['x-default'] = `${baseUrl}/${defaultLocale}/${page.slug}`;
|
|
|
|
sitemap.push({
|
|
url: `${baseUrl}/${locale}/${page.slug}`,
|
|
lastModified: new Date(page.updatedAt),
|
|
changeFrequency: 'weekly',
|
|
priority: 0.7,
|
|
alternates: {
|
|
languages: alternates
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return sitemap;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## SEO Best Practices
|
|
|
|
```typescript
|
|
// lib/i18n/seo.ts - i18n SEO Helpers
|
|
import { Metadata } from 'next';
|
|
|
|
interface LocalizedSEOConfig {
|
|
title: Record<string, string>;
|
|
description: Record<string, string>;
|
|
keywords: Record<string, string[]>;
|
|
ogImage?: Record<string, string>;
|
|
}
|
|
|
|
export function generateLocalizedMetadata(
|
|
config: LocalizedSEOConfig,
|
|
locale: string,
|
|
path: string
|
|
): Metadata {
|
|
const baseUrl = 'https://example.com';
|
|
const locales = ['de', 'en', 'fr', 'es'];
|
|
|
|
// Language alternates
|
|
const languages: Record<string, string> = {};
|
|
locales.forEach(loc => {
|
|
languages[loc] = `${baseUrl}/${loc}${path}`;
|
|
});
|
|
languages['x-default'] = `${baseUrl}/de${path}`;
|
|
|
|
return {
|
|
title: config.title[locale],
|
|
description: config.description[locale],
|
|
keywords: config.keywords[locale],
|
|
|
|
alternates: {
|
|
canonical: `${baseUrl}/${locale}${path}`,
|
|
languages
|
|
},
|
|
|
|
openGraph: {
|
|
title: config.title[locale],
|
|
description: config.description[locale],
|
|
url: `${baseUrl}/${locale}${path}`,
|
|
siteName: locale === 'de' ? 'Meine Website' : 'My Website',
|
|
locale: getOGLocale(locale),
|
|
alternateLocales: locales
|
|
.filter(l => l !== locale)
|
|
.map(getOGLocale),
|
|
images: config.ogImage?.[locale] ? [config.ogImage[locale]] : undefined
|
|
}
|
|
};
|
|
}
|
|
|
|
function getOGLocale(locale: string): string {
|
|
const map: Record<string, string> = {
|
|
de: 'de_DE',
|
|
en: 'en_US',
|
|
fr: 'fr_FR',
|
|
es: 'es_ES'
|
|
};
|
|
return map[locale] || 'en_US';
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
| Aspect | Implementation | Priority |
|
|
|--------|---------------|----------|
|
|
| **URL Structure** | Subdirectories (/de/, /en/) | Critical |
|
|
| **hreflang** | All pages, x-default | Critical |
|
|
| **Canonical** | Per locale | Critical |
|
|
| **Sitemap** | Multilingual with alternates | Important |
|
|
| **Language Detection** | Accept-Language header | Important |
|
|
| **Switcher** | Visible, accessible | Important |
|
|
| **Content** | Fully translated, not auto | Important |
|
|
| **Local Keywords** | Research per market | Important |
|
|
|
|
---
|
|
|
|
## Fazit
|
|
|
|
International SEO erfordert:
|
|
|
|
1. **Klare URL-Struktur**: Subdirectories für beste Balance
|
|
2. **hreflang Tags**: Korrekt auf allen Seiten
|
|
3. **Lokalisierung**: Mehr als nur Übersetzung
|
|
4. **Technische Basis**: Sitemaps, Canonical, Meta
|
|
|
|
Internationales SEO öffnet globale Märkte.
|
|
|
|
---
|
|
|
|
## Bildprompts
|
|
|
|
1. "World map with language flags, multilingual website concept"
|
|
2. "hreflang tag implementation diagram, connected language versions"
|
|
3. "Language switcher UI designs, dropdown and flags"
|
|
|
|
---
|
|
|
|
## Quellen
|
|
|
|
- [Google i18n SEO Guide](https://developers.google.com/search/docs/specialty/international)
|
|
- [Next.js Internationalization](https://nextjs.org/docs/app/building-your-application/routing/internationalization)
|
|
- [next-intl Documentation](https://next-intl-docs.vercel.app/)
|
|
- [Ahrefs International SEO](https://ahrefs.com/blog/international-seo/)
|