Skip to content

Vaynerov Technologies

Wir entwickeln nicht einfach — wir zaubern jede Codezeile und jeden Pixel.

Alle ArtikelDeep Dives

Sechs Sprachen, eine Codebasis: i18n als Architektur

vaynerov.com/de/leistungen ist keine englische Seite mit angeschraubtem Sprachumschalter. Es ist eine andere URL, ein anderer Katalog, ein anderer Satz hreflang-Alternates und eine sorgfältig rationierte Scheibe JavaScript. Das hier ist die Architektur unter sechs Sprachen.

Vaynerov TechnologiesDas Studio
Veröffentlicht Aktualisiert 10 Min. Lesezeit
Auf dieser Seite
  1. Der Slug ist Teil der Übersetzung
  2. Eine Middleware, drei Umgehungen
  3. 1,19 MB Message-Kataloge — und das russische Problem
  4. Elf Namespaces erreichen den Browser
  5. Übersetzungen, die sich ohne Deploy ändern
  6. Fallbacks, hreflang und was die Crawler bekommen

Sehen Sie sich die URL an: vaynerov.com/de/leistungen. Nichts daran ist englisch. Das Locale-Segment sagt Deutsch, und der Slug sagt es auch — Leistungen, nicht services. Wer aus einem deutschen Suchergebnis kommt, sieht in der Adressleiste nie ein englisches Wort, und ein Crawler, der diese Seite indexiert, indexiert einen deutschen Pfad und nicht einen englischen im ?lang=de-Kostüm.

Diese eine Entscheidung — dass der Slug Teil der Übersetzung ist — macht Internationalisierung auf dieser Seite zu einem Architekturproblem statt zu einem Übersetzungsproblem. Übersetzung ist eine Tabelle. Architektur ist das, was passiert, wenn aus einer kanonischen Route sechs URLs, sechs Sitemap-Einträge, sechs hreflang-Alternates und ein gemeinsamer React-Baum werden müssen, ohne den Code zu verdoppeln oder die Bytes zu verdreifachen, die auf einem Telefon ankommen.

6
Locales — en, ru, es, de, fr, pt
28
lokalisierte Pfadeinträge
1.19 MB
Message-Kataloge auf der Platte
11
an den Browser ausgelieferte Namespaces

Die gemessene Gestalt der i18n-Schicht: was geroutet wird, was gespeichert wird, und der kleine Bruchteil davon, den ein Browser je herunterlädt.

Der Slug ist Teil der Übersetzung

Wir fahren next-intl mit localePrefix: "as-needed" und Englisch als Standard-Locale. Englische URLs tragen überhaupt kein Präfix — /services ist die kanonische Leistungsseite —, während die anderen fünf Locales immer ein Präfix bekommen. Der Tausch ist Absicht: as-needed kostet bei jedem einzelnen Request eine Routing-Entscheidung und kauft dafür saubere, präfixfreie kanonische URLs für genau die Sprache, auf die die meisten Links zeigen.

Über dem Präfix sitzt die Pfadkarte: 28 Einträge, jeder paart eine interne Route mit sechs öffentlichen Slugs. Leistungen, Projekte, Preise, das Journal, Über uns, Karriere, Investoren, Kontakt, Angebot, Datenschutz und AGB — alles wird übersetzt.

const pathnames = {
  "/services": {
    en: "/services",
    ru: "/uslugi",
    es: "/servicios",
    de: "/leistungen",
    fr: "/services",
    pt: "/servicos",
  },
  // …27 more
};

Eine Route, sechs URLs. Französisch landet zufällig auf demselben Wort wie Englisch — harmlos, denn das Locale-Präfix macht es eindeutig: /services und /fr/services sind verschiedene Seiten.

Routeruesdefrpt
servicesuslugiserviciosleistungenservicesservicos
workrabotytrabajosprojekterealisationsprojetos
pricingtsenypreciospreisetarifsprecos
articlesstatiarticulosartikelarticlesartigos

Vier der 28 Pfadeinträge. Die russischen Slugs sind lateinische Transliterationen, damit jeder Pfad der Seite in einer Logzeile, einem Suchergebnis und einem Analytics-Dashboard ASCII bleibt.

