Autor:
    Creación:2026-09-26Última actualización:2026-09-26

    Cómo internacionalizar tu aplicación TanStack Start usando Lingui en 2026

    Tabla de contenidos

    ¿Qué es Lingui?

    Lingui es una biblioteca de i18n diseñada alrededor de macros y extracción de mensajes. Escribes el texto fuente directamente en tus componentes ( t`Hello` , <Trans>Hello</Trans>), lingui extract recopila cada mensaje en catálogos (archivos PO por defecto), los traductores los completan y el plugin de Vite los compila en JavaScript compacto. Los mensajes utilizan ICU MessageFormat, por lo que las formas plurales y selects están soportados.

    TanStack Start no incluye una capa de i18n integrada, por lo que esta guía conecta Lingui desde cero:

    • Macros compiladas por Babel a través de @rolldown/plugin-babel (requerido con @vitejs/plugin-react v6 y Vite 8).
    • Enrutamiento por locale con un segmento opcional {-$locale} (/about, /fr/about).
    • Un catálogo por locale, cargado bajo demanda, y una instancia de I18n por renderizado para que las solicitudes SSR concurrentes nunca compartan un locale.
    • SEO multilingüe completo: <title> y descripción traducidos, URL canónica, hreflang con x-default, locales Open Graph, JSON-LD, sitemap, robots.txt, pre-renderizado y páginas 404 localizadas.
    ¿Buscas otro stack? Consulta la guía de TanStack Start + use-intl, la guía de TanStack Start + Paraglide o la guía de TanStack Start + Intlayer.
    ¿Usas Next.js? Consulta la guía de Next.js + Lingui. ¿Comparando bibliotecas? Lee Lingui vs Intlayer.

    Qué dice el benchmark sobre Lingui en TanStack Start

    El benchmark de i18n ejecuta la misma aplicación TanStack Start de 10 páginas y 10 locales con cada biblioteca principal y mide lo que el navegador realmente descarga.

    Carga JSON dinámica

    Carga traducciones en tiempo de ejecución

    JSON con alcance (namespacing)

    Espacios de nombres de traduction por página

    Benchmark de Rendimiento I18n

    ¿Qué es esta métrica?

    El tamaño total comprimido en gzip del paquete de la biblioteca de internacionalización. Solo incluye el proveedor y la lógica de recuperación de contenido después del tree-shaking y la minificación.

    ¿Por qué es importante?

    Un tamaño de biblioteca más pequeño reduce la carga útil inicial de JavaScript, lo que acelera el tiempo de descarga y ejecución en el cliente.

    Ver como

    Cifras clave para @lingui/core@6.6.0, medidas el 2026-09-26 (gzip):

    ConfiguraciónTamaño de bibliotecaJS por páginaFuga de otro localeFuga de otra página
    Sin i18n (app base)-111.0 KB0%0%
    Lingui (configuración de la guía)56.7 KB115.2 KB9.3%0%
    @intlayer/lingui (compat)9.8 KB136.7 KB9.9%0%
    react-intlayer (Intlayer nativo)4.5 KB126.8 KB0%0%

    Conclusiones principales:

    • Carga un catálogo por locale, bajo demanda. Mantiene las páginas cerca del tamaño de la aplicación base.
    • El runtime sigue siendo pesado (~57 KB gzip). El adaptador de compatibilidad @intlayer/lingui (paso 16) conserva tus macros y lo reduce a ~10 KB.
    Consulta los datos completos: Informe de benchmark de TanStack Start y el repositorio del benchmark.

    Comparación de características en TanStack Start

    Cómo se compara Lingui con otras bibliotecas comúnmente usadas en TanStack Start:

    Característicareact-intlayer (Intlayer)use-intlParaglide JSLingui
    Traducciones junto a componentes✅ Co-ubicadas❌ JSON centralizado❌ Un archivo JSON por locale⚠️ Texto fuente en componentes
    Integración con TypeScript✅ Tipos autogenerados✅ Vía AppConfig✅ Funciones de mensaje tipadas⚠️ Solo macros
    Detección de traducción faltante✅ Errores de tipo y avisos de build⚠️ Fallback en runtime⚠️ Recurre al locale base⚠️ Recurre al texto fuente
    Contenido enriquecido (JSX, MD)✅ Soporte directo⚠️ Tags vía t.rich⚠️ Strings✅ JSX dentro de <Trans>
    Enrutamiento localizado✅ Integrado❌ {-$locale} manual✅ urlPatterns + rewrite router❌ {-$locale} manual
    Cambio de locale sin recarga✅ Sí✅ Sí❌ Recarga completa de página✅ Sí
    Pluralización✅ Basada en enumeración✅ ICU✅ Variantes✅ ICU
    ICU MessageFormat✅ Vía format: "icu"✅ Nativo⚠️ Vía un plugin inlang✅ Nativo
    Formatos de contenido✅ .ts, .json, .md, .yaml...⚠️ .json⚠️ JSON de inlang✅ PO, JSON, CSV
    Traducción con IA✅ Tu propio proveedor y clave❌ No❌ No❌ No
    Editor visual / CMS✅ Editor local + CMS opcional❌ Plataformas externas⚠️ Apps del ecosistema inlang❌ Plataformas externas
    Ayudantes SEO (hreflang, sitemap)✅ Integrados❌ Manual⚠️ URLs localizadas, resto manual❌ Manual
    Tamaño de runtime (gzip, bench)4.5 KB75.9 KB1.8 KB56.7 KB
    Fuga, mejor setup (locale / pág)0% / 0%0% / 0%49.7% / 0%8.6% / 0%
    Traducciones faltantes en CI✅ npx intlayer test⚠️ No integrado⚠️ No integrado✅ lingui compile --strict
    Las cifras de tamaño de runtime y fuga provienen del benchmark de TanStack Start. La fuga se mide en la mejor configuración de cada biblioteca.
    Otras guías de TanStack Start: use-intl, Paraglide JS e Intlayer.

    Prácticas recomendadas que debes seguir

    • Establece lang y dir en <html> a partir del locale de la ruta, para que sean correctos en el HTML del servidor.
    • Mantén una URL por locale con un prefijo, para que cada versión de idioma sea indexable.
    • Crea una instancia de I18n por locale, nunca mutes una global durante el SSR: dos solicitudes concurrentes sobreescribirían el locale de la otra.
    • Carga solo el catálogo activo, nunca importes todos ellos en el código del cliente.
    • Elige un estilo de macro (useLingui + t en componentes, msg para descriptores perezosos) y mantén la consistencia. Mezclar t, i18n._, i18n.t y <Trans> hace que el código sea más difícil de leer para humanos y asistentes de IA.
    • Ejecuta lingui extract en CI para que un mensaje nuevo nunca se publique sin traducir.
    • Traduce tus metadatos y declara canonical, hreflang y x-default en cada página.
    • Genera un sitemap multilingüe y robots.txt, y pre-renderiza cada locale.
    • Usa enlaces reales para el selector de idioma, para que los rastreadores descubran cada idioma.
    Consulta nuestra guía sobre internacionalización y SEO y la guía de hreflang.

    Guía paso a paso para configurar Lingui en una aplicación TanStack Start

    Esta es la estructura del proyecto que crearemos:

    bash
    .
    ├── lingui.config.ts
    ├── vite.config.ts
    └── src
        ├── locales
        │   ├── en
        │   │   └── messages.po     # Generado por `lingui extract`
        │   ├── fr
        │   │   └── messages.po
        │   └── es
        │       └── messages.po
        ├── start.ts                # Middleware de solicitud (redirección de locale)
        ├── i18n
        │   ├── config.ts           # Locales, utilidades de URL
        │   ├── lingui.ts           # Cargador de catálogos, instancias I18n
        │   ├── negotiateLocale.ts  # Procesamiento de Accept-Language
        │   └── seo.ts              # Constructor de head()
        ├── components
        │   ├── LocaleSwitcher.tsx
        │   ├── LocalizedLink.tsx
        │   └── NotFound.tsx
        └── routes
            ├── __root.tsx
            ├── sitemap[.]xml.ts
            ├── robots[.]txt.ts
            └── {-$locale}
                ├── route.tsx       # Layout de locale + I18nProvider
                ├── index.tsx
                ├── about.tsx
                └── $.tsx           # 404 localizado
    
    1. Instalar dependencias

      bash
      npm install @lingui/core @lingui/react
      npm install -D @lingui/cli @lingui/vite-plugin @lingui/babel-plugin-lingui-macro @lingui/format-po @rolldown/plugin-babel
      
      • @lingui/core / @lingui/react: runtime, I18nProvider y las macros (@lingui/core/macro, @lingui/react/macro).
      • @lingui/cli: lingui extract para recolectar mensajes en catálogos.
      • @lingui/vite-plugin: compila catálogos .po al importar, por lo que lingui compile no es necesario.
      • @lingui/babel-plugin-lingui-macro + @rolldown/plugin-babel: transforman las macros en tiempo de compilación.
    2. Centralizar la configuración de locales

      El locale predeterminado se mantiene sin prefijo (/about), otros locales llevan prefijo (/fr/about).

      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. Configurar Lingui

      La configuración de Lingui reutiliza la misma lista de locales, de modo que los catálogos, el enrutador y el sitemap nunca discrepen.

      lingui.config.ts
      import { defineConfig } from "@lingui/cli";
      import { formatter } from "@lingui/format-po";
      import { defaultLocale, locales } from "./src/i18n/config";
      
      export default defineConfig({
        sourceLocale: defaultLocale,
        locales: [...locales],
        catalogs: [
          {
            path: "<rootDir>/src/locales/{locale}/messages",
            include: ["src"],
          },
        ],
        format: formatter({ lineNumbers: false }),
      });
      

      Añade los scripts de extracción:

      package.json
      {
        "scripts": {
          "i18n:extract": "lingui extract --clean",
          "i18n:check": "lingui extract --clean && git diff --exit-code src/locales"
        }
      }
      

      i18n:check falla en CI cuando un componente contiene un mensaje que no fue extraído y confirmado en git.

    4. Configurar Vite

      Con @vitejs/plugin-react v6, Babel ya no viene integrado. @rolldown/plugin-babel ejecuta el plugin de macro de Lingui, y linguiTransformerBabelPreset solo procesa archivos que importan una macro, lo que mantiene compilaciones rápidas.

      vite.config.ts
      import { lingui, linguiTransformerBabelPreset } from "@lingui/vite-plugin";
      import babel from "@rolldown/plugin-babel";
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { defineConfig } from "vite";
      
      export default defineConfig({
        plugins: [
          tanstackStart(),
          viteReact(),
          lingui(),
          babel({ presets: [linguiTransformerBabelPreset()] }),
        ],
      });
      
    5. Cargar catálogos por locale

      La plantilla literal en import() permite que Vite emita un chunk por catálogo, y el plugin de Lingui compila el archivo .po dentro de él. Un visitante en francés descarga únicamente el catálogo en francés.

      Los mensajes compilados son datos planos, por lo que pueden ser devueltos por un loader de ruta, serializados en el HTML y reutilizados durante la hidratación.

      src/i18n/lingui.ts
      import { type I18n, type Messages, setupI18n } from "@lingui/core";
      import type { Locale } from "./config";
      
      /**
       * Loads the compiled catalog of one locale (one chunk per locale).
       */
      export const loadCatalog = async (locale: Locale): Promise<Messages> => {
        const { messages } = await import(`../locales/${locale}/messages.po`);
      
        return messages;
      };
      
      /**
       * Creates an isolated I18n instance: safe for concurrent SSR requests.
       */
      export const createI18n = (locale: Locale, messages: Messages): I18n =>
        setupI18n({ locale, messages: { [locale]: messages } });
      
      /**
       * Loads a catalog and returns a ready-to-use instance, for loaders and
       * server functions.
       */
      export const loadI18n = async (locale: Locale): Promise<I18n> =>
        createI18n(locale, await loadCatalog(locale));
      

      Para que TypeScript acepte la importación .po, declara el módulo una vez:

      src/i18n/po.d.ts
      declare module "*.po" {
        import type { Messages } from "@lingui/core";
      
        export const messages: Messages;
      }
      
    6. Crear el documento raíz

      La ruta raíz lee el parámetro opcional de locale para configurar lang y dir en el <html> renderizado en el servidor.

      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 }) {
        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. Crear la ruta de layout del locale

      La carpeta {-$locale} crea un segmento de ruta opcional: /about y /fr/about coinciden con /{-$locale}/about. El layout rechaza prefijos desconocidos, carga el catálogo del locale actual y proporciona una instancia dedicada de I18n.

      src/routes/{-$locale}/route.tsx
      import { I18nProvider } from "@lingui/react";
      import { createFileRoute, notFound, Outlet } from "@tanstack/react-router";
      import { useMemo } from "react";
      import { Header } from "@/components/Header";
      import { NotFound } from "@/components/NotFound";
      import { isLocale, resolveLocale } from "@/i18n/config";
      import { createI18n, loadCatalog } from "@/i18n/lingui";
      
      export const Route = createFileRoute("/{-$locale}")({
        beforeLoad: ({ params }) => {
          if (params.locale !== undefined && !isLocale(params.locale)) {
            throw notFound();
          }
        },
        loader: async ({ params }) => {
          const locale = resolveLocale(params.locale);
      
          return { locale, messages: await loadCatalog(locale) };
        },
        // A catalog never changes for a given locale
        staleTime: Infinity,
        component: LocaleLayout,
        notFoundComponent: NotFound,
      });
      
      function LocaleLayout() {
        const { locale, messages } = Route.useLoaderData();
      
        // One instance per locale, never shared between requests
        const i18n = useMemo(() => createI18n(locale, messages), [locale, messages]);
      
        return (
          <I18nProvider i18n={i18n}>
            <Header />
            <main>
              <Outlet />
            </main>
          </I18nProvider>
        );
      }
      
    8. Utilizar traducciones en tus páginas

      Escribe el texto fuente en el componente. Las macros lo convierten en IDs de mensajes en tiempo de compilación, y lingui extract lo recolecta.

      • <Trans> para contenido JSX, incluyendo elementos anidados;
      • useLingui().t para cadenas (atributos, props);
      • <Plural> para plurales ICU.
      src/routes/{-$locale}/about.tsx
      import { msg } from "@lingui/core/macro";
      import { Plural, Trans, useLingui } from "@lingui/react/macro";
      import { createFileRoute } from "@tanstack/react-router";
      import { useState } from "react";
      import { resolveLocale } from "@/i18n/config";
      import { loadI18n } from "@/i18n/lingui";
      import { buildLocalizedHead } from "@/i18n/seo";
      
      export const Route = createFileRoute("/{-$locale}/about")({
        // Translate the metadata in the loader: head() stays synchronous
        loader: async ({ params }) => {
          const i18n = await loadI18n(resolveLocale(params.locale));
      
          return {
            metadata: {
              title: i18n._(msg`About us`),
              description: i18n._(
                msg`Learn who we are and why we built this application.`
              ),
            },
          };
        },
        staleTime: Infinity,
        head: ({ params, loaderData }) =>
          loaderData
            ? buildLocalizedHead({
                path: "/about",
                locale: resolveLocale(params.locale),
                ...loaderData.metadata,
              })
            : {},
        component: AboutPage,
      });
      
      function AboutPage() {
        const { t } = useLingui();
        const [count, setCount] = useState(0);
      
        return (
          <>
            <h1>
              <Trans>About us</Trans>
            </h1>
            <p>
              <Plural
                value={count}
                _0="No clicks yet"
                one="# click"
                other="# clicks"
              />
            </p>
            <button
              type="button"
              aria-label={t`Counter`}
              onClick={() => setCount((value) => value + 1)}
            >
              <Trans>Increment</Trans>
            </button>
          </>
        );
      }
      
      La importación dinámica import() de un catálogo se almacena en caché por el sistema de módulos, por lo que llamar a loadI18n en varios loaders no descarga el catálogo dos veces.
    9. Extraer y traducir tus mensajes

      Ejecuta la extracción. Lingui escribe cada mensaje en el catálogo de cada locale:

      bash
      npm run i18n:extract
      

      Luego traduce el msgstr de cada entrada:

      src/locales/fr/messages.po
      msgid "About us"
      msgstr "À propos"
      
      msgid "Learn who we are and why we built this application."
      msgstr "Découvrez qui nous sommes et pourquoi nous avons créé cette application."
      
      msgid "Increment"
      msgstr "Incrémenter"
      
      msgid "Counter"
      msgstr "Compteur"
      
      msgid "{count, plural, =0 {No clicks yet} one {# click} other {# clics}}"
      msgstr "{count, plural, =0 {Aucun clic} one {# clic} other {# clics}}"
      
      src/locales/es/messages.po
      msgid "About us"
      msgstr "Sobre nosotros"
      
      msgid "Learn who we are and why we built this application."
      msgstr "Descubre quiénes somos y por qué creamos esta aplicación."
      
      msgid "Increment"
      msgstr "Incrementar"
      
      msgid "Counter"
      msgstr "Contador"
      
      msgid "{count, plural, =0 {No clicks yet} one {# click} other {# clicks}}"
      msgstr "{count, plural, =0 {Ningún clic} one {# clic} other {# clics}}"
      
      Por defecto, los IDs de mensajes son hashes del texto fuente: cambiar el texto en inglés crea un mensaje nuevo. Usa IDs explícitos (<Trans id="about.title">About us</Trans>) para textos que cambien a menudo.
    10. Construir un componente de enlace localizado

      Opcional

      Cada ruta se encuentra bajo {-$locale}, por lo que los enlaces deben portar el parámetro del locale actual.

      src/components/LocalizedLink.tsx
      import { useLingui } from "@lingui/react";
      import { Link, type LinkComponentProps } from "@tanstack/react-router";
      import { type Locale, toLocaleParam } from "@/i18n/config";
      
      type LocalizedLinkProps = Omit<LinkComponentProps, "params">;
      
      export const LocalizedLink = (props: LocalizedLinkProps) => {
        const { i18n } = useLingui();
      
        return (
          <Link
            {...props}
            params={{ locale: toLocaleParam(i18n.locale as Locale) }}
          />
        );
      };
      
    11. Cambiar el idioma de tu contenido

      Opcional

      Renderiza el selector como enlaces, de modo que los rastreadores encuentren cada versión de idioma. to="." mantiene la página actual y reemplaza el parámetro de locale. El loader del layout del locale obtiene entonces el nuevo catálogo.

      src/components/LocaleSwitcher.tsx
      import { useLingui } from "@lingui/react/macro";
      import { Link } from "@tanstack/react-router";
      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 = () => {
        // The macro version also returns the i18n instance
        const { i18n, t } = useLingui();
      
        return (
          <nav aria-label={t`Change language`}>
            <ul>
              {locales.map((locale) => (
                <li key={locale}>
                  <Link
                    to="."
                    params={(previous) => ({
                      ...previous,
                      locale: toLocaleParam(locale),
                    })}
                    hrefLang={locale}
                    lang={locale}
                    aria-current={locale === i18n.locale ? "page" : undefined}
                    onClick={() => persistLocale(locale)}
                  >
                    {getLocaleName(locale)}
                  </Link>
                </li>
              ))}
            </ul>
          </nav>
        );
      };
      
    12. Internacionalizar tus metadatos

      Opcional

      Cada versión de idioma puede posicionarse por sí misma, siempre que cada página exponga un <title> y descripción traducidos, un canonical autorreferenciado, un hreflang por locale más x-default, locales de Open Graph y JSON-LD con inLanguage. Los metadatos se traducen en el loader (paso 8), y este asistente construye el resto:

      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: [
            { rel: "canonical", href: url },
            ...locales.map((alternateLocale) => ({
              rel: "alternate",
              hrefLang: alternateLocale,
              href: getAbsoluteUrl(path, alternateLocale),
            })),
            {
              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,
              }),
            },
          ],
        };
      };
      
    13. Internacionalizar tu Sitemap y robots.txt

      Opcional

      El sitemap enumera cada URL de cada locale, declarando cada entrada todas sus alternativas con xhtml:link. robots.txt bloquea rutas privadas en todos los idiomas y apunta al sitemap. Elimina public/robots.txt si el proyecto inicial creó uno.

      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" },
              }),
          },
        },
      });
      
      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 =>
        [
          "User-agent: *",
          "Allow: /",
          ...privatePaths.flatMap((path) =>
            locales.map((locale) => `Disallow: ${localizePath(path, locale)}`)
          ),
          "",
          `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" },
              }),
          },
        },
      });
      
    14. Pre-renderizar cada locale

      Opcional

      Enumera cada ruta localizada para que TanStack Start pre-renderice todas las versiones de idioma en tiempo de compilación:

      vite.config.ts
      import { lingui, linguiTransformerBabelPreset } from "@lingui/vite-plugin";
      import babel from "@rolldown/plugin-babel";
      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(),
          lingui(),
          babel({ presets: [linguiTransformerBabelPreset()] }),
        ],
      });
      
    15. Redirigir a visitantes primerizos y manejar páginas 404

      Opcional

      Un middleware de solicitud envía a los visitantes que aterrizan en / a su idioma preferido (primero por cookie, luego por Accept-Language). Los enlaces profundos nunca se redirigen, por lo que los rastreadores y las URLs compartidas siempre obtienen la página que solicitaron.

      src/i18n/negotiateLocale.ts
      import { isLocale, type Locale } from "./config";
      
      /** "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 }) => {
          if (new URL(request.url).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],
      }));
      

      Para las páginas 404, una ruta comodín (catch-all) renderiza el notFoundComponent localizado del layout. Márcala como noindex: React 19 eleva el <meta> hacia <head>.

      src/components/NotFound.tsx
      import { Trans } from "@lingui/react/macro";
      import { LocalizedLink } from "./LocalizedLink";
      
      export const NotFound = () => (
        <div>
          <meta name="robots" content="noindex" />
          <h1>
            <Trans>Page not found</Trans>
          </h1>
          <LocalizedLink to="/{-$locale}">
            <Trans>Back to home</Trans>
          </LocalizedLink>
        </div>
      );
      
      src/routes/{-$locale}/$.tsx
      import { createFileRoute, notFound } from "@tanstack/react-router";
      
      export const Route = createFileRoute("/{-$locale}/$")({
        beforeLoad: () => {
          throw notFound();
        },
      });
      
    16. Mantén tus macros, reduce el runtime con Intlayer

      Opcional

      El adaptador de compatibilidad @intlayer/lingui mantiene tu código fuente intacto: las macros se compilan exactamente como antes, y las llamadas resultantes a i18n._(), useLingui() y <Trans> son servidas por diccionarios compilados de Intlayer. En el benchmark, el runtime baja de ~56.7 KB a ~9.8 KB gzip.

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

      Añade el plugin después de la transformación de macros, de modo que cree alias de @lingui/core y @lingui/react hacia el adaptador:

      vite.config.ts
      import { lingui as linguiIntlayer } from "@intlayer/lingui/plugin";
      import { linguiTransformerBabelPreset } from "@lingui/vite-plugin";
      import babel from "@rolldown/plugin-babel";
      import { tanstackStart } from "@tanstack/react-start/plugin/vite";
      import viteReact from "@vitejs/plugin-react";
      import { defineConfig } from "vite";
      
      export default defineConfig({
        plugins: [
          tanstackStart(),
          viteReact(),
          babel({ presets: [linguiTransformerBabelPreset()] }),
          linguiIntlayer(),
        ],
      });
      

      Los catálogos se sincronizan con el plugin de sincronización JSON (catálogos JSON) o el plugin de sincronización PO (catálogos PO). Consulta la configuración completa en la guía de compatibilidad con Lingui y una comparación detallada en Lingui vs @intlayer/lingui.

    17. Automatiza tus traducciones usando Intlayer

      Opcional

      Lingui extrae mensajes, pero completar docenas de catálogos a mano es donde se va la mayor parte del tiempo. Intlayer es gratuito y de código abierto, y sus herramientas funcionan junto a Lingui:

    Preguntas frecuentes

    Sí. Lingui no tiene una integración dedicada para TanStack Start, pero su plugin de Vite y el plugin de macros de Babel funcionan tal cual. Los dos puntos clave a configurar correctamente son ejecutar las macros a través de @rolldown/plugin-babel (Vite 8 y @vitejs/plugin-react v6 ya no incluyen Babel) y crear una instancia de I18n por locale en lugar de activar una global durante el SSR.

    En el servidor, un proceso renderiza muchas solicitudes al mismo tiempo. Llamar a i18n.activate("fr") en un objeto compartido cambiaría el idioma de una solicitud que se esté renderizando en inglés en paralelo. setupI18n crea una instancia aislada por locale, lo cual es seguro.

    No. @lingui/vite-plugin compila los catálogos .po cuando son importados. Solo necesitas ejecutar lingui extract para recolectar nuevos mensajes.

    Decláralos con la macro msg y tradúcelos en el loader de la ruta con i18n._(msg`...`). El loader devuelve cadenas de texto simples, por lo que head() se mantiene síncrono y los valores se serializan para la hidratación. El paso 8 y el paso 12 muestran la configuración completa.

    El benchmark mide ~56.7 KB gzip para el runtime. Con un catálogo por locale cargado bajo demanda, las páginas pesan ~115 KB frente a 111 KB sin i18n. Importar todos los catálogos estáticamente eleva el tamaño a ~152 KB.

    Sí. El adaptador @intlayer/lingui mantiene las macros y reemplaza el runtime. Luego puedes migrar los componentes a useIntlayer uno por uno. Consulta los adaptadores de compatibilidad.

    Comentarios

    Aún no hay comentarios. Sé el primero en compartir tus pensamientos.

    Artículos relacionados

    Últimos artículos