Better I18NBetter I18N
Remix & Hydrogen

Shopify Hydrogen

Integrate Better i18n with Shopify Hydrogen for multilingual storefronts

Shopify Hydrogen is a React-based framework for building custom Shopify storefronts. @better-i18n/remix provides first-class support for Hydrogen with Storefront API locale bridging.

How It Works

Better i18n handles your UI translations (button labels, headings, static text), while Shopify's Storefront API handles product content (titles, descriptions, prices). The locale detected from the URL drives both systems:

URL: /tr/products/hat
  └─> Better i18n: loads Turkish UI translations from CDN
  └─> Storefront API: queries product data in Turkish (language: TR)

Setup

Install dependencies

npm install @better-i18n/remix i18next react-i18next
bun add @better-i18n/remix i18next react-i18next
pnpm add @better-i18n/remix i18next react-i18next

remix-i18next is not compatible with Hydrogen — it requires React Router v7 middleware, which Hydrogen's CF Worker fetch() handler does not support. Use i18next + react-i18next directly instead.

Create the i18n singleton

app/i18n.server.ts
import { createRemixI18n } from "@better-i18n/remix";

export const i18n = createRemixI18n({
  project: "my-company/hydrogen-store", 
  defaultLocale: "en", 
});

Define locale mapping

getLocaleFromRequest derives Shopify LanguageCode and CountryCode enums automatically from the CDN language list — no static map needed:

app/lib/i18n.ts
import type {
  LanguageCode,
  CountryCode,
} from "@shopify/hydrogen/storefront-api-types";
import type { LanguageOption } from "@better-i18n/remix";

export interface I18nLocale {
  language: LanguageCode;
  country: CountryCode;
  pathPrefix: string;
}

/**
 * Derives Shopify Storefront API locale enums from a Better i18n locale code.
 * Better i18n uses lowercase ("en", "tr"), Shopify uses uppercase enums (EN, TR).
 *
 * Supports both simple ("tr") and compound ("en-gb") locale formats.
 * "en" without a region defaults to country "US" (most common Shopify use case).
 */
function deriveShopifyLocale(code: string, isDefault: boolean): I18nLocale {
  const [lang, country] = code.toLowerCase().split("-");
  return {
    language: lang.toUpperCase() as LanguageCode,
    country: (country ?? (lang === "en" ? "us" : lang)).toUpperCase() as CountryCode,
    pathPrefix: isDefault ? "" : `/${code}`,
  };
}

/**
 * Extract locale from URL path prefix, validated against CDN language list.
 *
 * /tr/products/hat → "tr"
 * /products/hat    → defaultLocale
 */
export function getLocaleFromRequest(
  request: Request,
  languages: LanguageOption[],
  defaultLocale = "en",
): { locale: string; i18n: I18nLocale } {
  const url = new URL(request.url);
  const firstSegment = url.pathname.split("/")[1]?.toLowerCase();

  if (firstSegment && firstSegment !== defaultLocale && languages.some((l) => l.code === firstSegment)) {
    return {
      locale: firstSegment,
      i18n: deriveShopifyLocale(firstSegment, false),
    };
  }

  return { locale: defaultLocale, i18n: deriveShopifyLocale(defaultLocale, true) };
}

Locale derivation rules:

  • "tr"{ language: "TR", country: "TR" } — language code doubles as country code
  • "en"{ language: "EN", country: "US" } — English defaults to US market
  • "en-gb"{ language: "EN", country: "GB" } — compound locales split on -

The language list comes from your CDN manifest via getLanguages(), so adding a language in the Better i18n dashboard is all you need — no code changes required.

Configure server.ts

Wire everything together in your Hydrogen server entry:

server.ts
import { createStorefrontClient } from "@shopify/hydrogen";
import { createRequestHandler } from "@shopify/remix-oxygen";
import { getLocaleFromRequest } from "~/lib/i18n";
import { i18n } from "~/i18n.server";

export default {
  async fetch(
    request: Request,
    env: Env,
    executionContext: ExecutionContext,
  ): Promise<Response> {
    const cache = await caches.open("hydrogen");

    // 1. Fetch CDN language list (TtlCache'd — instant after first request)
    const languages = await i18n.getLanguages(); 

    // 2. Detect locale from URL path, validated against CDN language list
    const { locale, i18n: shopifyI18n } = getLocaleFromRequest(request, languages); 

    // 3. Load Better i18n translations from CDN
    const messages = await i18n.getMessages(locale); 

    // 4. Create Shopify Storefront client with locale
    const { storefront } = createStorefrontClient({
      cache,
      waitUntil: (p: Promise<unknown>) => executionContext.waitUntil(p),
      publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
      privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
      storeDomain: env.PUBLIC_STORE_DOMAIN,
      storefrontApiVersion: env.PUBLIC_STOREFRONT_API_VERSION || "2026-01",
      i18n: shopifyI18n, 
    });

    const handleRequest = createRequestHandler({
      build: remixBuild,
      mode: process.env.NODE_ENV,
      getLoadContext() {
        return {
          storefront,
          env,
          locale, 
          messages, 
          languages, 
          cart: {} as never,
        };
      },
    });

    return handleRequest(request);
  },
};