Das Interessante an der Tabelle ist, was in ihr fehlt. Eine Handvoll Routen wird nie übersetzt: /museum, /games, /roadmap, /cookies, /nda. Zwei verschiedene Gründe, beide gehören ausgesprochen. Produkt-Codenamen sind Namen — das Reliquary heißt in jeder Sprache Reliquary, und wer es auf Deutsch sucht, tippt den Namen, den wir ihm gegeben haben, nicht dessen Übersetzung. Bei juristischen Standardtexten ist es umgekehrt: Diese Seiten ändern sich selten, werden häufiger von Juristen und Crawlern gelesen als von Menschen, und ein über alle sechs Locales stabiler Pfad ist mehr wert als ein lokalisierter.

Eine Route hinein, sechs URLs hinaus. Englisch überspringt das Präfix per Richtlinie; Produkt-Codenamen und Rechtsseiten überspringen die Übersetzung per Richtlinie.

Eine Middleware, drei Umgehungen

Jeder Request trifft auf einen selbst gebauten Router, der createIntlMiddleware umschließt. Die meisten gehen direkt weiter in die Locale-Verhandlung. Drei Präfixe nicht. /admin* wird an updateSession gereicht, damit das Supabase-Auth-Cookie aufgefrischt wird; /api* und /pitch* gehen unberührt an next(), weil weder das eine noch das andere etwas in einer Locale zu suchen hat, um die es nie gebeten hat. Der Matcher selbst schließt _next und alles mit einem Punkt darin aus, statische Assets wecken die Middleware also gar nicht erst auf.

Die Umgehung, die sich bezahlt macht, ist die zur Session, und die Begründung steht als Kommentar direkt daneben: Keine öffentliche Seite liest die Supabase-Session, updateSession auf öffentlichem Verkehr laufen zu lassen würde also einen Auth-Roundtrip pro Request kaufen und sonst nichts. Das ist eine bewusste Kopplung und kein Versehen — an dem Tag, an dem eine öffentliche Seite einen eingeloggten Nutzer braucht, ist dieser Kommentar das Erste, was neu bewertet werden muss, und genau deshalb ist er ein Kommentar und kein Schweigen.

1,19 MB Message-Kataloge — und das russische Problem

Sechs JSON-Kataloge halten jede Zeichenkette der Seite. Sie sind nicht gleich groß.

LocaleKatalog-Bytesgegenüber Englisch
en170.069
es186.854+10 %
pt187.552+10 %
de188.041+11 %
fr193.393+14 %
ru260.610+53 %

Größe der Message-Kataloge, gemessen mit wc -c. Gesamt über sechs Locales: 1.186.519 Bytes.

Vier der fünf Übersetzungen liegen zehn bis vierzehn Prozent über dem Englischen, was ungefähr die Ausdehnung ist, die einem jede romanische oder germanische Übersetzung gratis beschert. Russisch liegt bei +53 %, und man sollte präzise sagen, warum, denn die Zahl schmeichelt dem falschen Schluss: Ein großer Teil dieser Lücke ist UTF-8-Arithmetik. Kyrillische Zeichen kosten je zwei Bytes, wo lateinische eines kosten, ein russischer Satz gleicher Länge landet also bei doppelten Bytes, bevor auch nur ein zusätzliches Wort dazukommt. Russisch ist auch wirklich wortreicher als Englisch — aber die Byte-Zahl misst Kodierung und Prosa zusammen, und etwas anderes zu behaupten hieße, unsere eigenen Diagramme lügen zu lassen.

Die Größe zählt, weil die Kataloge so geladen werden, wie sie geladen werden: Die Request-Konfiguration importiert sie als Ganzes, pro Request. Für Oberflächentexte ist das ein guter Handel und für lange Prosa ein furchtbarer — weshalb der Artikel, den Sie gerade lesen, überhaupt nicht in einem Message-Katalog wohnt. Journal-Stücke sind typisierte Content-Module unter src/content/articles/<slug>/<locale>.ts und werden nur von den Artikel-Routen importiert. Lange Texte in den Katalogen würden jede Seite der Website mit Text belasten, der auf einer einzigen erscheint.

Elf Namespaces erreichen den Browser

Server-Komponenten übersetzen auf dem Server; die Zeichenketten verlassen den Render nie. Client-Komponenten können das nicht, und die naive Lösung — den ganzen Katalog an NextIntlClientProvider zu geben — serialisierte über 10 KB Zeichenketten, die keine Client-Komponente der Seite je lesen würde, in die RSC-Payload jeder einzelnen Seite.

