Skip to content
better-i18n.com
इस पेज पर

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:

Code
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

Create the i18n singleton #

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

export const i18n = createRemixI18n({
  projectId: "my-company/hydrogen-store", // [!code highlight]
  defaultLocale: "en", // [!code highlight]
});

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

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(); // [!code highlight]

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

    // 3. Load Better i18n translations from CDN
    const messages = await i18n.getMessages(locale); // [!code highlight]

    // 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, // [!code highlight]
    });

    const handleRequest = createRequestHandler({
      build: remixBuild,
      mode: process.env.NODE_ENV,
      getLoadContext() {
        return {
          storefront,
          env,
          locale, // [!code highlight]
          messages, // [!code highlight]
          languages, // [!code highlight]
          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"; // [!code highlight]

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

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", // [!code highlight]
  ],
});

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 }, // [!code highlight]
    initImmediate: false, // sync init — required for SSR // [!code highlight]
    lowerCaseLng: true,
    defaultNS: "common",
    fallbackNS: "common",
    interpolation: { escapeValue: false },
  });
  return instance;
}

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( // [!code highlight]
    () => createI18nextInstance(locale, messages), // [!code highlight]
    [locale, messages], // [!code highlight]
  ); // [!code highlight]

  return (
    <html lang={locale} dir="ltr">
      <body>
        <I18nextProvider i18n={i18nInstance}> {/* [!code highlight] */}
          <Layout locale={locale} languages={languages}>
            <Outlet />
          </Layout>
        </I18nextProvider> {/* [!code highlight] */}
      </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>
  );
}

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.

TypeScript
// 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)

Full Example #

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