Augment AppLoadContext

Add type declarations so context.locale, context.messages, and context.languages are typed in your loaders:

env.d.ts
import type { Storefront, HydrogenCart } from "@shopify/hydrogen";
import type { Messages, LanguageOption } from "@better-i18n/remix"; 

declare module "@shopify/remix-oxygen" {
  interface AppLoadContext {
    storefront: Storefront;
    cart: HydrogenCart;
    env: Env;
    locale: string; 
    messages: Messages; 
    languages: LanguageOption[]; 
  }
}

Add CSP entry

Hydrogen uses Content Security Policy by default. Add cdn.better-i18n.com to connectSrc:

entry.server.tsx
const { nonce, header, NonceProvider } = createContentSecurityPolicy({
  shop: {
    checkoutDomain: context.env.PUBLIC_STORE_DOMAIN,
    storeDomain: context.env.PUBLIC_STORE_DOMAIN,
  },
  connectSrc: [
    "'self'",
    "cdn.better-i18n.com", 
  ],
});

Create the i18next client helper

This helper creates a per-request i18next instance from the CDN translations already loaded in the server.

app/lib/i18n-client.ts
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import type { Messages } from "@better-i18n/remix";

export function createI18nextInstance(locale: string, messages: Messages) {
  const instance = i18next.createInstance();
  instance.use(initReactI18next).init({
    lng: locale,
    resources: { [locale]: messages }, 
    initImmediate: false, // sync init — required for SSR
    lowerCaseLng: true,
    defaultNS: "common",
    fallbackNS: "common",
    interpolation: { escapeValue: false },
  });
  return instance;
}

initImmediate: false enables synchronous initialization. Since all translations are already loaded from the CDN via the loader, no network requests are needed — the instance is ready immediately during SSR render.

Wrap your app with I18nextProvider

app/root.tsx
import { useMemo } from "react";
import { I18nextProvider } from "react-i18next";
import { createI18nextInstance } from "~/lib/i18n-client";

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

  const i18nInstance = useMemo( 
    () => createI18nextInstance(locale, messages), 
    [locale, messages], 
  ); 

  return (
    <html lang={locale} dir="ltr">
      <body>
        <I18nextProvider i18n={i18nInstance}> {}
          <Layout locale={locale} languages={languages}>
            <Outlet />
          </Layout>
        </I18nextProvider> {}
      </body>
    </html>
  );
}

Using Translations in Routes

Use the useTranslation() hook from react-i18next in any component. Translations are pre-loaded server-side via the loader — no client-side fetching needed.

app/routes/($locale)._index.tsx
import { useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "@shopify/remix-oxygen";
import { useTranslation } from "react-i18next";

export async function loader({ context }: LoaderFunctionArgs) {
  const { storefront, locale, messages } = context;
  const { products } = await storefront.query(PRODUCTS_QUERY);
  return { locale, messages, products: products.nodes };
}

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

  return (
    <div>
      <h1>{tc("welcome")}</h1>

      {products.map((product) => (
        <div key={product.id}>
          {/* Product title comes from Storefront API (already localized) */}
          <h2>{product.title}</h2>

          {/* UI labels come from Better i18n via useTranslation */}
          <span>{tc("view_details")}</span>
          <span>{tp("from")}</span>
        </div>
      ))}
    </div>
  );
}

meta() functions cannot use React hooks. Use the msg() helper from @better-i18n/remix for meta tags:

import { msg } from "@better-i18n/remix";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  const title = msg(data?.messages?.common, "meta_title", "Fallback Title");
  return [{ title }];
};

Loaders should still return messages so meta() can access them.

Storefront API Language Support

When you pass the i18n option to createStorefrontClient, Shopify automatically adds the @inContext directive to GraphQL queries. Product titles, descriptions, and collection names are returned in the requested language — no extra work needed.

// The Storefront client handles localization automatically:
const { products } = await storefront.query(PRODUCTS_QUERY);
// When locale is "tr", product.title returns Turkish content
// (if available in Shopify admin)

Product content localization depends on translations being configured in your Shopify admin (Settings > Languages). Better i18n handles your custom UI translations; Shopify handles product data.

Full Example

For a complete working implementation, see the hydrogen-demo in the Better i18n repository.

On this page