Der Provider bekommt deshalb eine handverlesene Teilmenge. Ein Helfer pickMessages löst gepunktete Pfade gegen den Katalog auf, und eine einzige Liste GLOBAL_CLIENT_NAMESPACES — elf Einträge — entscheidet, was ein Browser sehen darf. Manche dieser Einträge sind keine ganzen Namespaces, sondern einzelne Blätter: Von pages.services gehen nur drei Blätter mit, weil drei davon von einer interaktiven Komponente gebraucht werden und der Rest dieses Namespace auf dem Server gerendert wird. Die Wartungsprozedur, um diese Liste ehrlich zu halten, ist ein grep, direkt neben der Liste dokumentiert — die unglamouröseste und zuverlässigste Form von Dokumentation, die wir kennen.

Jede Zeichenkette, die den Browser erreicht, ist dort, weil jemand das entschieden hat. Es gibt keinen Standardfall „alles ausliefern“.

Die Ausnahme ist das Lab. Spielkomponenten sind textlastig, wie Marketingseiten es nicht sind — der Namespace games kommt für ein einziges Spiel auf rund 150 Schlüssel —, und nichts davon gehört auf die globale Positivliste. Der Games-Teilbaum mountet daher seinen eigenen verschachtelten Provider mit seinem eigenen Namespace, und genau dort wohnt eine wirklich fiese Falle.

Übersetzungen, die sich ohne Deploy ändern

Statische Kataloge sind der Boden, nicht die Decke. Texte für CMS-getriebene Flächen lassen sich aus der Datenbank überschreiben, eine Formulierungskorrektur auf Spanisch braucht also keinen Build. Das Zusammenführen ist bewusst vorsichtig: Overrides werden per setNestedValue auf ein structuredClone des importierten Katalogs angewandt, nie auf das Modulobjekt selbst — dieses Objekt teilen sich alle Requests im Prozess, und es zu mutieren würde den Override einer Besucherin zu dem aller machen. Der Lesezugriff sitzt hinter unstable_cache mit dem Tag i18n-overrides und einem Fünf-Minuten-Fenster, und Schreibvorgänge im Admin sprengen das Tag, statt es abzuwarten.

Ist die Datenbank nicht erreichbar, fällt ein nacktes catch auf das statische JSON zurück, und die Seite rendert. Das ist das richtige Verhalten — und es hat zugleich den besten Bug in der Geschichte dieses Systems hervorgebracht.

cookies() innerhalb von unstable_cache aufzurufen wirft ab Next 15. Der gecachte Override-Leser tat genau das. Der Wurf landete im nackten catch, der statische Katalog wurde ausgeliefert, jede Seite sah perfekt aus — und das Override-Feature war still gestorben.

Overrides wurden sauber gespeichert. Sie wurden sauber gecacht. Sie erschienen nur nie, und nirgends meldete etwas einen Fehler, weil der Fallback exakt die Aufgabe erledigte, für die er geschrieben worden war. Die Behebung ist klein — der Override-Lesezugriff benutzt den cookie-freien öffentlichen Supabase-Client, der ohnehin nichts in Cookies zu suchen hat —, aber die Lehre hat sie überlebt. Wir behandeln einen stillen Fallback jetzt als einen unfertigen: Wenn ein catch einen Fehlschlag schluckt, der verändert, was der Nutzer sieht, muss er irgendwo eine Spur hinterlassen — sonst ist er kein Sicherheitsnetz, sondern eine Augenbinde.

Ein Fallback, der Ihnen nicht sagen kann, dass er ausgelöst hat, versteckt irgendwann ein totes Feature.

Eine Folge der Overrides reicht bis in die Client-Payload zurück: Der Provider wird aus getMessages() gespeist und nicht aus einem rohen JSON-Import. Das JSON direkt zu importieren wäre marginal schneller und würde das Zusammenführen komplett umgehen, Client-Komponenten würden also den Text vor dem Override rendern, während die serverseitig gerenderte Hälfte derselben Seite die korrigierte Fassung zeigt. Zwei Wahrheiten auf einem Bildschirm sind schlimmer als eine langsame.

Fallbacks, hreflang und was die Crawler bekommen

