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-intlhilft 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
| Bereich | Dateien | Strings |
|---|---|---|
| UI-Komponenten | 13 | ~80 |
| Analyzer | 7 + Index | ~120 |
| Fix-Generatoren | 10 | ~100 |
| SEO-Wissen (Tooltips) | 1 | ~60 |
| AI-Prompts | 1 | ~14 |
| Connectors | 3 | ~30 |
| API-Routes | 2 | ~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.