Better I18NBetter I18N
Remix & Hydrogen

Routing

Locale-prefixed URLs, LocaleLink, and LocaleSwitcher for Remix apps

Remix uses file-based routing with optional segments. This guide covers locale-prefixed routing patterns for multi-language apps.

Route Structure

Use Remix's ($locale) optional segment to support locale prefixes:

app/routes/
├── ($locale)._index.tsx         # / or /tr
├── ($locale).products.$handle.tsx  # /products/hat or /tr/products/hat
├── ($locale).collections.$handle.tsx
└── ($locale).about.tsx

The $locale parameter is optional — when absent, the default locale is used:

/products/hat         → locale: "en" (default)
/tr/products/hat      → locale: "tr"
/fr/products/hat      → locale: "fr"

Create a locale-aware Link wrapper that automatically prepends the locale prefix for non-default locales:

app/components/LocaleLink.tsx
import { Link, type LinkProps } from "react-router";

interface LocaleLinkProps extends Omit<LinkProps, "to"> {
  to: string;
  locale: string;
  children?: React.ReactNode;
}

export function LocaleLink({ to, locale, ...rest }: LocaleLinkProps) {
  const prefix = locale === "en" ? "" : `/${locale}`;
  const path = to.startsWith("/") ? `${prefix}${to}` : to;

  return <Link to={path} {...rest} />;
}

Usage:

<LocaleLink to="/products/hat" locale="tr">
  View Product
</LocaleLink>
// Renders: <a href="/tr/products/hat">View Product</a>

<LocaleLink to="/products/hat" locale="en">
  View Product
</LocaleLink>
// Renders: <a href="/products/hat">View Product</a>

LocaleSwitcher Component

Build a dropdown that switches locales by rewriting the current URL path:

app/components/LocaleSwitcher.tsx
import { useLocation, useNavigate } from "react-router";
import type { LanguageOption } from "@better-i18n/remix";

interface LocaleSwitcherProps {
  locale: string;
  languages: LanguageOption[];
}

export function LocaleSwitcher({ locale, languages }: LocaleSwitcherProps) {
  const location = useLocation();
  const navigate = useNavigate();

  function handleSelect(newLocale: string) {
    const currentPath = location.pathname;

    // Strip existing locale prefix (non-default locales have a URL prefix)
    const nonDefaultCodes = languages.filter((l) => !l.isDefault).map((l) => l.code);
    const regex = new RegExp(`^/(${nonDefaultCodes.join("|")})`);
    const pathWithoutLocale = currentPath.replace(regex, "") || "/";

    // Add new locale prefix (skip for default locale)
    const newPath = newLocale === "en"
      ? pathWithoutLocale
      : `/${newLocale}${pathWithoutLocale}`;

    navigate(newPath + location.search);
  }

  return (
    <select
      value={locale}
      onChange={(e) => handleSelect(e.target.value)}
      aria-label="Select language"
    >
      {languages.map((language) => (
        <option key={language.code} value={language.code}>
          {language.nativeName || language.code.toUpperCase()}
        </option>
      ))}
    </select>
  );
}

The languages array comes from i18n.getLanguages() — pass it through your root loader so the switcher always reflects available languages from the CDN manifest.

Using in Routes

Access locale and messages from loader context in any route. Use hook-based translations for full ICU formatting support:

app/routes/($locale)._index.tsx
import { useLoaderData } from "react-router";
import { useTranslation } from "react-i18next";
import { LocaleLink } from "~/components/LocaleLink";

export async function loader({ context }: LoaderFunctionArgs) {
  return {
    locale: context.locale,
  };
}

export default function Homepage() {
  const { locale } = useLoaderData<typeof loader>();
  const { t } = useTranslation("common"); 

  return (
    <div>
      <h1>{t("welcome")}</h1>
      <LocaleLink to="/products" locale={locale}>
        {t("shop_now")}
      </LocaleLink>
    </div>
  );
}
app/routes/($locale)._index.tsx
import { useLoaderData } from "react-router";
import { useTranslations } from "@better-i18n/remix/react";
import { LocaleLink } from "~/components/LocaleLink";

export async function loader({ context }: LoaderFunctionArgs) {
  return {
    locale: context.locale,
  };
}

export default function Homepage() {
  const { locale } = useLoaderData<typeof loader>();
  const t = useTranslations("common"); 

  return (
    <div>
      <h1>{t("welcome")}</h1>
      <LocaleLink to="/products" locale={locale}>
        {t("shop_now")}
      </LocaleLink>
    </div>
  );
}

meta() functions cannot use React hooks. Use the msg() helper for meta tags — see API Reference.

SEO Considerations

For proper SEO with locale-prefixed routes, add hreflang alternate links in your root:

app/root.tsx
export async function loader({ request, context }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  return {
    locale: context.locale,
    languages: context.languages,
    canonicalUrl: url.origin + url.pathname,
  };
}

export default function App() {
  const { locale, languages, canonicalUrl } = useLoaderData<typeof loader>();

  return (
    <html lang={locale}>
      <head>
        <link rel="canonical" href={canonicalUrl} />
        {languages.map((lang) => (
          <link
            key={lang.code}
            rel="alternate"
            hrefLang={lang.code}
            href={canonicalUrl.replace(`/${locale}`, lang.isDefault ? "" : `/${lang.code}`)}
          />
        ))}
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}

On this page