Die Übersetzungsabdeckung ist nie gleichmäßig. Ein CMS-Abschnitt existiert vielleicht auf Englisch und Deutsch, aber noch nicht auf Portugiesisch, und die ehrlichen Optionen sind eine leere Fläche oder englischer Text. Wir haben uns für Englisch entschieden, auf der Inhaltsebene: fetchSectionWithFallback, fetchRowsWithFallback und fetchPageMeta versuchen jeweils die angeforderte Locale, wiederholen bei einem Fehlschlag mit Englisch und überspringen den zweiten Versuch, wenn die angeforderte Locale ohnehin schon Englisch ist. Eine teilweise übersetzte Seite verkommt zu einer zweisprachigen statt zu einer kaputten.

Die Alternates-Karte weiß davon nichts, und das ist Absicht. alternates.ts deklariert die URL-Menge — sechs hreflang-Einträge plus x-default, das auf die englische URL zeigt — und sonst nichts. Fallback-Logik hineinzumischen hieße, dass der hreflang-Graph seine Form ändert, je nachdem, wie weit eine Übersetzerin gerade ist, und das ist ein hervorragender Weg, einer Suchmaschine beizubringen, dass die eigene URL-Struktur instabil ist. Abdeckung wird dort ausgedrückt, wo Abdeckung hingehört: Die Sitemap beschränkt die hreflang-Alternates für Projekt- und Journal-Detailseiten auf die Locales, für die es tatsächlich eine veröffentlichte Zeile gibt.

Lokalisierte Slugs haben außerdem an der Crawler-Grenze eine Folge, die beim ersten Mal niemand vorhersieht. Der Angebotsprozess ist in der robots.txt gesperrt — er ist ein Trichter, keine Landingpage — und weil der Slug lokalisiert ist, gibt es nicht einen Pfad zu sperren. Es sind sechs. Rechnet man dazu, dass robots.txt-Gruppen voneinander unabhängig sind (ein Disallow in einer Gruppe gilt nicht für die nächste) und dass wir achtzehn KI-Crawler in achtzehn getrennten Gruppen erlauben, muss die Sperrliste jedes Mal vollständig wiederholt werden, von jeder Gruppe. Sie wird erzeugt und nicht getippt, aus Gründen, die auf der Hand liegen sollten.

Die Multiplikation zeigt sich auch in der Sitemap. Aus zwanzig statischen Seiten werden 120 Einträge, bevor ein einziges Spiel, eine Case Study oder ein Journal-Stück gezählt ist, und jeder dieser Einträge trägt seinen eigenen Alternates-Block. Eine Sitemap dieser Gestalt pflegt niemand von Hand; sie ist eine Funktion der Pfadkarte — derselbe Grund, warum die Pfadkarte der einzige Ort sein darf, an dem ein Slug aufgeschrieben wird. Was an sechs Stellen wiederholt werden muss, wird irgendwann an fünf wiederholt.

Die letzte Meile ist Social Metadata, wo aus sechs Locales eine Open-Graph-Karte wird — en_US, ru_RU, es_ES, de_DE, fr_FR, pt_PT —, ausgegeben von einem gemeinsamen Helfer, der immer jedes Feld neu setzt. Dieser letzte Teil ist keine Stilfrage: Next.js ersetzt openGraph pro Routensegment, statt es tief zu mischen, jede Seite, die eines ihrer eigenen Felder setzt, wirft also still die Vorgaben des Locale-Layouts weg und nimmt og:site_name und og:locale mit. Den Helfer gibt es, damit sich das niemand merken muss.

Nichts davon ist exotisch. Zusammengehalten wird es dadurch, dass jede der sechs Locales als erstklassige Fläche mit denselben Zusagen behandelt wird — eigene URL, eigener Katalog, eigene Metadaten, eigene Crawler-Geschichte — und nicht als Englisch mit einer Nachschlagetabelle davor. Das Maß dafür ist nicht der Launch; es sind die Kosten des neunundzwanzigsten Pfadeintrags, und die sind: eine Zeile in der Karte, sechs Slugs, eine Sitemap-Zeile und keine Diskussion. Warum wir diese Latte hochhalten, steht in Zaubern als Standard; hier sehen Sie, wie die Latte aussieht, wenn sie eine Routing-Tabelle ist.