Menu
Zurück zum Blog
2 min read
SEO

International SEO & i18n

Internationales SEO mit Next.js. hreflang Tags, URL-Strukturen, Content-Lokalisierung und Multi-Language Routing.

International SEOi18nhreflangMultilingualLocalizationNext.js i18n
International SEO & i18n

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

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

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

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

// 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"
  }
}
// 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

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

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

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

AspectImplementationPriority
**URL Structure**Subdirectories (/de/, /en/)Critical
**hreflang**All pages, x-defaultCritical
**Canonical**Per localeCritical
**Sitemap**Multilingual with alternatesImportant
**Language Detection**Accept-Language headerImportant
**Switcher**Visible, accessibleImportant
**Content**Fully translated, not autoImportant
**Local Keywords**Research per marketImportant

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