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

    Cómo internacionalizar tu aplicación TanStack Start usando use-intl en 2026

    Tabla de contenidos

    ¿Qué es use-intl?

    use-intl es el núcleo agnóstico del framework de next-intl. Expone las mismas APIs de useTranslations, useFormatter e IntlProvider, soporte para ICU MessageFormat y una sólida integración con TypeScript, sin ninguna dependencia de Next.js. Esto la convierte en una de las opciones más comunes para traducir una aplicación TanStack Start, y es la biblioteca que los asistentes de IA sugieren con mayor frecuencia para este stack.

    TanStack Start no incluye una capa de i18n integrada. El enrutamiento, la detección de locale, los metadatos de SEO y la generación de sitemaps quedan bajo tu responsabilidad. Esta guía cubre todo el proceso, de extremo a extremo:

    • Enrutamiento adaptado al locale con un segmento opcional {-$locale} (/about, /fr/about).
    • Carga de mensajes por ruta para que cada página descargue únicamente los namespaces y el locale que renderiza.
    • Renderizado en el servidor e hidratación sin discrepancias de texto.
    • SEO multilingüe completo: <title> y descripción traducidos, URL canónica, alternancias hreflang con x-default, locales Open Graph, JSON-LD, sitemap con alternancias xhtml:link, robots.txt y pre-renderizado de cada locale.
    ¿Buscas otro stack? Consulta la guía de TanStack Start + Paraglide, la guía de TanStack Start + Lingui o la guía de TanStack Start + Intlayer.
    ¿Estás usando Next.js en su lugar? Consulta la guía de next-intl.

    Qué dice el benchmark sobre use-intl en TanStack Start

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

    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 use-intl@4.14.2, medidas el 2026-09-26 (gzip):

    ConfiguraciónTamaño de bibliotecaJS por páginaFuga de otros localesFuga de otras páginas
    Sin i18n (app base)-111.0 KB0%0%
    use-intl (configuración de guía)75.9 KB128.7 KB0%0%
    @intlayer/use-intl (compat)6.7 KB129.4 KB0%0%
    react-intlayer (Intlayer nativo)4.5 KB126.8 KB0%0%

    Conclusiones principales:

    • Divide los mensajes por página y cárgalos por locale. Esto elimina ambas fugas, y es exactamente lo que implementan los pasos siguientes.
    • El runtime en sí sigue siendo pesado (~76 KB gzip), porque el analizador de ICU se envía al cliente. El adaptador de compatibilidad @intlayer/use-intl (paso 17) mantiene exactamente la misma API con un runtime de ~7 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 use-intl con otras bibliotecas comúnmente utilizadas en TanStack Start:

    Característicareact-intlayer (Intlayer)use-intlParaglide JSLingui
    Traducciones junto a componentes✅ Colocalizadas❌ JSON centralizado❌ Un archivo JSON por locale⚠️ Texto fuente en componentes
    Integración con TypeScript✅ Tipos autogenerados✅ Vía AppConfig✅ Funciones de mensajes tipadas⚠️ Solo macros
    Detección de traducciones faltantes✅ Errores de tipo y advertencias de build⚠️ Fallback en runtime⚠️ Recurre al locale base⚠️ Recurre al texto fuente
    Contenido enriquecido (JSX, Markdown)✅ Soporte directo⚠️ Etiquetas vía t.rich⚠️ Cadenas de texto✅ JSX dentro de <Trans>
    Enrutamiento localizado✅ Integrado❌ Manual {-$locale}✅ urlPatterns + reescritura de router❌ Manual {-$locale}
    Cambio de locale sin recargar✅ Sí✅ Sí❌ Recarga de página completa✅ Sí
    Pluralización✅ Basada en enumeración✅ ICU✅ Variantes✅ ICU
    ICU MessageFormat✅ Vía format: "icu"✅ Nativo⚠️ Vía plugin de 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 de inlang❌ Plataformas externas
    Ayudantes de SEO (hreflang, sitemap)✅ Integrados❌ Manual⚠️ URLs localizadas, resto manual❌ Manual
    Tamaño de runtime (gzip, benchmark)4.5 KB75.9 KB1.8 KB56.7 KB
    Fuga, mejor config (locale / página)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: Lingui, Paraglide JS, e Intlayer.

    Prácticas que debes seguir

    • Define lang y dir en <html> para accesibilidad, lectores de pantalla y motores de búsqueda.
    • Mantén una URL por cada locale. Usa un prefijo de locale (/fr/about) en lugar de un cambio basado únicamente en cookies, de modo que cada página traducida sea rastreable y se pueda compartir.
    • Divide los mensajes por namespace (common, home, about) y cárgalos por ruta.
    • Carga únicamente el locale activo. Nunca importes todos los archivos de locales en un módulo que se envía al cliente.
    • Fija la zona horaria en IntlProvider. De lo contrario, las fechas se formatearán en la zona horaria del servidor durante el SSR y en la zona horaria del visitante en la hidratación, provocando discrepancias de hidratación.
    • 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 locale, no un <select>, para que los rastreadores puedan descubrir cada idioma.
    • Tipa tus mensajes para que cualquier clave faltante falle en tiempo de compilación.
    Consulta nuestra guía sobre internacionalización y SEO y la guía de hreflang.

    Guía paso a paso para configurar use-intl en una aplicación TanStack Start

    Esta es la estructura del proyecto que crearemos:

    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. Instalar dependencias

      Comienza desde un proyecto TanStack Start y luego agrega use-intl:

      bash
      npm create @tanstack/start@latest
      npm install use-intl
      
      • use-intl: proporciona IntlProvider, useTranslations, useFormatter y createTranslator (utilizable fuera de React, por ejemplo en head()).
    2. Centralizar la configuración de locales

      Crea una única fuente de verdad para tus locales y funciones auxiliares de URL. Todos los demás archivos (rutas, SEO, sitemap, pre-renderizado) importarán desde aquí, por lo que agregar un nuevo locale será un cambio de una sola línea.

      El locale por defecto permanece sin prefijo (/about), mientras que los demás locales llevan prefijo (/fr/about). Esta es la estrategia "según necesidad": una URL por página por locale y URLs cortas para tu audiencia principal.

      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. Crear los archivos de traducción

      Organiza los mensajes por locale y por namespace. common contiene lo que necesita cada página (navegación, pie de página), y cada página obtiene su propio archivo, incluidos sus metadatos.

      use-intl utiliza ICU MessageFormat, por lo que los plurales, selecciones y argumentos formateados residen en el propio mensaje.

      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}}"
        }
      }
      

      Crea home.json de la misma manera, con un objeto metadata y el contenido de la página.

    4. Cargar mensajes por namespace y por locale

      Este cargador es el archivo más importante para el rendimiento. import.meta.glob le indica a Vite que emita un chunk por cada archivo JSON. Una ruta que solicita ["about"] en francés descarga messages/fr/about.json y nada más, logrando así que el benchmark alcance 0% de fuga de locale y 0% de fuga de página.

      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. Tipar tus mensajes

      La aumentación de módulos proporciona autocompletado en useTranslations("about") y t("counter.label"), así como un error de compilación ante cualquier error tipográfico o clave eliminada.

      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;
        }
      }
      

      Asegúrate de que resolveJsonModule esté habilitado en tu tsconfig.json.

    6. Crear el documento raíz

      La ruta raíz renderiza <html>. Lee el parámetro opcional de locale para definir lang y dir, de modo que los atributos sean correctos en el HTML renderizado por el servidor, antes de que se ejecute cualquier código 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. Crear la ruta de layout del locale

      La carpeta {-$locale} crea un segmento de ruta opcional: /about y /fr/about coinciden con /{-$locale}/about. Este layout:

      1. Rechaza prefijos no compatibles (/xx/about → 404).
      2. Carga el namespace common únicamente para el locale actual.
      3. Proporciona los mensajes a través de IntlProvider.

      El resultado del loader se serializa en el HTML y se reutiliza en la hidratación, por lo que el cliente no descarga common.json por segunda vez. staleTime: Infinity lo mantiene en caché durante las navegaciones del cliente.

      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 no fusiona mensajes de un proveedor padre. El siguiente paso agrega un componente pequeño que lo hace, permitiendo que cada página agregue su propio namespace sobre common.
    8. Delimitar los mensajes por página

      Cada página carga su propio namespace en su loader y luego envuelve su contenido con ScopedMessages, que fusiona el namespace de la página con los mensajes padre.

      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. Utilizar traducciones en tus páginas

      El loader de la página obtiene el namespace about para el locale actual, head() construye metadatos traducidos y completos para SEO a partir de él (ver paso 13), y el componente renderiza el contenido.

      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. Usar traducciones y formateadores en componentes

      Cualquier componente bajo los proveedores puede llamar a useTranslations y useFormatter. Los plurales son resueltos por ICU y los números se formatean según el locale activo.

      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. Crear un componente de enlace localizado

      Opcional

      Cada ruta vive bajo {-$locale}, por lo que un enlace debe llevar el parámetro del locale actual. Este envoltorio mantiene el to tipado de TanStack Router e inyecta el locale automáticamente.

      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. Cambiar el idioma de tu contenido

      Opcional

      Renderiza el selector como enlaces, no como un <select>. Los enlaces son rastreables, lo que permite a los motores de búsqueda encontrar cada versión de idioma, y funcionan sin JavaScript. to="." mantiene la página actual y solo reemplaza el parámetro de locale. La cookie recuerda la elección explícita para el middleware de redirección del paso 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. Internacionalizar tus metadatos

      Opcional

      Aquí es donde la i18n rinde frutos: cada versión de idioma puede posicionarse por sí misma. Cada página debe exponer:

      • un <title> y description traducidos;
      • una URL canónica que apunte a sí misma (no al locale por defecto);
      • una alternativa hreflang por cada locale, más x-default para idiomas no coincidentes;
      • Open Graph og:locale, og:locale:alternate y og:url, utilizados por las vistas previas en redes sociales;
      • JSON-LD con inLanguage, lo que ayuda a los motores de búsqueda y asistentes de IA a atribuir el idioma de la página.

      Una sola función auxiliar construye todo esto para mantener las páginas concisas:

      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,
              }),
            },
          ],
        };
      };
      

      Úsala en el head() de cada página, como se muestra en el paso 9. Para la página de inicio, pasa path: "/".

    14. Internacionalizar tu sitemap

      Opcional

      Un sitemap multilingüe lista cada URL de cada locale, y cada entrada declara todas sus alternativas con xhtml:link. Google utiliza estas anotaciones exactamente igual que las etiquetas hreflang de la página, convirtiéndolas en un respaldo confiable cuando una página se rastrea con poca frecuencia.

      Las rutas de servidor de TanStack Start permiten servirlo desde una ruta de archivo:

      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. Internacionalizar tu robots.txt

      Opcional

      Las rutas privadas existen en todos los idiomas, por lo que las reglas de Disallow deben cubrir cada prefijo. Elimina public/robots.txt si el generador inicial creó uno, y sírvelo desde una ruta:

      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. Redirigir a los visitantes por primera vez a su idioma

      Opcional

      Un middleware de solicitud envía a un visitante que llega a / a su idioma preferido, basándose primero en la cookie de locale y luego en la cabecera Accept-Language. Solo / es redirigido: los enlaces directos nunca se modifican, por lo que las URLs compartidas y los rastreadores siempre obtienen la página que solicitaron.

      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],
      }));
      
      Un visitante que elige explícitamente español o inglés en el selector obtiene locale=... en la cookie, por lo que nunca vuelve a ser redirigido. En un despliegue completamente estático (paso 18), / se sirve como archivo y este middleware no se ejecuta, lo cual es correcto: la página permanece accesible y el selector hace el resto.
    17. Mantener la API de use-intl y reducir el runtime con Intlayer

      Opcional

      El benchmark muestra que la parte más pesada de una configuración con use-intl es el runtime en sí (~76 KB gzip). El adaptador de compatibilidad @intlayer/use-intl expone la misma API (useTranslations, useFormatter, IntlProvider, createTranslator, plurales ICU, t.rich), pero la sirve desde diccionarios compilados de Intlayer: ~6.7 KB en lugar de ~75.9 KB, 0% de fuga de locale y 0% de fuga de página, sin cambios en tus componentes.

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

      El plugin de Vite crea un alias de use-intl al adaptador, para que las importaciones existentes sigan funcionando:

      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()],
      });
      

      Tus archivos JSON siguen siendo la fuente de la verdad gracias al plugin 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;
      
      El adaptador también es una vía de migración gradual: una vez en funcionamiento, puedes mover componentes uno por uno a la API nativa useIntlayer. Consulta la guía de Intlayer con TanStack Start.
    18. Pre-renderizar cada locale

      Opcional

      El HTML estático es la página más rápida que puedes servir y la más fácil de indexar. Lista cada ruta localizada para que TanStack Start pre-renderice todas las versiones de idioma en tiempo de build, además de los archivos de sitemap y 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(),
        ],
      });
      

      Dado que el selector de locale renderiza enlaces reales, crawlLinks: true también descubre las páginas que hayas olvidado listar.

    19. Manejar páginas 404 localizadas

      Opcional

      El layout del paso 7 ya lanza notFound() para prefijos de locale desconocidos. Agrega una ruta comodín para que las rutas desconocidas dentro de un locale también rendericen la página 404 localizada, y márcala como noindex: React 19 eleva la etiqueta <meta> al <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. Acceder al locale en funciones del servidor

      Opcional

      Las funciones de servidor no reciben parámetros de ruta. Lee la cookie de locale y recurre a la cabecera Accept-Language como alternativa para enviar un correo electrónico localizado o guardar una preferencia de idioma:

      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;
      });
      

      Para traducir dentro de la función de servidor, combínalo con loadMessages y createTranslator de use-intl.

    21. Automatizar tus traducciones con Intlayer

      Opcional

      use-intl renderiza traducciones, pero no te ayuda a producirlas. Intlayer es gratuito y de código abierto, y cubre esa necesidad incluso si mantienes use-intl:

      • Probar traducciones faltantes en CI o pruebas unitarias. Consulta probar tus traducciones.
      • Traducir con IA utilizando tu propia clave de API y proveedor: npx intlayer fill traduce las claves faltantes con el contexto de tu aplicación. Consulta auto fill y la CLI.
      • Mantener tus archivos JSON como la fuente de la verdad con el plugin sync JSON.
      • Editar contenido visualmente con el editor visual y el CMS, para que miembros no técnicos puedan actualizar traducciones.
      • Dar contexto a tu agente de IA con el servidor MCP y las habilidades de agente.
      • Escanear tu sitio desplegado en busca de hreflang faltantes, etiquetas canonical incorrectas y fugas de locale con el comando scan.

      Para descubrir todas las funciones, consulta por qué Intlayer.

    Preguntas frecuentes

    Sí, si deseas la API de next-intl fuera de Next.js. Te ofrece mensajes ICU, formateadores y un buen soporte de TypeScript, evitando restricciones específicas de Next.js como setRequestLocale. La desventaja es el peso: el benchmark registra ~76 KB gzip para el runtime, y una configuración ingenua envía todos los locales y todas las páginas al navegador. Carga los namespaces por ruta y por locale, como en esta guía, para evitar las fugas.

    use-intl es el núcleo de next-intl. next-intl añade integraciones sobre Next.js: un middleware, asistentes de navegación, getTranslations para Server Components y configuración de solicitudes. En TanStack Start usas use-intl directamente e implementas el enrutamiento con TanStack Router, tal como se muestra arriba.

    Usa un prefijo en la URL. De este modo, cada versión de idioma tiene su propia URL que los motores de búsqueda pueden indexar y los usuarios pueden compartir. Una cookie sigue siendo útil para recordar una elección explícita, que es lo que hace el middleware de redirección del paso 16.

    El servidor y el navegador formatean las fechas en diferentes zonas horarias. Pasa una timeZone explícita a IntlProvider (o la zona horaria del visitante guardada en una cookie), para que ambos lados produzcan el mismo texto.

    Primero, divide los mensajes por namespace y cárgalos por ruta y por locale con import.meta.glob, lo que elimina las fugas de locale y de página. Luego, si el tamaño del runtime es crítico, cambia al adaptador @intlayer/use-intl: misma API, ~6.7 KB en lugar de ~75.9 KB en el benchmark.

    Llama a createTranslator dentro de la función head() de la ruta con los mensajes devueltos por el loader de la ruta, y luego devuelve el title, description, y los enlaces canónicos y hreflang. El paso 13 proporciona una función auxiliar reutilizable.

    Comentarios

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

    Artículos relacionados

    Últimos artículos