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
bun add @better-i18n/remix
pnpm add @better-i18n/remix

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

    // 3. 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", 
  ],
});

Using Translations in Routes

Access translations from context in any Hydrogen route:

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

export async function loader({ context }: LoaderFunctionArgs) {
  const { storefront, locale, messages } = context;

  // Storefront API returns content in the locale set via i18n
  const { products } = await storefront.query(PRODUCTS_QUERY);

  return { locale, messages, products: products.nodes };
}

export default function Homepage() {
  const { locale, messages, products } = useLoaderData<typeof loader>();
  const common = messages.common;

  return (
    <div>
      <h1>{msg(common, "welcome", "Welcome to our store")}</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 */}
          <span>{msg(common, "view_details", "View Details")}</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.

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