← Zurueck zum Blog
i18nInternationalisierungNext.jsTypeScriptSEOReactlocalStorage

SEO Checker: Internationalisierung ohne Bibliothek — Custom i18n mit ~400 Strings

Marco Carstensen·10. März 2026·5 Min. Lesezeit

SEO Checker: Internationalisierung ohne Bibliothek — Custom i18n mit ~400 Strings

Der SEO Checker analysiert Webseiten, bewertet sie nach SEO-Kriterien und generiert automatisch Fix-Vorschlaege — teilweise mit KI. Nach zwei abgeschlossenen Phasen (Kern-Analyse + Fix-Automatisierung) war der naechste logische Schritt: Internationalisierung. Deutsch als Default, Englisch als zweite Sprache.

Das Ergebnis: ~400 uebersetzte Strings in 42+ Dateien, eine einzige t()-Funktion — und keine einzige externe Bibliothek.

Warum kein next-intl?

Die uebliche Empfehlung fuer i18n in Next.js ist next-intl. Fuer den SEO Checker war das aber Overkill:

  • Single-Page-App: Der Checker ist eine einzige Route — kein /de/... und /en/... Routing noetig
  • Server-Code braucht Uebersetzungen: 7 Analyzer, 10 Fix-Generatoren, AI-Prompts — alles serverseitig. next-intl hilft dort nicht direkt
  • Nur 2 Sprachen: Kein Locale-Routing, keine Middleware, keine URL-Prefixe
  • Konsistenz: Der Dark Mode nutzt bereits localStorage + Toggle. Die Sprache sollte genauso funktionieren

Die Entscheidung: Custom Lightweight i18n — zero Dependencies, maximale Kontrolle.

Die Architektur

Das System besteht aus vier Teilen:

src/i18n/
  index.ts              # t(key, locale, params?) — die zentrale Funktion
  locale-context.tsx    # React Context (useLocale, useT)
  messages/
    types.ts            # TypeScript Interface (~500 Zeilen)
    de.ts               # Deutsche Strings (~400)
    en.ts               # Englische Strings (~400)

Die t()-Funktion

Der Kern ist eine einzige Funktion, die ueberall funktioniert — in React-Komponenten, API-Routes und Library-Code:

// Server-Code (Analyzer, Fix-Generatoren, API-Routes)
import { t, type Locale } from "@/i18n";
const msg = t("analyzers.meta.titleMissing", locale);
 
// React-Komponenten
const t = useT();
<p>{t("form.placeholder")}</p>

Die Funktion loest verschachtelte Keys auf ("analyzers.meta.titleMissing"messages.analyzers.meta.titleMissing), unterstuetzt Interpolation ({count} wird durch Parameter ersetzt) und faellt auf Deutsch zurueck, wenn ein englischer Key fehlt.

React Context

Fuer Client-Komponenten gibt es einen LocaleProvider, der die Sprache aus localStorage liest und ueber useLocale() und useT() bereitstellt:

export function useT() {
  const { locale } = useLocale();
  return (key: string, params?: Record<string, string | number>) =>
    t(key, locale, params);
}

Das Pattern ist identisch zum Dark Mode Toggle — ein Button im Header, ein localStorage-Eintrag, ein Context-Provider.

TypeScript-Sicherheit

Das Messages-Interface in types.ts definiert die komplette Struktur aller Uebersetzungen. Wenn in de.ts ein Key fehlt, schreit TypeScript. Wenn in en.ts eine Section fehlt, schreit TypeScript. Kein String geht verloren.

Was uebersetzt wurde

BereichDateienStrings
UI-Komponenten13~80
Analyzer7 + Index~120
Fix-Generatoren10~100
SEO-Wissen (Tooltips)1~60
AI-Prompts1~14
Connectors3~30
API-Routes2~10
Gesamt~42~400

Jede einzelne Datei, die dem User Text anzeigt, wurde umgestellt. Von "Analyse starten" zu t("form.submit") — ueberall.

Der Locale-Flow

Auf der Client-Seite ist es simpel: LanguageToggle klicken → localStorage aktualisieren → React Context aktualisiert alle Komponenten.

Fuer den Server-Code war mehr noetig. Die Analyzer und Fix-Generatoren laufen in API-Routes — sie haben keinen Zugriff auf localStorage. Die Loesung:

// Client: Locale im Request mitschicken
body: JSON.stringify({ url, locale })
 
// Server: Locale auslesen und durchreichen
const { url, locale = "de" } = await req.json();
const results = await analyzeUrlWithProgress(url, onProgress, locale);

Jeder Analyzer und jeder Fix-Generator akzeptiert jetzt locale: Locale = "de" als Parameter. Das Default ist Deutsch, fuer Abwaertskompatibilitaet.

FOUC-Prevention

Wie beim Dark Mode gibt es auch bei der Sprache ein Flash-Problem: Wenn die Seite mit dem Default (Deutsch) rendert und dann auf Englisch umschaltet, flackert der Text kurz. Die Loesung ist ein Blocking-Script im <head>:

<script>
  (function() {
    var locale = localStorage.getItem("seo-checker-locale") || "de";
    document.documentElement.lang = locale;
  })();
</script>

Das setzt das lang-Attribut vor dem ersten Paint. Der React-Hydration-Schritt uebernimmt dann nahtlos.

AI-Prompts: Zweisprachig

Ein interessantes Detail: Die KI-Prompts fuer Claude (Title-Generierung, Alt-Text, JSON-LD etc.) sind teilweise uebersetzt. Die Einleitung ("Du bist ein SEO-Experte...") ist locale-abhaengig, aber die strukturierten Anweisungen ("Return ONLY the tag, no quotes") bleiben auf Englisch — weil Claude damit zuverlaessiger arbeitet.

export function buildTitlePrompt(context: PromptContext, locale?: Locale) {
  const l = locale ?? "de";
  return `${t("aiPrompts.titlePrompt", l)}
Current title: "${context.currentTitle ?? ""}"
URL: ${context.url}
Return ONLY the title tag content, no quotes.`;
}

Factory-Pattern fuer Connectors

Die drei Connectors (Clipboard, WordPress, Custom CMS) waren urspruenglich statische Objekte mit hardcodierten deutschen Strings. Fuer i18n wurden sie zu Factory-Funktionen umgebaut:

// Vorher
export const clipboardConnector: SEOConnector = { name: "Zwischenablage", ... };
 
// Nachher
export function createClipboardConnector(locale: Locale = "de"): SEOConnector {
  return { name: t("connectors.clipboard.name", locale), ... };
}
 
// Abwaertskompatibel
export const clipboardConnector = createClipboardConnector("de");

Bestehender Code funktioniert weiter, neuer Code kann die Locale uebergeben.

Fazit

~400 Strings, 42 Dateien, 0 externe Bibliotheken. Das Custom-i18n-System ist klein, typesicher und deckt alles ab — von UI-Buttons ueber Server-Analyzer bis zu KI-Prompts.

Die Entscheidung gegen next-intl war richtig: Kein URL-Routing, keine Middleware, kein Overhead. Stattdessen eine t()-Funktion, ein React Context und ein localStorage-Eintrag — exakt das gleiche Pattern wie der Dark Mode Toggle.