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/remixbun add @better-i18n/remixpnpm add @better-i18n/remixCreate the i18n singleton
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:
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:
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:
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:
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:
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.