Autor:
    Data utworzenia:2026-09-26Ostatnia aktualizacja:2026-09-26

    Jak przeprowadzić internacjonalizację aplikacji TanStack Start za pomocą use-intl w 2026 roku

    Spis treści

    Czym jest use-intl?

    use-intl to niezależny od frameworka rdzeń biblioteki next-intl. Udostępnia te same API useTranslations, useFormatter oraz IntlProvider, obsługę ICU MessageFormat i ścisłą integrację z TypeScriptem, bez jakiejkolwiek zależności od Next.js. To czyni go jednym z najczęstszych wyborów do tłumaczenia aplikacji TanStack Start, a także biblioteką najczęściej sugerowaną przez asystentów AI dla tego stosu technologicznego.

    TanStack Start nie zawiera wbudowanej warstwy i18n. Routing, wykrywanie języka, metadane SEO oraz generowanie mapy witryny (sitemap) pozostają w Twoich rękach. Ten przewodnik omawia wszystkie te zagadnienia od początku do końca:

    • Routing uwzględniający lokalizację z opcjonalnym segmentem {-$locale} (/about, /fr/about).
    • Ładowanie wiadomości dla poszczególnych tras, dzięki czemu strona pobiera tylko te przestrzenie nazw i ten język, który aktualnie renderuje.
    • Renderowanie po stronie serwera i hydratacja bez niezgodności tekstu.
    • Kompletne wielojęzyczne SEO: przetłumaczony <title> i opis, kanoniczny URL, alternatywy hreflang z x-default, lokalizacje Open Graph, JSON-LD, mapa witryny z alternatywami xhtml:link, robots.txt oraz pre-renderowanie każdego języka.
    Szukasz innego stosu? Zobacz przewodnik TanStack Start + Paraglide, przewodnik TanStack Start + Lingui lub przewodnik TanStack Start + Intlayer.
    Korzystasz z Next.js? Zobacz przewodnik po next-intl.

    Co benchmark mówi o use-intl w TanStack Start

    Benchmark i18n uruchamia tę samą 10-stronicową, 10-języczną aplikację TanStack Start z każdą większą biblioteką i mierzy to, co przeglądarka rzeczywiście pobiera.

    Dynamiczne ładowanie JSON

    Wczytuje tłumaczenia leniwie w czasie wykonywania

    Ograniczony JSON (przestrzenie nazw)

    Przestrzenie nazw tłumaczeń na stronę

    Benchmark wydajności I18n

    Czym jest ta metryka?

    Całkowity skompresowany przez gzip rozmiar pakietu biblioteki umiędzynarodowienia. Obejmuje tylko dostawcę i logikę pobierania treści po tree-shakingu i minifikacji.

    Dlaczego to jest ważne?

    Mniejszy rozmiar biblioteki zmniejsza obciążenie u klienta.

    Zobacz jako

    Kluczowe dane dla use-intl@4.14.2, zmierzone w dniu 2026-09-26 (gzip):

    KonfiguracjaRozmiar bibliotekiJS na stronęWyciek innych językówWyciek innych stron
    Brak i18n (aplikacja bazowa)-111.0 KB0%0%
    use-intl (konfiguracja z poradnika)75.9 KB128.7 KB0%0%
    @intlayer/use-intl (kompatybilność)6.7 KB129.4 KB0%0%
    react-intlayer (natywny Intlayer)4.5 KB126.8 KB0%0%

    Wnioski:

    • Podziel wiadomości według stron i ładuj je dla każdego języka osobno. Eliminuje to oba wycieki i jest dokładnie tym, co wdrażają poniższe kroki.
    • Sam runtime pozostaje ciężki (~76 KB gzip), ponieważ parser ICU jest przesyłany do klienta. Adapter kompatybilności @intlayer/use-intl (krok 17) zachowuje dokładnie to samo API przy runtime wynoszącym ~7 KB.
    Zobacz pełne dane: Raport z benchmarku TanStack Start oraz repozytorium benchmarku.

    Porównanie funkcji w TanStack Start

    Jak use-intl wypada na tle innych bibliotek powszechnie używanych w TanStack Start:

    Funkcjareact-intlayer (Intlayer)use-intlParaglide JSLingui
    Tłumaczenia blisko komponentów✅ Współdzielona lokalizacja (co-located)❌ Scentralizowany JSON❌ Jeden plik JSON na język⚠️ Tekst źródłowy w komponentach
    Integracja z TypeScript✅ Automatycznie generowane typy✅ Poprzez AppConfig✅ Typowane funkcje wiadomości⚠️ Tylko makra
    Wykrywanie brakujących tłumaczeń✅ Błędy typów i ostrzeżenia buildu⚠️ Runtime fallback⚠️ Powrót do języka bazowego⚠️ Powrót do tekstu źródłowego
    Bogata zawartość (JSX, Markdown)✅ Bezpośrednie wsparcie⚠️ Tagi przez t.rich⚠️ Ciągi znaków✅ JSX wewnątrz <Trans>
    Zlokalizowany routing✅ Wbudowany❌ Ręczny {-$locale}✅ urlPatterns + przepisywanie routera❌ Ręczny {-$locale}
    Zmiana języka bez przeładowania✅ Tak✅ Tak❌ Pełne przeładowanie strony✅ Tak
    Liczba mnoga (Pluralizacja)✅ Oparta na wyliczeniach✅ ICU✅ Warianty✅ ICU
    ICU MessageFormat✅ Przez format: "icu"✅ Natywne⚠️ Poprzez wtyczkę inlang✅ Natywne
    Formaty zawartości✅ .ts, .json, .md, .yaml...⚠️ .json⚠️ inlang JSON✅ PO, JSON, CSV
    Tłumaczenie AI✅ Własny dostawca i klucz❌ Brak❌ Brak❌ Brak
    Edytor wizualny / CMS✅ Lokalny edytor + opcjonalny CMS❌ Zewnętrzne platformy⚠️ Aplikacje ekosystemu inlang❌ Zewnętrzne platformy
    Pomocniki SEO (hreflang, sitemap)✅ Wbudowane❌ Ręczne⚠️ Zlokalizowane URL, reszta ręczna❌ Ręczne
    Rozmiar runtime (gzip, benchmark)4.5 KB75.9 KB1.8 KB56.7 KB
    Wyciek, najlepsza konfiguracja (język / strona)0% / 0%0% / 0%49.7% / 0%8.6% / 0%
    Brakujące tłumaczenia w CI✅ npx intlayer test⚠️ Brak wbudowanego⚠️ Brak wbudowanego✅ lingui compile --strict
    Dane dotyczące rozmiaru runtime i wycieków pochodzą z benchmarku TanStack Start. Wyciek jest mierzony na najlepszej konfiguracji każdej biblioteki.
    Inne przewodniki po TanStack Start: Lingui, Paraglide JS oraz Intlayer.

    Praktyki, których powinieneś przestrzegać

    • Ustaw lang oraz dir w <html> dla dostępności, czytników ekranu i wyszukiwarek.
    • Zachowaj jeden adres URL dla każdego języka. Użyj prefiksu językowego (/fr/about) zamiast przełączania wyłącznie za pomocą ciasteczek, aby każda przetłumaczona strona mogła być indeksowana i udostępniana.
    • Podziel wiadomości według przestrzeni nazw (common, home, about) i ładuj je dla każdej trasy.
    • Ładuj tylko aktywny język. Nigdy nie importuj wszystkich plików językowych w module przesyłanym do klienta.
    • Ustal strefę czasową w IntlProvider. W przeciwnym razie daty są formatowane w strefie czasowej serwera podczas SSR i w strefie czasowej odwiedzającego podczas hydratacji, co powoduje błędy niezgodności hydratacji.
    • Przetłumacz metadane i zadeklaruj canonical, hreflang oraz x-default na każdej stronie.
    • Wygeneruj wielojęzyczną mapę witryny (sitemap) i robots.txt, a także pre-renderuj każdy język.
    • Używaj prawdziwych linków w przełączniku języków, a nie elementu <select>, aby roboty indeksujące mogły odkryć każdą wersję językową.
    • Typuj swoje wiadomości, aby brakujący klucz powodował błąd na etapie kompilacji.
    Zobacz nasz przewodnik na temat internacjonalizacji i SEO oraz przewodnik po hreflang.

    Przewodnik krok po kroku: Konfiguracja use-intl w aplikacji TanStack Start

    Oto struktura projektu, którą utworzymy:

    bash
    .
    ├── messages
    │   ├── en
    │   │   ├── common.json
    │   │   ├── home.json
    │   │   └── about.json
    │   ├── fr
    │   │   └── ... same files
    │   └── es
    │       └── ... same files
    ├── vite.config.ts
    └── src
        ├── start.ts                  # Request middleware (locale redirect)
        ├── router.tsx
        ├── i18n
        │   ├── config.ts             # Locales, URL helpers
        │   ├── messages.ts           # Per-namespace, per-locale loader
        │   ├── negotiateLocale.ts    # Accept-Language parsing
        │   ├── seo.ts                # head() builder
        │   └── use-intl.d.ts         # Typed messages
        ├── components
        │   ├── LocaleSwitcher.tsx
        │   ├── LocalizedLink.tsx
        │   ├── ScopedMessages.tsx
        │   └── Counter.tsx
        └── routes
            ├── __root.tsx
            ├── sitemap[.]xml.ts
            ├── robots[.]txt.ts
            └── {-$locale}
                ├── route.tsx         # Locale layout + IntlProvider
                ├── index.tsx         # / and /fr
                ├── about.tsx         # /about and /fr/about
                └── $.tsx             # Localized 404
    
    1. Zainstaluj zależności

      Rozpocznij od projektu TanStack Start, a następnie dodaj use-intl:

      bash
      npm create @tanstack/start@latest
      npm install use-intl
      
      • use-intl: dostarcza IntlProvider, useTranslations, useFormatter oraz createTranslator (użyteczny poza Reactem, na przykład w head()).
    2. Scentralizuj konfigurację lokalizacji

      Utwórz pojedyncze źródło prawdy dla swoich języków i funkcji pomocniczych URL. Każdy inny plik (trasy, SEO, sitemap, pre-renderowanie) importuje konfigurację stąd, dzięki czemu dodanie nowego języka wymaga zmiany tylko w jednej linii.

      Domyślny język pozostaje bez prefiksu (/about), a pozostałe języki otrzymują prefiks (/fr/about). Jest to strategia "w razie potrzeby" (as-needed): jeden URL na stronę dla każdego języka i krótkie adresy URL dla Twoich głównych odbiorców.

      src/i18n/config.ts
      export const locales = ["en", "fr", "es"] as const;
      
      export type Locale = (typeof locales)[number];
      
      export const defaultLocale: Locale = "en";
      
      /** Public origin, used for canonical URLs, hreflang and the sitemap. */
      export const siteUrl = "https://example.com";
      
      /** Cookie storing the locale explicitly chosen by the visitor. */
      export const localeCookieName = "locale";
      
      /** Open Graph expects `language_TERRITORY` codes. */
      export const openGraphLocales: Record<Locale, string> = {
        en: "en_US",
        fr: "fr_FR",
        es: "es_ES",
      };
      
      export const isLocale = (value: unknown): value is Locale =>
        typeof value === "string" && (locales as readonly string[]).includes(value);
      
      /** Maps the optional `{-$locale}` route param to a supported locale. */
      export const resolveLocale = (localeParam: string | undefined): Locale =>
        isLocale(localeParam) ? localeParam : defaultLocale;
      
      /** The value to pass as `locale` param: `undefined` for the default locale. */
      export const toLocaleParam = (locale: Locale): Locale | undefined =>
        locale === defaultLocale ? undefined : locale;
      
      const rightToLeftLanguages = new Set(["ar", "fa", "he", "ur", "ps", "yi"]);
      
      export const getTextDirection = (locale: string): "ltr" | "rtl" =>
        rightToLeftLanguages.has(new Intl.Locale(locale).language) ? "rtl" : "ltr";
      
      /** `localizePath("/about", "fr")` → `/fr/about`, default locale unprefixed. */
      export const localizePath = (path: string, locale: Locale): string => {
        if (locale === defaultLocale) return path;
      
        return path === "/" ? `/${locale}` : `/${locale}${path}`;
      };
      
      export const getAbsoluteUrl = (path: string, locale: Locale): string =>
        `${siteUrl}${localizePath(path, locale)}`;
      
      export const getLocaleName = (locale: Locale): string =>
        new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale;
      
    3. Utwórz pliki tłumaczeń

      Organizuj wiadomości według języka i przestrzeni nazw. common zawiera to, czego potrzebuje każda strona (nawigacja, stopka), a każda podstrona otrzymuje własny plik, w tym własne metadane.

      use-intl korzysta z ICU MessageFormat, więc formy liczby mnogiej, instrukcje select oraz sformatowane argumenty znajdują się bezpośrednio w samej wiadomości.

      messages/en/common.json
      {
        "navigation": {
          "home": "Home",
          "about": "About"
        },
        "localeSwitcher": {
          "label": "Change language"
        },
        "notFound": {
          "title": "Page not found",
          "backHome": "Back to home"
        }
      }
      
      messages/en/about.json
      {
        "metadata": {
          "title": "About us",
          "description": "Learn who we are and why we built this application."
        },
        "title": "About us",
        "counter": {
          "label": "Counter",
          "increment": "Increment",
          "clicks": "{count, plural, =0 {No clicks yet} one {# click} other {# clicks}}"
        }
      }
      
      messages/fr/common.json
      {
        "navigation": {
          "home": "Accueil",
          "about": "À propos"
        },
        "localeSwitcher": {
          "label": "Changer de langue"
        },
        "notFound": {
          "title": "Page introuvable",
          "backHome": "Retour à l'accueil"
        }
      }
      
      messages/fr/about.json
      {
        "metadata": {
          "title": "À propos",
          "description": "Découvrez qui nous sommes et pourquoi nous avons créé cette application."
        },
        "title": "À propos",
        "counter": {
          "label": "Compteur",
          "increment": "Incrémenter",
          "clicks": "{count, plural, =0 {Aucun clic} one {# clic} other {# clics}}"
        }
      }
      

      Utwórz home.json w ten sam sposób, zawierając obiekt metadata oraz zawartość strony.

    4. Ładuj wiadomości według przestrzeni nazw i języka

      Ten loader jest najważniejszym plikiem dla wydajności. import.meta.glob instruuje Vite, aby wygenerował jeden chunk na każdy plik JSON. Trasa żądająca ["about"] w języku francuskim pobiera messages/fr/about.json i nic więcej, dzięki czemu benchmark osiąga 0% wycieku języków i 0% wycieku stron.

      src/i18n/messages.ts
      import type about from "../../messages/en/about.json";
      import type common from "../../messages/en/common.json";
      import type home from "../../messages/en/home.json";
      import type { Locale } from "./config";
      
      /** Shape of every namespace, inferred from the English source files. */
      export type AppMessages = {
        common: typeof common;
        home: typeof home;
        about: typeof about;
      };
      
      export type Namespace = keyof AppMessages;
      
      type JsonModule = { default: AppMessages[Namespace] };
      
      // Lazy: each JSON file becomes its own chunk, loaded on demand
      const messageLoaders = import.meta.glob<JsonModule>("../../messages/*/*.json");
      
      /**
       * Loads the requested namespaces for one locale, in parallel.
       */
      export const loadMessages = async <
        const TNamespaces extends readonly Namespace[],
      >(
        locale: Locale,
        namespaces: TNamespaces
      ): Promise<Pick<AppMessages, TNamespaces[number]>> => {
        const entries = await Promise.all(
          namespaces.map(async (namespace) => {
            const loadNamespace =
              messageLoaders[`../../messages/${locale}/${namespace}.json`];
      
            if (!loadNamespace) {
              throw new Error(`Missing messages: ${locale}/${namespace}.json`);
            }
      
            const namespaceModule = await loadNamespace();
      
            return [namespace, namespaceModule.default] as const;
          })
        );
      
        return Object.fromEntries(entries) as Pick<AppMessages, TNamespaces[number]>;
      };
      
    5. Typuj swoje wiadomości

      Rozszerzenie modułów (module augmentation) zapewnia autouzupełnianie w useTranslations("about") oraz t("counter.label"), a także błąd kompilacji w przypadku literówki lub usuniętego klucza.

      src/i18n/use-intl.d.ts
      import type { Locale } from "./config";
      import type { AppMessages } from "./messages";
      
      declare module "use-intl" {
        interface AppConfig {
          Locale: Locale;
          Messages: AppMessages;
        }
      }
      

      Upewnij się, że opcja resolveJsonModule jest włączona w Twoim tsconfig.json.

    6. Utwórz dokument główny (Root Document)

      Trasa główna renderuje element <html>. Odczytuje opcjonalny parametr języka, aby ustawić lang oraz dir, dzięki czemu atrybuty są poprawne w kodzie HTML wyrenderowanym przez serwer przed uruchomieniem jakiegokolwiek kodu JavaScript.

      src/routes/__root.tsx
      import {
        createRootRoute,
        HeadContent,
        Scripts,
        useParams,
      } from "@tanstack/react-router";
      import type { ReactNode } from "react";
      import { getTextDirection, resolveLocale } from "@/i18n/config";
      
      export const Route = createRootRoute({
        head: () => ({
          meta: [
            { charSet: "utf-8" },
            { name: "viewport", content: "width=device-width, initial-scale=1" },
          ],
        }),
        shellComponent: RootDocument,
      });
      
      function RootDocument({ children }: { children: ReactNode }) {
        // strict: false reads params from whichever route is matched
        const { locale: localeParam } = useParams({ strict: false });
        const locale = resolveLocale(localeParam);
      
        return (
          <html lang={locale} dir={getTextDirection(locale)}>
            <head>
              <HeadContent />
            </head>
            <body>
              {children}
              <Scripts />
            </body>
          </html>
        );
      }
      
    7. Utwórz trasę układu językowego (Locale Layout Route)

      Katalog {-$locale} tworzy opcjonalny segment ścieżki: zarówno /about, jak i /fr/about pasują do /{-$locale}/about. Ten układ:

      1. Odrzuca nieobsługiwane prefiksy (/xx/about → 404).
      2. Ładuje przestrzeń nazw common tylko dla bieżącego języka.
      3. Dostarcza wiadomości poprzez IntlProvider.

      Wynik loadera jest serializowany do formatu HTML i ponownie wykorzystywany podczas hydratacji, dzięki czemu klient nie pobiera common.json po raz drugi. Opcja staleTime: Infinity utrzymuje dane w pamięci podręcznej podczas nawigacji po stronie klienta.

      src/routes/{-$locale}/route.tsx
      import { createFileRoute, notFound, Outlet } from "@tanstack/react-router";
      import { IntlProvider } from "use-intl";
      import { Header } from "@/components/Header";
      import { NotFound } from "@/components/NotFound";
      import { isLocale, resolveLocale } from "@/i18n/config";
      import { loadMessages } from "@/i18n/messages";
      
      export const Route = createFileRoute("/{-$locale}")({
        beforeLoad: ({ params }) => {
          // /xx/about with an unknown prefix → 404
          if (params.locale !== undefined && !isLocale(params.locale)) {
            throw notFound();
          }
        },
        loader: async ({ params }) => {
          const locale = resolveLocale(params.locale);
      
          return { locale, messages: await loadMessages(locale, ["common"]) };
        },
        // Messages never change for a given locale
        staleTime: Infinity,
        component: LocaleLayout,
        notFoundComponent: NotFound,
      });
      
      function LocaleLayout() {
        const { locale, messages } = Route.useLoaderData();
      
        return (
          <IntlProvider
            locale={locale}
            messages={messages}
            // A fixed time zone prevents SSR / hydration date mismatches
            timeZone="UTC"
          >
            <Header />
            <main>
              <Outlet />
            </main>
          </IntlProvider>
        );
      }
      
      IntlProvider nie scala automatycznie wiadomości z nadrzędnego providera. Następny krok dodaje mały komponent, który to robi, dzięki czemu każda strona może dodać własną przestrzeń nazw do common.
    8. Ogranicz zasięg wiadomości strony (Scope Page Messages)

      Każda strona ładuje własną przestrzeń nazw w swoim loaderze, a następnie owija swoją zawartość za pomocą ScopedMessages, który scala przestrzeń nazw strony z wiadomościami nadrzędnymi.

      src/components/ScopedMessages.tsx
      import { type ReactNode, useMemo } from "react";
      import {
        type AbstractIntlMessages,
        IntlProvider,
        useLocale,
        useMessages,
        useTimeZone,
      } from "use-intl";
      
      type ScopedMessagesProps = {
        messages: AbstractIntlMessages;
        children: ReactNode;
      };
      
      /**
       * Adds route-level namespaces on top of the messages already provided.
       */
      export const ScopedMessages = ({ messages, children }: ScopedMessagesProps) => {
        const parentMessages = useMessages();
        const locale = useLocale();
        const timeZone = useTimeZone();
      
        const mergedMessages = useMemo(
          () => ({ ...parentMessages, ...messages }),
          [parentMessages, messages]
        );
      
        return (
          <IntlProvider locale={locale} timeZone={timeZone} messages={mergedMessages}>
            {children}
          </IntlProvider>
        );
      };
      
    9. Wykorzystaj tłumaczenia na swoich stronach

      Loader strony pobiera przestrzeń nazw about dla bieżącego języka, funkcja head() buduje na jej podstawie przetłumaczone, kompletne pod kątem SEO metadane (zobacz krok 13), a komponent renderuje zawartość.

      src/routes/{-$locale}/about.tsx
      import { createFileRoute } from "@tanstack/react-router";
      import { createTranslator, useTranslations } from "use-intl";
      import { Counter } from "@/components/Counter";
      import { ScopedMessages } from "@/components/ScopedMessages";
      import { resolveLocale } from "@/i18n/config";
      import { loadMessages } from "@/i18n/messages";
      import { buildLocalizedHead } from "@/i18n/seo";
      
      export const Route = createFileRoute("/{-$locale}/about")({
        loader: async ({ params }) => ({
          messages: await loadMessages(resolveLocale(params.locale), ["about"]),
        }),
        staleTime: Infinity,
        head: ({ params, loaderData }) => {
          const locale = resolveLocale(params.locale);
      
          if (!loaderData) return {};
      
          // createTranslator works outside React, perfect for head()
          const t = createTranslator({
            locale,
            messages: loaderData.messages,
            namespace: "about.metadata",
          });
      
          return buildLocalizedHead({
            path: "/about",
            locale,
            title: t("title"),
            description: t("description"),
          });
        },
        component: AboutPage,
      });
      
      function AboutPage() {
        const { messages } = Route.useLoaderData();
      
        return (
          <ScopedMessages messages={messages}>
            <AboutContent />
          </ScopedMessages>
        );
      }
      
      function AboutContent() {
        const t = useTranslations("about");
      
        return (
          <>
            <h1>{t("title")}</h1>
            <Counter />
          </>
        );
      }
      
    10. Używaj tłumaczeń i formaterów w komponentach

      Każdy komponent znajdujący się wewnątrz providerów może wywoływać useTranslations oraz useFormatter. Formy liczby mnogiej są obsługiwane przez ICU, a liczby są formatowane zgodnie z aktywnym językiem.

      src/components/Counter.tsx
      import { useState } from "react";
      import { useFormatter, useTranslations } from "use-intl";
      
      export const Counter = () => {
        const t = useTranslations("about.counter");
        const format = useFormatter();
        const [count, setCount] = useState(0);
      
        return (
          <div>
            <p>{t("clicks", { count })}</p>
            <p>{format.number(count)}</p>
            <button
              type="button"
              aria-label={t("label")}
              onClick={() => setCount((value) => value + 1)}
            >
              {t("increment")}
            </button>
          </div>
        );
      };
      
    11. Zbuduj komponent zlokalizowanego linku

      Opcjonalne

      Każda trasa znajduje się pod {-$locale}, więc link musi przekazywać bieżący parametr języka. Ten wrapper zachowuje typowane to z TanStack Router i automatycznie wstrzykuje język.

      src/components/LocalizedLink.tsx
      import { Link, type LinkComponentProps } from "@tanstack/react-router";
      import { useLocale } from "use-intl";
      import { toLocaleParam } from "@/i18n/config";
      
      type LocalizedLinkProps = Omit<LinkComponentProps, "params">;
      
      export const LocalizedLink = (props: LocalizedLinkProps) => {
        const locale = useLocale();
      
        return <Link {...props} params={{ locale: toLocaleParam(locale) }} />;
      };
      
      src/components/Header.tsx
      import { useTranslations } from "use-intl";
      import { LocaleSwitcher } from "./LocaleSwitcher";
      import { LocalizedLink } from "./LocalizedLink";
      
      export const Header = () => {
        const t = useTranslations("common.navigation");
      
        return (
          <header>
            <nav>
              <LocalizedLink to="/{-$locale}">{t("home")}</LocalizedLink>
              <LocalizedLink to="/{-$locale}/about">{t("about")}</LocalizedLink>
            </nav>
            <LocaleSwitcher />
          </header>
        );
      };
      
    12. Zmień język swojej zawartości

      Opcjonalne

      Renderuj przełącznik jako linki, a nie <select>. Linki mogą być indeksowane przez roboty sieciowe, dzięki czemu wyszukiwarki znajdują każdą wersję językową, a ponadto działają one bez włączonego JavaScriptu. to="." zachowuje bieżącą stronę i podmienia jedynie parametr języka. Ciasteczko zapamiętuje jednoznaczny wybór dla oprogramowania pośredniczącego (middleware) przekierowań z kroku 16.

      src/components/LocaleSwitcher.tsx
      import { Link } from "@tanstack/react-router";
      import { useLocale, useTranslations } from "use-intl";
      import {
        getLocaleName,
        type Locale,
        localeCookieName,
        locales,
        toLocaleParam,
      } from "@/i18n/config";
      
      const persistLocale = (locale: Locale) => {
        document.cookie = `${localeCookieName}=${locale}; Path=/; Max-Age=31536000; SameSite=Lax`;
      };
      
      export const LocaleSwitcher = () => {
        const t = useTranslations("common.localeSwitcher");
        const activeLocale = useLocale();
      
        return (
          <nav aria-label={t("label")}>
            <ul>
              {locales.map((locale) => (
                <li key={locale}>
                  <Link
                    to="."
                    params={(previous) => ({
                      ...previous,
                      locale: toLocaleParam(locale),
                    })}
                    hrefLang={locale}
                    lang={locale}
                    aria-current={locale === activeLocale ? "page" : undefined}
                    onClick={() => persistLocale(locale)}
                  >
                    {getLocaleName(locale)}
                  </Link>
                </li>
              ))}
            </ul>
          </nav>
        );
      };
      
    13. Zinternacjonalizuj swoje metadane

      Opcjonalne

      To tutaj i18n przynosi największe korzyści: każda wersja językowa może pozycjonować się niezależnie. Każda strona musi udostępniać:

      • przetłumaczony <title> oraz opis (description);
      • kanoniczny URL wskazujący na samą siebie (nie na domyślny język);
      • jeden odpowiednik hreflang na każdy język, plus x-default dla niedopasowanych języków;
      • Open Graph og:locale, og:locale:alternate oraz og:url, używane przez podglądy w mediach społecznościowych;
      • JSON-LD z inLanguage, co pomaga wyszukiwarkom i asystentom AI poprawnie przypisać język strony.

      Jeden pomocnik buduje to wszystko, dzięki czemu pliki stron pozostają zwięzłe:

      src/i18n/seo.ts
      import {
        defaultLocale,
        getAbsoluteUrl,
        type Locale,
        locales,
        openGraphLocales,
      } from "./config";
      
      type LocalizedHeadOptions = {
        /** Path without locale prefix, e.g. "/about" */
        path: string;
        locale: Locale;
        title: string;
        description: string;
      };
      
      export const buildLocalizedHead = ({
        path,
        locale,
        title,
        description,
      }: LocalizedHeadOptions) => {
        const url = getAbsoluteUrl(path, locale);
      
        return {
          meta: [
            { title },
            { name: "description", content: description },
            { property: "og:type", content: "website" },
            { property: "og:title", content: title },
            { property: "og:description", content: description },
            { property: "og:url", content: url },
            { property: "og:locale", content: openGraphLocales[locale] },
            ...locales
              .filter((alternateLocale) => alternateLocale !== locale)
              .map((alternateLocale) => ({
                property: "og:locale:alternate",
                content: openGraphLocales[alternateLocale],
              })),
          ],
          links: [
            // Canonical: each locale is its own canonical page
            { rel: "canonical", href: url },
            // hreflang: every language version, including the current one
            ...locales.map((alternateLocale) => ({
              rel: "alternate",
              hrefLang: alternateLocale,
              href: getAbsoluteUrl(path, alternateLocale),
            })),
            // x-default: fallback for visitors whose language is not supported
            {
              rel: "alternate",
              hrefLang: "x-default",
              href: getAbsoluteUrl(path, defaultLocale),
            },
          ],
          scripts: [
            {
              type: "application/ld+json",
              children: JSON.stringify({
                "@context": "https://schema.org",
                "@type": "WebPage",
                name: title,
                description,
                url,
                inLanguage: locale,
              }),
            },
          ],
        };
      };
      

      Użyj go w head() każdej strony, jak pokazano w kroku 9. W przypadku strony głównej przekaż path: "/".

    14. Zinternacjonalizuj swoją mapę witryny (Sitemap)

      Opcjonalne

      Wielojęzyczna mapa witryny zawiera każdy URL w każdym języku, a każdy wpis deklaruje wszystkie swoje alternatywy za pomocą xhtml:link. Google używa tych adnotacji dokładnie tak samo jak tagów hreflang na stronie, co czyni je niezawodnym zabezpieczeniem, gdy strona jest rzadziej indeksowana.

      Trasy serwerowe TanStack Start pozwalają na serwowanie mapy witryny bezpośrednio z trasy plikowej:

      src/routes/sitemap[.]xml.ts
      import { createFileRoute } from "@tanstack/react-router";
      import { defaultLocale, getAbsoluteUrl, locales } from "@/i18n/config";
      
      type SitemapPage = {
        path: string;
        changeFrequency: "daily" | "weekly" | "monthly";
        priority: number;
      };
      
      export const sitemapPages: SitemapPage[] = [
        { path: "/", changeFrequency: "daily", priority: 1.0 },
        { path: "/about", changeFrequency: "monthly", priority: 0.8 },
      ];
      
      const buildAlternateLinks = (path: string): string =>
        [
          ...locales.map(
            (locale) =>
              `<xhtml:link rel="alternate" hreflang="${locale}" href="${getAbsoluteUrl(path, locale)}"/>`
          ),
          `<xhtml:link rel="alternate" hreflang="x-default" href="${getAbsoluteUrl(path, defaultLocale)}"/>`,
        ].join("");
      
      const buildSitemap = (): string => {
        const urls = sitemapPages.flatMap((page) =>
          locales.map(
            (locale) =>
              `<url><loc>${getAbsoluteUrl(page.path, locale)}</loc>${buildAlternateLinks(page.path)}<changefreq>${page.changeFrequency}</changefreq><priority>${page.priority}</priority></url>`
          )
        );
      
        return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls.join("")}</urlset>`;
      };
      
      export const Route = createFileRoute("/sitemap.xml")({
        server: {
          handlers: {
            GET: () =>
              new Response(buildSitemap(), {
                headers: { "Content-Type": "application/xml; charset=utf-8" },
              }),
          },
        },
      });
      
    15. Zinternacjonalizuj swój plik robots.txt

      Opcjonalne

      Prywatne trasy istnieją w każdym języku, więc reguły Disallow muszą obejmować każdy prefiks. Usuń public/robots.txt, jeśli szablon startowy go utworzył, a następnie serwuj go z trasy:

      src/routes/robots[.]txt.ts
      import { createFileRoute } from "@tanstack/react-router";
      import { locales, localizePath, siteUrl } from "@/i18n/config";
      
      const privatePaths = ["/dashboard", "/admin"];
      
      const buildRobots = (): string => {
        // /dashboard, /fr/dashboard, /es/dashboard...
        const disallowRules = privatePaths.flatMap((path) =>
          locales.map((locale) => `Disallow: ${localizePath(path, locale)}`)
        );
      
        return [
          "User-agent: *",
          "Allow: /",
          ...disallowRules,
          "",
          `Sitemap: ${siteUrl}/sitemap.xml`,
        ].join("\n");
      };
      
      export const Route = createFileRoute("/robots.txt")({
        server: {
          handlers: {
            GET: () =>
              new Response(buildRobots(), {
                headers: { "Content-Type": "text/plain; charset=utf-8" },
              }),
          },
        },
      });
      
    16. Przekierowuj użytkowników odwiedzających stronę po raz pierwszy do ich języka

      Opcjonalne

      Oprogramowanie pośredniczące (middleware) żądań kieruje odwiedzającego wchodzącego na / do jego preferowanego języka, sprawdzając w pierwszej kolejności ciasteczko lokalizacji, a następnie nagłówek Accept-Language. Przekierowywany jest wyłącznie adres /: bezpośrednie linki (deep links) nigdy nie są modyfikowane, więc udostępniane adresy URL i roboty indeksujące zawsze otrzymują dokładnie tę stronę, o którą prosiły.

      src/i18n/negotiateLocale.ts
      import { isLocale, type Locale } from "./config";
      
      /**
       * Picks the best supported locale from an Accept-Language header.
       * "fr-CA,fr;q=0.9,en;q=0.8" → "fr"
       */
      export const negotiateLocale = (
        acceptLanguage: string | null | undefined
      ): Locale | undefined => {
        if (!acceptLanguage) return undefined;
      
        return acceptLanguage
          .split(",")
          .map((part) => {
            const [tag = "", quality] = part.trim().split(";q=");
      
            return {
              language: tag.toLowerCase().split("-")[0],
              quality: quality ? Number(quality) : 1,
            };
          })
          .sort((first, second) => second.quality - first.quality)
          .map(({ language }) => language)
          .find(isLocale);
      };
      
      src/start.ts
      import { redirect } from "@tanstack/react-router";
      import { createMiddleware, createStart } from "@tanstack/react-start";
      import { getCookie } from "@tanstack/react-start/server";
      import { defaultLocale, isLocale, localeCookieName } from "@/i18n/config";
      import { negotiateLocale } from "@/i18n/negotiateLocale";
      
      const localeRedirectMiddleware = createMiddleware().server(
        ({ request, next }) => {
          const { pathname } = new URL(request.url);
      
          if (pathname !== "/") return next();
      
          const cookieLocale = getCookie(localeCookieName);
          const preferredLocale = isLocale(cookieLocale)
            ? cookieLocale
            : negotiateLocale(request.headers.get("accept-language"));
      
          if (preferredLocale && preferredLocale !== defaultLocale) {
            throw redirect({ href: `/${preferredLocale}`, statusCode: 307 });
          }
      
          return next();
        }
      );
      
      export const startInstance = createStart(() => ({
        requestMiddleware: [localeRedirectMiddleware],
      }));
      
      Odwiedzający, który wyraźnie wybierze język angielski w przełączniku, otrzymuje locale=en w ciasteczku, dzięki czemu nie zostanie przekierowany ponownie. W przypadku wdrożenia w pełni statycznego (krok 18), / jest serwowany jako plik i to oprogramowanie pośredniczące nie jest uruchamiane, co jest w porządku: strona pozostaje dostępna, a przełącznik załatwia resztę.
    17. Zachowaj API use-intl, zredukuj rozmiar runtime dzięki Intlayer

      Opcjonalne

      Benchmark pokazuje, że najcięższą częścią konfiguracji use-intl jest sam runtime (~76 KB gzip). Adapter kompatybilności @intlayer/use-intl udostępnia to samo API (useTranslations, useFormatter, IntlProvider, createTranslator, formy mnogie ICU, t.rich), ale serwuje je ze skompilowanych słowników Intlayer: ~6.7 KB zamiast ~75.9 KB, 0% wycieku języków i 0% wycieku stron, bez konieczności wprowadzania jakichkolwiek zmian w komponentach.

      bash
      npm install @intlayer/use-intl intlayer @intlayer/sync-json-plugin
      npx intlayer init
      

      Wtyczka Vite tworzy alias use-intl na adapter, dzięki czemu dotychczasowe importy działają bez zmian:

      vite.config.ts
      import useIntlVitePlugin from "@intlayer/use-intl/plugin";
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { defineConfig } from "vite";
      
      export default defineConfig({
        plugins: [tanstackStart(), viteReact(), useIntlVitePlugin()],
      });
      

      Twoje pliki JSON pozostają źródłem prawdy dzięki wtyczce sync JSON:

      intlayer.config.ts
      import { syncJSON } from "@intlayer/sync-json-plugin";
      import { type IntlayerConfig, Locales } from "intlayer";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH],
          defaultLocale: Locales.ENGLISH,
        },
        dictionary: {
          // One chunk per locale, loaded on demand
          importMode: "dynamic",
          format: "icu",
        },
        plugins: [
          syncJSON({
            format: "icu",
            source: ({ locale, key }) => `./messages/${locale}/${key}.json`,
          }),
        ],
      };
      
      export default config;
      
      Adapter stanowi również płynną ścieżkę migracji: po jego uruchomieniu możesz stopniowo przenosić poszczególne komponenty na natywne API useIntlayer. Zobacz przewodnik po Intlayer dla TanStack Start.
    18. Pre-renderuj każdy język

      Opcjonalne

      Statyczny HTML to najszybsza strona, jaką możesz zaserwować, i najłatwiejsza do zaindeksowania. Wypisz każdą zlokalizowaną ścieżkę, aby TanStack Start pre-renderował wszystkie wersje językowe w czasie budowania, a także pliki sitemap i robots:

      vite.config.ts
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { defineConfig } from "vite";
      import { locales, localizePath } from "./src/i18n/config";
      
      const pagePaths = ["/", "/about"];
      
      const localizedPages = pagePaths.flatMap((path) =>
        locales.map((locale) => ({
          path: localizePath(path, locale),
          prerender: { enabled: true },
        }))
      );
      
      export default defineConfig({
        plugins: [
          tanstackStart({
            prerender: { enabled: true, crawlLinks: true },
            pages: [
              ...localizedPages,
              { path: "/sitemap.xml", prerender: { enabled: true } },
              { path: "/robots.txt", prerender: { enabled: true } },
            ],
          }),
          viteReact(),
        ],
      });
      

      Ponieważ przełącznik języków renderuje prawdziwe linki, opcja crawlLinks: true odkryje również podstrony, o których zapomniałeś na liście.

    19. Obsługuj zlokalizowane strony 404

      Opcjonalne

      Układ z kroku 7 już zgłasza notFound() dla nieznanych prefiksów językowych. Dodaj trasę uniwersalną (catch-all), aby nieznane ścieżki w ramach danego języka również renderowały zlokalizowany błąd 404 i oznacz ją jako noindex: React 19 automatycznie przenosi tag <meta> do <head>.

      src/components/NotFound.tsx
      import { useTranslations } from "use-intl";
      import { LocalizedLink } from "./LocalizedLink";
      
      export const NotFound = () => {
        const t = useTranslations("common.notFound");
      
        return (
          <div>
            <meta name="robots" content="noindex" />
            <h1>{t("title")}</h1>
            <LocalizedLink to="/{-$locale}">{t("backHome")}</LocalizedLink>
          </div>
        );
      };
      
      src/routes/{-$locale}/$.tsx
      import { createFileRoute, notFound } from "@tanstack/react-router";
      
      // /fr/does/not/exist → rendered by the layout notFoundComponent
      export const Route = createFileRoute("/{-$locale}/$")({
        beforeLoad: () => {
          throw notFound();
        },
      });
      
    20. Uzyskaj dostęp do języka w funkcjach serwerowych (Server Functions)

      Opcjonalne

      Funkcje serwerowe nie otrzymują parametrów trasy. Odczytaj ciasteczko lokalizacji i w razie potrzeby użyj nagłówka Accept-Language, aby wysłać zlokalizowaną wiadomość e-mail lub zapisać preferencje językowe:

      src/server/getServerLocale.ts
      import { createServerFn } from "@tanstack/react-start";
      import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
      import { defaultLocale, isLocale, localeCookieName } from "@/i18n/config";
      import { negotiateLocale } from "@/i18n/negotiateLocale";
      
      export const getServerLocale = createServerFn().handler(() => {
        const cookieLocale = getCookie(localeCookieName);
      
        if (isLocale(cookieLocale)) return cookieLocale;
      
        return negotiateLocale(getRequestHeader("accept-language")) ?? defaultLocale;
      });
      

      Aby przetłumaczyć treść wewnątrz funkcji serwerowej, połącz powyższe rozwiązanie z loadMessages oraz createTranslator z biblioteki use-intl.

    21. Zautomatyzuj swoje tłumaczenia za pomocą Intlayer

      Opcjonalne

      use-intl renderuje tłumaczenia, ale nie pomaga w ich tworzeniu. Intlayer jest darmowy i open-source, wypełniając tę lukę nawet jeśli pozostaniesz przy use-intl:

      Aby odkryć wszystkie funkcje, sprawdź dlaczego warto wybrać Intlayer.

    Często zadawane pytania

    Tak, jeśli chcesz używać API next-intl poza Next.js. Otrzymujesz obsługę wiadomości ICU, formatery i dobre wsparcie TypeScriptu, unikając ograniczeń specyficznych dla Next.js, takich jak setRequestLocale. Kompromisem jest waga: benchmark wskazuje ~76 KB gzip dla runtime, a naiwna konfiguracja przesyła wszystkie języki i wszystkie strony do przeglądarki. Ładuj przestrzenie nazw dla każdej trasy i dla każdego języka osobno, jak opisano w tym poradniku, aby uniknąć wycieków.

    use-intl to rdzeń biblioteki next-intl. next-intl dodaje integracje specyficzne dla Next.js: middleware, pomocniki nawigacji, getTranslations dla Server Components oraz konfigurację żądań. W TanStack Start używasz use-intl bezpośrednio i implementujesz routing za pomocą TanStack Router, jak pokazano powyżej.

    Użyj prefiksu w adresie URL. Dzięki temu każda wersja językowa posiada własny adres URL, który wyszukiwarki mogą zaindeksować, a użytkownicy udostępniać. Ciasteczko jest nadal przydatne do zapamiętania jednoznacznego wyboru użytkownika, co robi middleware przekierowań z kroku 16.

    Serwer i przeglądarka formatują daty w różnych strefach czasowych. Przekaż jednoznaczną wartość timeZone do IntlProvider (lub strefę czasową odwiedzającego zapisaną w ciasteczku), aby obie strony wygenerowały identyczny tekst.

    Po pierwsze, podziel wiadomości według przestrzeni nazw i ładuj je dla każdej trasy i języka za pomocą import.meta.glob, co eliminuje wycieki języków i stron. Następnie, jeśli rozmiar runtime ma znaczenie, przełącz się na adapter @intlayer/use-intl: to samo API, ~6.7 KB zamiast ~75.9 KB w benchmarku.

    Wywołaj createTranslator wewnątrz funkcji head() trasy z wiadomościami zwróconymi przez loader trasy, a następnie zwróć title, description, link kanoniczny oraz linki hreflang. Krok 13 zawiera gotowy do ponownego użycia pomocnik.

    Komentarze

    Nie ma jeszcze komentarzy. Bądź pierwszą osobą, która podzieli się swoimi przemyśleniami.

    Powiązane posty

    Ostatnie posty