--- title: Markets in Hydrogen description: This recipe adds basic localization support to your Hydrogen app using Shopify Markets. source_url: html: https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets md: https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets.md --- ExpandOn this page * [Requirements](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#requirements) * [Ingredients](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#ingredients) * [Step 1: Add localization utilities and update core components](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#step-1-add-localization-utilities-and-update-core-components) * [Step 2: Localizing the individual routes](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#step-2-localizing-the-individual-routes) * [Deleted Files](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#deleted-files) * [Next steps](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#next-steps) # Markets in Hydrogen This recipe shows how to add support for [Shopify Markets](https://shopify.dev/docs/apps/build/markets) to your Hydrogen app. Markets let you segment your audience based on location and serve different content to each market. You can use Markets in a variety of ways. In this recipe, you'll set up basic localization support for your Hydrogen store, learn what options are available for routing, add a country selector component to your app, and set up links that work across localized versions of your store. There are several ways to implement localization in your Shopify Hydrogen store, and the approach you take will depend on your project's requirements. This recipe uses **URL-based localization**, which makes market information visible in the URL. This provides two key benefits: * It's transparent to search engine crawlers. * It allows each localized version of your store to be properly indexed. This approach is typically implemented in two ways: 1. Path-based localization (recommended) * **Example:** `example.com/fr-ca/products` * **Implementation:** Requires adding a locale parameter to your routes * Rename `routes/_index.tsx` to `routes/($locale)._index.tsx` * **Advantages:** No infrastructure changes needed * **Considerations:** Requires additional code to handle link formatting throughout your application 2. Subdomain or top-level domain localization * **Example:** `fr-ca.example.com/products` (or `example.fr/products`) * **Implementation:** Requires infrastructure configuration * **Advantages:** Maintains consistent URL structure across localized stores * **Considerations:** More complex setup at the infrastructure level Although you can use other methods for localization (like cookies or HTTP headers), these approaches have one significant disadvantage: they're not visible to search engine crawlers. This can negatively impact your SEO for different markets. In this recipe, we'll implement **path-based localization**. Note This recipe is particularly useful for existing Hydrogen projects. If you need to set up a brand new Hydrogen app, you can get a solid foundation by selecting the localization options when setting up your new project using the Shopify CLI. You can also use `h2 setup markets` to add localization support to your new Hydrogen app. *** ## Requirements * Set up your store's regions and languages using [Shopify Markets](https://help.shopify.com/en/manual/markets). * Configure your products appropriately for each market. * Make sure your Hydrogen app is configured to use a default `language` and `country code`. They will be used as the fallback when no market is explicitly selected. *** ## Ingredients *New files added to the template by this recipe.* | File | Description | | - | - | | [app/components/CountrySelector.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/CountrySelector.tsx) | A component that displays a country selector inside the Header. | | [app/components/Link.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx) | A unified locale-aware Link component that handles both regular links and navigation links with active states. Automatically prepends locale prefixes and cleans menu URLs. | | [app/lib/i18n.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/lib/i18n.ts) | Comprehensive i18n utilities including locale detection, path transformation hooks, URL cleaning functions, and locale validation. Centralizes all localization logic in one place. | | [app/routes/($locale).\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\)._index.tsx) | A route that renders a localized version of the home page. | | [app/routes/($locale).account.$.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.$.tsx) | Fallback route for unauthenticated account pages with locale support | | [app/routes/($locale).account.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account._index.tsx) | Localized account dashboard redirect route | | [app/routes/($locale).account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.addresses.tsx) | Customer address management page with locale-aware forms and links | | [app/routes/($locale).account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.orders.$id.tsx) | Individual order details page with localized currency and date formatting | | [app/routes/($locale).account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.orders._index.tsx) | Customer order history listing with locale-specific pagination | | [app/routes/($locale).account.profile.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.profile.tsx) | Customer profile editing form with localized field labels | | [app/routes/($locale).account.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.tsx) | Account layout wrapper with locale-aware navigation tabs | | [app/routes/($locale).account\_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account_.authorize.tsx) | OAuth authorization callback route with locale preservation | | [app/routes/($locale).account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account_.login.tsx) | Customer login redirect with locale-specific return URL | | [app/routes/($locale).account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account_.logout.tsx) | Logout handler that maintains locale after sign out | | [app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).blogs.$blogHandle.$articleHandle.tsx) | Blog article page with locale-specific content and SEO metadata | | [app/routes/($locale).blogs.$blogHandle.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).blogs.$blogHandle._index.tsx) | Blog listing page with localized article previews and pagination | | [app/routes/($locale).blogs.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).blogs._index.tsx) | All blogs overview page with locale-aware navigation links | | [app/routes/($locale).cart.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).cart.tsx) | A localized cart route. | | [app/routes/($locale).collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).collections.$handle.tsx) | Collection page displaying products with locale-specific pricing and availability | | [app/routes/($locale).collections.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).collections._index.tsx) | Collections listing page with localized collection names and images | | [app/routes/($locale).collections.all.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).collections.all.tsx) | All products page with locale-based filtering and sorting | | [app/routes/($locale).pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).pages.$handle.tsx) | Dynamic page route for locale-specific content pages | | [app/routes/($locale).policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).policies.$handle.tsx) | Policy page (privacy, terms, etc.) with locale-specific legal content | | [app/routes/($locale).policies.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).policies._index.tsx) | Policies index page listing all available store policies | | [app/routes/($locale).products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).products.$handle.tsx) | A route that renders a localized version of the product page. | | [app/routes/($locale).search.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).search.tsx) | Search results page with locale-aware product matching and predictive search | | [app/routes/($locale).tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).tsx) | A utility route that makes sure the locale is valid. | *** ## Step 1: Add localization utilities and update core components In this section, we'll create utilities to handle localization and country selection, and update the core components to use these utilities. ### Step 1.​1: Update Cart​Line​Item with locale-aware product links Update cart line items to use the unified Link component for product links. ##### File: [CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/CartLineItem.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/patches/CartLineItem.tsx.2c9a50.patch)) ```diff @@ -2,7 +2,7 @@ import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; import type {CartLayout} from '~/components/CartMain'; import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen'; import {useVariantUrl} from '~/lib/variants'; -import {Link} from 'react-router'; +import {Link} from '~/components/Link'; import {ProductPrice} from './ProductPrice'; import {useAside} from './Aside'; import type {CartApiQueryFragment} from 'storefrontapi.generated'; ``` ### Step 1.​2: Create a Country​Selector component Create a new `CountrySelector` component that allows users to select the locale from a dropdown of the supported locales. To handle redirects, use a `Form` that updates the cart buyer identity, which eventually redirects to the localized root of the app. ##### File: [CountrySelector.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/CountrySelector.tsx) ## File ```tsx import {Form, useLocation} from 'react-router'; import type {Locale} from '../lib/i18n'; import { SUPPORTED_LOCALES, useSelectedLocale, getPathWithoutLocale, } from '../lib/i18n'; import {CartForm} from '@shopify/hydrogen'; export function CountrySelector() { const selectedLocale = useSelectedLocale(); const label = selectedLocale != null ? `${selectedLocale.language}-${selectedLocale.country}` : 'Country'; return (
{label}
{SUPPORTED_LOCALES.map((locale) => ( ))}
); } function LocaleForm({locale}: {locale: Locale}) { const {pathname, search} = useLocation(); const selectedLocale = useSelectedLocale(); // Get the new path with the new locale, preserving the current path const pathWithoutLocale = getPathWithoutLocale(pathname, selectedLocale); const newPath = `${locale.pathPrefix.replace(/\/+$/, '')}${pathWithoutLocale}${search}`; const action = `${locale.pathPrefix.replace(/\/+$/, '')}/cart`; const variables = { action: CartForm.ACTIONS.BuyerIdentityUpdate, inputs: { buyerIdentity: { countryCode: locale.country.toUpperCase(), }, }, }; return (
); } ``` ### Step 1.​3: Create a unified locale-aware Link component Create a single Link component that handles both regular links and navigation links. This component automatically: * Prepends the current locale to paths * Supports variant="nav" for navigation links with active states * Cleans invalid locale prefixes from menu URLs * Enables locale switching while preserving paths ##### File: [Link.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx) ## File ```tsx import type {LinkProps, NavLinkProps} from 'react-router'; import {Link as ReactLink, NavLink as ReactNavLink} from 'react-router'; import {useLocalizedPath, cleanPath} from '../lib/i18n'; import type {Locale} from '../lib/i18n'; type BaseProps = { locale?: Locale; preservePath?: boolean; }; type LinkVariantProps = BaseProps & LinkProps & { variant?: never; }; type NavLinkVariantProps = BaseProps & NavLinkProps & { variant: 'nav'; }; export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; /** * Locale-aware Link component that handles both regular and navigation links * * @example * // Regular link (auto-adds current locale) * Products * * @example * // Navigation link with active styles * About * * @example * // Switch locale while preserving current path * Français * * @example * // Link to specific locale * Canadian Products */ export function Link(props: ExtendedLinkProps) { const {locale, preservePath = false, variant, ...restProps} = props; let to = restProps.to; // Auto-clean menu URLs for navigation links if (variant === 'nav' && typeof to === 'string') { if (to.includes('://')) { try { to = new URL(to).pathname; } catch { // Keep original URL } } to = cleanPath(to); } to = useLocalizedPath(to, locale, preservePath); if (variant === 'nav') { return ; } return ; } ``` ### Step 1.​4: Create comprehensive i18n utilities Create a centralized i18n module that includes: 1. The `useSelectedLocale()` hook to get the current locale from route data 2. The `useLocalizedPath()` hook for intelligent path transformation 3. The `cleanPath()` function to remove invalid locale/language prefixes 4. The `findLocaleByPrefix()` function to detect locales in paths 5. The `normalizePrefix()` function for consistent prefix formatting 6. Locale validation utilities for route params 7. Support for case-insensitive locale matching ##### File: [i18n.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/lib/i18n.ts) ## File ```ts import {useMatches, useLocation} from 'react-router'; import type { CountryCode as CustomerCountryCode, LanguageCode as CustomerLanguageCode, } from '@shopify/hydrogen/customer-account-api-types'; import type { CountryCode as StorefrontCountryCode, LanguageCode as StorefrontLanguageCode, } from '@shopify/hydrogen/storefront-api-types'; type LanguageCode = CustomerLanguageCode & StorefrontLanguageCode; type CountryCode = CustomerCountryCode & StorefrontCountryCode; export type Locale = { language: LanguageCode; country: CountryCode; pathPrefix: string; }; export const DEFAULT_LOCALE: Locale = { language: 'EN', country: 'US', pathPrefix: '/', }; export const SUPPORTED_LOCALES: Locale[] = [ DEFAULT_LOCALE, {language: 'EN', country: 'CA', pathPrefix: '/EN-CA'}, {language: 'FR', country: 'CA', pathPrefix: '/FR-CA'}, {language: 'FR', country: 'FR', pathPrefix: '/FR-FR'}, ]; const RE_LOCALE_PREFIX = /^[A-Z]{2}-[A-Z]{2}$/i; function getFirstPathPart(url: URL): string | null { return ( ``` ### Step 1.​5: Update Product​Item to use locale-aware Link Replace standard react-router Link imports with the new unified Link component. This ensures all product links automatically include the correct locale prefix. ##### File: [ProductItem.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/ProductItem.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/patches/ProductItem.tsx.d3223b.patch)) ```diff @@ -1,4 +1,3 @@ -import {Link} from 'react-router'; import {Image, Money} from '@shopify/hydrogen'; import type { ProductItemFragment, @@ -6,6 +5,7 @@ import type { RecommendedProductFragment, } from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {Link} from './Link'; export function ProductItem({ product, ``` ### Step 1.​6: Add the selected locale to the context Detect the locale from the URL path, and add it to the HydrogenContext. ##### File: [context.ts](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/lib/context.ts) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/patches/context.ts.1877b3.patch)) ```diff @@ -1,6 +1,7 @@ import {createHydrogenContext} from '@shopify/hydrogen'; import {AppSession} from '~/lib/session'; import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; +import {getLocaleFromRequest} from './i18n'; // Define the additional context object const additionalContext = { @@ -40,6 +41,8 @@ export async function createHydrogenRouterContext( AppSession.init(request, [env.SESSION_SECRET]), ]); + const i18n = getLocaleFromRequest(request); + const hydrogenContext = createHydrogenContext( { env, @@ -47,8 +50,7 @@ export async function createHydrogenRouterContext( cache, waitUntil, session, - // Or detect from URL path based on locale subpath, cookies, or any other strategy - i18n: {language: 'EN', country: 'US'}, + i18n, cart: { queryFragment: CART_QUERY_FRAGMENT, }, ``` ### Step 1.​7: Update Header with Country​Selector and locale-aware Links 1. Add the `CountrySelector` component to the header navigation. 2. Update all navigation links to use the unified `Link` component with `variant="nav"`. Menu URLs are automatically cleaned of invalid locale prefixes. ##### File: [Header.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/Header.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/patches/Header.tsx.e25645.patch)) ## File ```diff @@ -1,5 +1,6 @@ import {Suspense} from 'react'; -import {Await, NavLink, useAsyncValue} from 'react-router'; +import {Await, useAsyncValue} from 'react-router'; +import {Link} from '~/components/Link'; import { type CartViewPayload, useAnalytics, @@ -7,6 +8,7 @@ import { } from '@shopify/hydrogen'; import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated'; import {useAside} from '~/components/Aside'; +import {CountrySelector} from './CountrySelector'; interface HeaderProps { header: HeaderQuery; @@ -26,9 +28,9 @@ export function Header({ const {shop, menu} = header; return (
- + {shop.name} - + {viewport === 'mobile' && ( - Home - + )} {(menu || FALLBACK_HEADER_MENU).items.map((item) => { if (!item.url) return null; @@ -78,7 +81,8 @@ export function HeaderMenu({ ? new URL(item.url).pathname : item.url; return ( - {item.title} - + ); })} @@ -102,13 +106,14 @@ function HeaderCtas({ return ( ``` ### Step 1.​8: Add the selected locale to the root route 1. Include the selected locale in the root route's loader data. 2. Make sure to redirect to the 404 page if the requested locale is not supported. 3. Add a key prop to the `PageLayout` component to make sure it re-renders when the locale changes. ##### File: [root.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/root.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/patches/root.tsx.5e9998.patch)) ```diff @@ -77,6 +77,7 @@ export async function loader(args: Route.LoaderArgs) { return { ...deferredData, ...criticalData, + selectedLocale: args.context.storefront.i18n, publicStoreDomain: env.PUBLIC_STORE_DOMAIN, shop: getShopAnalytics({ storefront, @@ -176,7 +177,10 @@ export default function App() { shop={data.shop} consent={data.consent} > - + ``` *** ## Step 2: Localizing the individual routes In this section, we'll add localization to the individual routes using the language [dynamic segment](https://reactrouter.com/start/data/routing#optional-segments). ### Step 2.​1: Update Cart​Main with locale-aware links Replace all Link imports to use the unified locale-aware `Link` component for consistent navigation. ##### File: [CartMain.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/app/components/CartMain.tsx) ([patch](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/patches/CartMain.tsx.035161.patch)) ```diff @@ -1,5 +1,5 @@ import {useOptimisticCart} from '@shopify/hydrogen'; -import {Link} from 'react-router'; +import {Link} from '~/components/Link'; import type {CartApiQueryFragment} from 'storefrontapi.generated'; import {useAside} from '~/components/Aside'; import {CartLineItem} from '~/components/CartLineItem'; ``` ### Step 2.​2: Add language dynamic segment to the desired routes To implement path-based localization, add a language dynamic segment to your localized routes (for example, renaming `routes/_index.tsx` to `routes/($locale)._index.tsx`). For brevity, we'll focus on the home page, the cart page, and the product page in this example. In your app, you should do this for all the app routes. ### Step 2.​3: Add localization to the home page 1. Add the dynamic segment to the home page route. 2. Use the new `Link` component as a drop-in replacement. Note Rename `app/routes/_index.tsx` to `app/routes/($locale)._index.tsx`. ##### File: [($locale).\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\)._index.tsx) ## File ```tsx import {Await, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale)._index'; import {Suspense} from 'react'; import {Image} from '@shopify/hydrogen'; import type { FeaturedCollectionFragment, RecommendedProductsQuery, } from 'storefrontapi.generated'; import {ProductItem} from '~/components/ProductItem'; import {Link} from '../components/Link'; export const meta: Route.MetaFunction = () => { return [{title: 'Hydrogen | Home'}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context}: Route.LoaderArgs) { const [{collections}] = await Promise.all([ context.storefront.query(FEATURED_COLLECTION_QUERY), // Add other queries here, so that they are loaded in parallel ]); return { ``` ### Step 2.​4: Add localization to the cart page Add the dynamic segment to the cart page route. Note Rename `app/routes/cart.tsx` to `app/routes/($locale).cart.tsx`. ##### File: [($locale).cart.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).cart.tsx) ## File ```tsx import {useLoaderData, data} from 'react-router'; import type {Route} from './+types/($locale).cart'; import type {CartQueryDataReturn} from '@shopify/hydrogen'; import {CartForm} from '@shopify/hydrogen'; import {CartMain} from '~/components/CartMain'; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Cart`}]; }; export const headers: Route.HeadersFunction = ({actionHeaders}) => actionHeaders; export async function action({request, context}: Route.ActionArgs) { const {cart} = context; const formData = await request.formData(); const {action, inputs} = CartForm.getFormInput(formData); if (!action) { throw new Error('No action provided'); } let status = 200; let result: CartQueryDataReturn; switch (action) { case CartForm.ACTIONS.LinesAdd: result = await cart.addLines(inputs.lines); break; case CartForm.ACTIONS.LinesUpdate: result = await cart.updateLines(inputs.lines); break; case CartForm.ACTIONS.LinesRemove: result = await cart.removeLines(inputs.lineIds); break; case CartForm.ACTIONS.DiscountCodesUpdate: { const formDiscountCode = inputs.discountCode; // User inputted discount code const discountCodes = ( formDiscountCode ? [formDiscountCode] : [] ) as string[]; // Combine discount codes already applied on cart discountCodes.push(...inputs.discountCodes); result = await cart.updateDiscountCodes(discountCodes); break; } case CartForm.ACTIONS.GiftCardCodesUpdate: { const formGiftCardCode = inputs.giftCardCode; // User inputted gift card code const giftCardCodes = ( formGiftCardCode ? [formGiftCardCode] : [] ) as string[]; // Combine gift card codes already applied on cart giftCardCodes.push(...inputs.giftCardCodes); result = await cart.updateGiftCardCodes(giftCardCodes); break; } case CartForm.ACTIONS.BuyerIdentityUpdate: { result = await cart.updateBuyerIdentity({ ...inputs.buyerIdentity, }); break; } default: throw new Error(`${action} cart action is not defined`); } const cartId = result?.cart?.id; const headers = cartId ? cart.setCartId(result.cart.id) : new Headers(); const {cart: cartResult, errors, warnings} = result; const redirectTo = formData.get('redirectTo') ?? null; if (typeof redirectTo === 'string') { status = 303; headers.set('Location', redirectTo); } return data( { cart: cartResult, errors, warnings, analytics: { cartId, }, }, {status, headers}, ); } export async function loader({context}: Route.LoaderArgs) { const {cart} = context; return await cart.get(); } export default function Cart() { const cart = useLoaderData(); return (

Cart

); } ``` ### Step 2.​5: Add localization to the product page 1. Add the dynamic segment to the product page route. 2. Update the `meta` function to also update the canonical URL to use the localized prefix. Note Rename `app/routes/products.$handle.tsx` to `app/routes/($locale).products.$handle.tsx`. ##### File: [($locale).products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).products.$handle.tsx) ## File ```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).products.$handle'; import { getSelectedProductOptions, Analytics, useOptimisticVariant, getProductOptions, getAdjacentAndFirstAvailableVariants, useSelectedOptionInUrlParam, } from '@shopify/hydrogen'; import {ProductPrice} from '~/components/ProductPrice'; import {ProductImage} from '~/components/ProductImage'; import {ProductForm} from '~/components/ProductForm'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; import type {WithLocale} from '~/lib/i18n'; import {DEFAULT_LOCALE} from '~/lib/i18n'; export const meta: Route.MetaFunction = (args) => { const rootMatch = args.matches.at(0) ?? null; const selectedLocale = (rootMatch?.data as WithLocale)?.selectedLocale ?? null; const prefix = ( selectedLocale?.pathPrefix ?? DEFAULT_LOCALE.pathPrefix ).replace(/\/+$/, ''); const href = `${prefix}/products/${args.data?.product.handle}`; return [ {title: `Hydrogen | ${args.data?.product.title ?? ''}`}, { rel: 'canonical', href, }, ]; }; ``` ### Step 2.​6: Add a utility route to validate the locale. Add a utility route in `$(locale).tsx` that will use `localeMatchesPrefix` to validate the locale from the URL params. If the locale is invalid, the route will throw a 404 error. ##### File: [($locale).tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).tsx) ## File ```tsx import type {Route} from './+types/($locale)'; import {localeMatchesPrefix} from '~/lib/i18n'; export async function loader({params}: Route.LoaderArgs) { if (!localeMatchesPrefix(params.locale ?? null)) { throw new Response('Invalid locale', {status: 404}); } return null; } ``` ### Step 2.​7: Handle unauthenticated account pages Add a fallback route for unauthenticated account pages with locale support. ##### File: [($locale).account.$.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.$.tsx) ## File ```tsx import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account.$'; // fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { context.customerAccount.handleAuthStatus(); return redirect('/account'); } ``` ### Step 2.​8: Redirect to account dashboard Add a localized account dashboard redirect route. ##### File: [($locale).account.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account._index.tsx) ## File ```tsx import {redirect} from 'react-router'; export async function loader() { return redirect('/account/orders'); } ``` ### Step 2.​9: Add address management Add a customer address management page with locale-aware forms and links. ##### File: [($locale).account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.addresses.tsx) ## File ```tsx import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; import type { AddressFragment, CustomerFragment, } from 'customer-accountapi.generated'; import { data, Form, useActionData, useNavigation, useOutletContext, type Fetcher, } from 'react-router'; import type {Route} from './+types/($locale).account.addresses'; import { UPDATE_ADDRESS_MUTATION, DELETE_ADDRESS_MUTATION, CREATE_ADDRESS_MUTATION, } from '~/graphql/customer-account/CustomerAddressMutations'; export type ActionResponse = { addressId?: string | null; createdAddress?: AddressFragment; defaultAddress?: string | null; deletedAddress?: string | null; error: Record | null; updatedAddress?: AddressFragment; }; export const meta: Route.MetaFunction = () => { return [{title: 'Addresses'}]; }; export async function loader({context}: Route.LoaderArgs) { context.customerAccount.handleAuthStatus(); ``` ### Step 2.​10: Show order details Add an individual order details page with localized currency and date formatting. ##### File: [($locale).account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.orders.$id.tsx) ## File ```tsx import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).account.orders.$id'; import {Money, Image} from '@shopify/hydrogen'; import type { OrderLineItemFullFragment, OrderQuery, } from 'customer-accountapi.generated'; import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery'; export const meta: Route.MetaFunction = ({data}) => { return [{title: `Order ${data?.order?.name}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { if (!params.id) { return redirect('/account/orders'); } const orderId = atob(params.id); const {data, errors}: {data: OrderQuery; errors?: Array<{message: string}>} = await context.customerAccount.query(CUSTOMER_ORDER_QUERY, { variables: { orderId, language: context.customerAccount.i18n.language, }, }); if (errors?.length || !data?.order) { throw new Error('Order not found'); } const {order} = data; // Extract line items directly from nodes array const lineItems = order.lineItems.nodes; ``` ### Step 2.​11: Display order history Implement customer order history listing with locale-specific pagination. ##### File: [($locale).account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.orders._index.tsx) ## File ```tsx import { useLoaderData, } from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).account.orders._index'; import { Money, getPaginationVariables, flattenConnection, } from '@shopify/hydrogen'; import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery'; import type { CustomerOrdersFragment, OrderItemFragment, } from 'customer-accountapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; export const meta: Route.MetaFunction = () => { return [{title: 'Orders'}]; }; export async function loader({request, context}: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { pageBy: 20, }); const {data, errors} = await context.customerAccount.query( CUSTOMER_ORDERS_QUERY, { variables: { ...paginationVariables, language: context.customerAccount.i18n.language, }, }, ); if (errors?.length || !data?.customer) { throw Error('Customer orders not found'); } return {customer: data.customer}; } export default function Orders() { const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>(); const {orders} = customer; return (
{orders.nodes.length ? : }
); } function OrdersTable({orders}: Pick) { return (
{orders?.nodes.length ? ( {({node: order}) => } ) : ( )}
); } function EmptyOrders() { return (

You haven't placed any orders yet.


Start Shopping →

); } function OrderItem({order}: {order: OrderItemFragment}) { const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status; return ( <>
#{order.number}

{new Date(order.processedAt).toDateString()}

{order.financialStatus}

{fulfillmentStatus &&

{fulfillmentStatus}

} View Order →

); } ``` ### Step 2.​12: Build customer profile page Add a customer profile editing form with localized field labels. ##### File: [($locale).account.profile.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.profile.tsx) ## File ```tsx import type {CustomerFragment} from 'customer-accountapi.generated'; import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types'; import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation'; import { data, Form, useActionData, useNavigation, useOutletContext, } from 'react-router'; import type {Route} from './+types/($locale).account.profile'; export type ActionResponse = { error: string | null; customer: CustomerFragment | null; }; export const meta: Route.MetaFunction = () => { return [{title: 'Profile'}]; }; export async function loader({context}: Route.LoaderArgs) { context.customerAccount.handleAuthStatus(); return {}; } export async function action({request, context}: Route.ActionArgs) { const {customerAccount} = context; if (request.method !== 'PUT') { return data({error: 'Method not allowed'}, {status: 405}); } const form = await request.formData(); try { const customer: CustomerUpdateInput = {}; const validInputKeys = ['firstName', 'lastName'] as const; for (const [key, value] of form.entries()) { if (!validInputKeys.includes(key as any)) { continue; } if (typeof value === 'string' && value.length) { customer[key as (typeof validInputKeys)[number]] = value; } } // update customer and possibly password const {data, errors} = await customerAccount.mutate( CUSTOMER_UPDATE_MUTATION, { variables: { customer, language: context.customerAccount.i18n.language, }, }, ); if (errors?.length) { throw new Error(errors[0].message); } if (!data?.customerUpdate?.customer) { throw new Error('Customer profile update failed.'); } return { error: null, customer: data?.customerUpdate?.customer, }; } catch (error: any) { return data( {error: error.message, customer: null}, { status: 400, }, ); } } export default function AccountProfile() { const account = useOutletContext<{customer: CustomerFragment}>(); const {state} = useNavigation(); const action = useActionData(); const customer = action?.customer ?? account?.customer; return (

My profile


Personal information
{action?.error ? (

{action.error}

) : (
)}
); } ``` ### Step 2.​13: Create account layout Add an account layout wrapper with locale-aware navigation tabs. ##### File: [($locale).account.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account.tsx) ## File ```tsx import { data as remixData, Form, Outlet, useLoaderData, } from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).account'; import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; export function shouldRevalidate() { return true; } export async function loader({context}: Route.LoaderArgs) { const {data, errors} = await context.customerAccount.query( CUSTOMER_DETAILS_QUERY, { variables: { language: context.customerAccount.i18n.language, }, }, ); if (errors?.length || !data?.customer) { throw new Error('Customer not found'); } return remixData( {customer: data.customer}, { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', }, }, ); } export default function AccountLayout() { const {customer} = useLoaderData(); const heading = customer ? customer.firstName ? `Welcome, ${customer.firstName}` : `Welcome to your account.` : 'Account Details'; return (

{heading}




); } function AccountMenu() { function isActiveStyle({ isActive, isPending, }: { isActive: boolean; isPending: boolean; }) { return { fontWeight: isActive ? 'bold' : undefined, color: isPending ? 'grey' : 'black', }; } return ( ); } function Logout() { return (
 
); } ``` ### Step 2.​14: Handle OAuth authorization Add an OAuth authorization callback route with locale preservation. ##### File: [($locale).account\_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account_.authorize.tsx) ## File ```tsx import type {Route} from './+types/($locale).account_.authorize'; export async function loader({context}: Route.LoaderArgs) { return context.customerAccount.authorize(); } ``` ### Step 2.​15: Create login redirect Add a customer login redirect with a locale-specific return URL. ##### File: [($locale).account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account_.login.tsx) ## File ```tsx import type {Route} from './+types/($locale).account_.login'; export async function loader({context}: Route.LoaderArgs) { return context.customerAccount.login(); } ``` ### Step 2.​16: Handle logout Add a logout handler that maintains locale after the user signs out. ##### File: [($locale).account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).account_.logout.tsx) ## File ```tsx import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account_.logout'; // if we don't implement this, /account/logout will get caught by account.$.tsx to do login export async function loader() { return redirect('/'); } export async function action({context}: Route.ActionArgs) { return context.customerAccount.logout(); } ``` ### Step 2.​17: Show blog articles Add a blog article page with locale-specific content and SEO metadata. ##### File: [($locale).blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).blogs.$blogHandle.$articleHandle.tsx) ## File ```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).blogs.$blogHandle.$articleHandle'; import {Image} from '@shopify/hydrogen'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; export const meta: Route.MetaFunction = ({data}) => { return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { const {blogHandle, articleHandle} = params; if (!articleHandle || !blogHandle) { throw new Response('Not found', {status: 404}); } const [{blog}] = await Promise.all([ context.storefront.query(ARTICLE_QUERY, { variables: {blogHandle, articleHandle}, }), // Add other queries here, so that they are loaded in parallel ]); if (!blog?.articleByHandle) { throw new Response(null, {status: 404}); } redirectIfHandleIsLocalized( request, { handle: articleHandle, data: blog.articleByHandle, }, { handle: blogHandle, data: blog, }, ); const article = blog.articleByHandle; return {article}; } /** * Load data for rendering content below the fold. This data is deferred and will be * fetched after the initial page load. If it's unavailable, the page should still 200. * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { return {}; } export default function Article() { const {article} = useLoaderData(); const {title, image, contentHtml, author} = article; const publishedDate = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'long', day: 'numeric', }).format(new Date(article.publishedAt)); return (

{title}
·{' '}
{author?.name}

{image && }
); } // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog#field-blog-articlebyhandle const ARTICLE_QUERY = `#graphql query Article( $articleHandle: String! $blogHandle: String! $country: CountryCode $language: LanguageCode ) @inContext(language: $language, country: $country) { blog(handle: $blogHandle) { handle articleByHandle(handle: $articleHandle) { handle title contentHtml publishedAt author: authorV2 { name } image { id altText url width height } seo { description title } } } } ` as const; ``` ### Step 2.​18: List blog posts Add a blog listing page with localized article previews and pagination. ##### File: [($locale).blogs.$blogHandle.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).blogs.$blogHandle._index.tsx) ## File ```tsx import { useLoaderData, } from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs.$blogHandle._index'; import {Image, getPaginationVariables} from '@shopify/hydrogen'; import type {ArticleItemFragment} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; export const meta: Route.MetaFunction = ({data}) => { return [{title: `Hydrogen | ${data?.blog.title ?? ''} blog`}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({ context, request, params, }: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { pageBy: 4, }); ``` ### Step 2.​19: Display all blogs Add an overview page for all blogs with locale-aware navigation links. ##### File: [($locale).blogs.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).blogs._index.tsx) ## File ```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs._index'; import {getPaginationVariables} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import type {BlogsQuery} from 'storefrontapi.generated'; type BlogNode = BlogsQuery['blogs']['nodes'][0]; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Blogs`}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context, request}: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { pageBy: 10, }); const [{blogs}] = await Promise.all([ context.storefront.query(BLOGS_QUERY, { variables: { ...paginationVariables, }, }), // Add other queries here, so that they are loaded in parallel ]); return {blogs}; } /** * Load data for rendering content below the fold. This data is deferred and will be * fetched after the initial page load. If it's unavailable, the page should still 200. * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { return {}; } export default function Blogs() { const {blogs} = useLoaderData(); return (

Blogs

connection={blogs}> {({node: blog}) => (

{blog.title}

)}
); } // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog const BLOGS_QUERY = `#graphql query Blogs( $country: CountryCode $endCursor: String $first: Int $language: LanguageCode $last: Int $startCursor: String ) @inContext(country: $country, language: $language) { blogs( first: $first, last: $last, before: $startCursor, after: $endCursor ) { pageInfo { hasNextPage hasPreviousPage startCursor endCursor } nodes { title handle seo { title description } } } } ` as const; ``` ### Step 2.​20: Show collection pages Add a collection page displaying products with locale-specific pricing and availability. ##### File: [($locale).collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).collections.$handle.tsx) ## File ```tsx import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).collections.$handle'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; import {ProductItem} from '~/components/ProductItem'; import type {ProductItemFragment} from 'storefrontapi.generated'; export const meta: Route.MetaFunction = ({data}) => { return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; const paginationVariables = getPaginationVariables(request, { pageBy: 8, }); if (!handle) { throw redirect('/collections'); } ``` ### Step 2.​21: List all collections Add a collections listing page with localized collection names and images. ##### File: [($locale).collections.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).collections._index.tsx) ## File ```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).collections._index'; import {getPaginationVariables, Image} from '@shopify/hydrogen'; import type {CollectionFragment} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context, request}: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { pageBy: 4, }); const [{collections}] = await Promise.all([ context.storefront.query(COLLECTIONS_QUERY, { variables: paginationVariables, }), // Add other queries here, so that they are loaded in parallel ]); return {collections}; } /** * Load data for rendering content below the fold. This data is deferred and will be * fetched after the initial page load. If it's unavailable, the page should still 200. * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { return {}; } export default function Collections() { const {collections} = useLoaderData(); return (

Collections

connection={collections} resourcesClassName="collections-grid" > {({node: collection, index}) => ( )}
); } function CollectionItem({ collection, index, }: { collection: CollectionFragment; index: number; }) { return ( {collection?.image && ( {collection.image.altText )}
{collection.title}
); } const COLLECTIONS_QUERY = `#graphql fragment Collection on Collection { id title handle image { id url altText width height } } query StoreCollections( $country: CountryCode $endCursor: String $first: Int $language: LanguageCode $last: Int $startCursor: String ) @inContext(country: $country, language: $language) { collections( first: $first, last: $last, before: $startCursor, after: $endCursor ) { nodes { ...Collection } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } } ` as const; ``` ### Step 2.​22: Show all products Add an "All products" page with locale-based filtering and sorting. ##### File: [($locale).collections.all.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).collections.all.tsx) ## File ```tsx import type {Route} from './+types/($locale).collections.all'; import { useLoaderData, } from 'react-router'; import {getPaginationVariables, Image, Money} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {ProductItem} from '~/components/ProductItem'; import type {CollectionItemFragment} from 'storefrontapi.generated'; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Products`}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context, request}: Route.LoaderArgs) { const {storefront} = context; const paginationVariables = getPaginationVariables(request, { pageBy: 8, }); const [{products}] = await Promise.all([ storefront.query(CATALOG_QUERY, { variables: {...paginationVariables}, }), // Add other queries here, so that they are loaded in parallel ]); return {products}; } /** * Load data for rendering content below the fold. This data is deferred and will be * fetched after the initial page load. If it's unavailable, the page should still 200. * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { return {}; } export default function Collection() { const {products} = useLoaderData(); return (

Products

connection={products} resourcesClassName="products-grid" > {({node: product, index}) => ( )}
); } const COLLECTION_ITEM_FRAGMENT = `#graphql fragment MoneyCollectionItem on MoneyV2 { amount currencyCode } fragment CollectionItem on Product { id handle title featuredImage { id altText url width height } priceRange { minVariantPrice { ...MoneyCollectionItem } maxVariantPrice { ...MoneyCollectionItem } } } ` as const; // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/product const CATALOG_QUERY = `#graphql query Catalog( $country: CountryCode $language: LanguageCode $first: Int $last: Int $startCursor: String $endCursor: String ) @inContext(country: $country, language: $language) { products(first: $first, last: $last, before: $startCursor, after: $endCursor) { nodes { ...CollectionItem } pageInfo { hasPreviousPage hasNextPage startCursor endCursor } } } ${COLLECTION_ITEM_FRAGMENT} ` as const; ``` ### Step 2.​23: Display content pages Add a dynamic page route for locale-specific content pages. ##### File: [($locale).pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).pages.$handle.tsx) ## File ```tsx import { useLoaderData, } from 'react-router'; import type {Route} from './+types/($locale).pages.$handle'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; export const meta: Route.MetaFunction = ({data}) => { return [{title: `Hydrogen | ${data?.page.title ?? ''}`}]; }; export async function loader(args: Route.LoaderArgs) { // Start fetching non-critical data without blocking time to first byte const deferredData = loadDeferredData(args); // Await the critical data required to render initial state of the page const criticalData = await loadCriticalData(args); return {...deferredData, ...criticalData}; } /** * Load data necessary for rendering content above the fold. This is the critical data * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({ context, request, params, }: Route.LoaderArgs) { if (!params.handle) { throw new Error('Missing page handle'); } const [{page}] = await Promise.all([ context.storefront.query(PAGE_QUERY, { variables: { handle: params.handle, }, }), // Add other queries here, so that they are loaded in parallel ]); if (!page) { throw new Response('Not Found', {status: 404}); } redirectIfHandleIsLocalized(request, {handle: params.handle, data: page}); return { page, }; } /** * Load data for rendering content below the fold. This data is deferred and will be * fetched after the initial page load. If it's unavailable, the page should still 200. * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { return {}; } export default function Page() { const {page} = useLoaderData(); return (

{page.title}

); } const PAGE_QUERY = `#graphql query Page( $language: LanguageCode, $country: CountryCode, $handle: String! ) @inContext(language: $language, country: $country) { page(handle: $handle) { handle id title body seo { description title } } } ` as const; ``` ### Step 2.​24: Show policy pages Add a policy page (privacy, terms, etc.) with locale-specific legal content. ##### File: [($locale).policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).policies.$handle.tsx) ## File ```tsx import { useLoaderData, } from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).policies.$handle'; import {type Shop} from '@shopify/hydrogen/storefront-api-types'; type SelectedPolicies = keyof Pick< Shop, 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy' >; export const meta: Route.MetaFunction = ({data}) => { return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { if (!params.handle) { throw new Response('No handle was passed in', {status: 404}); } const policyName = params.handle.replace( /-([a-z])/g, (_: unknown, m1: string) => m1.toUpperCase(), ) as SelectedPolicies; const data = await context.storefront.query(POLICY_CONTENT_QUERY, { variables: { privacyPolicy: false, shippingPolicy: false, termsOfService: false, refundPolicy: false, [policyName]: true, language: context.storefront.i18n?.language, }, }); const policy = data.shop?.[policyName]; if (!policy) { throw new Response('Could not find the policy', {status: 404}); } return {policy}; } export default function Policy() { const {policy} = useLoaderData(); return (


← Back to Policies

{policy.title}

); } // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop const POLICY_CONTENT_QUERY = `#graphql fragment Policy on ShopPolicy { body handle id title url } query Policy( $country: CountryCode $language: LanguageCode $privacyPolicy: Boolean! $refundPolicy: Boolean! $shippingPolicy: Boolean! $termsOfService: Boolean! ) @inContext(language: $language, country: $country) { shop { privacyPolicy @include(if: $privacyPolicy) { ...Policy } shippingPolicy @include(if: $shippingPolicy) { ...Policy } termsOfService @include(if: $termsOfService) { ...Policy } refundPolicy @include(if: $refundPolicy) { ...Policy } } } ` as const; ``` ### Step 2.​25: List all policies Add a policies index page that lists all available store policies. ##### File: [($locale).policies.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).policies._index.tsx) ## File ```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).policies._index'; import type {PoliciesQuery, PolicyItemFragment} from 'storefrontapi.generated'; export async function loader({context}: Route.LoaderArgs) { const data: PoliciesQuery = await context.storefront.query(POLICIES_QUERY); const shopPolicies = data.shop; const policies: PolicyItemFragment[] = [ shopPolicies?.privacyPolicy, shopPolicies?.shippingPolicy, shopPolicies?.termsOfService, shopPolicies?.refundPolicy, shopPolicies?.subscriptionPolicy, ].filter((policy): policy is PolicyItemFragment => policy != null); if (!policies.length) { throw new Response('No policies found', {status: 404}); } return {policies}; } export default function Policies() { const {policies} = useLoaderData(); return (

Policies

{policies.map((policy) => (
{policy.title}
))}
); } const POLICIES_QUERY = `#graphql fragment PolicyItem on ShopPolicy { id title handle } query Policies ($country: CountryCode, $language: LanguageCode) @inContext(country: $country, language: $language) { shop { privacyPolicy { ...PolicyItem } shippingPolicy { ...PolicyItem } termsOfService { ...PolicyItem } refundPolicy { ...PolicyItem } subscriptionPolicy { id title handle } } } ` as const; ``` ### Step 2.​26: Build search functionality Add a search results page with locale-aware product matching and predictive search. ##### File: [($locale).search.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/\($locale\).search.tsx) ## File ```tsx import { useLoaderData, } from 'react-router'; import type {Route} from './+types/($locale).search'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; import {SearchForm} from '~/components/SearchForm'; import {SearchResults} from '~/components/SearchResults'; import { type RegularSearchReturn, type PredictiveSearchReturn, getEmptyPredictiveSearchResult, } from '~/lib/search'; import type {RegularSearchQuery, PredictiveSearchQuery} from 'storefrontapi.generated'; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Search`}]; }; export async function loader({request, context}: Route.LoaderArgs) { const url = new URL(request.url); const isPredictive = url.searchParams.has('predictive'); const searchPromise: Promise = isPredictive ? predictiveSearch({request, context}) : regularSearch({request, context}); searchPromise.catch((error: Error) => { console.error(error); return {term: '', result: null, error: error.message}; }); return await searchPromise; } /** * Renders the /search route ``` *** ## Deleted Files * [templates/skeleton/app/routes/account.$.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account.$.tsx) * [templates/skeleton/app/routes/account.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account._index.tsx) * [templates/skeleton/app/routes/account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account.addresses.tsx) * [templates/skeleton/app/routes/account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account.orders.$id.tsx) * [templates/skeleton/app/routes/account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account.orders._index.tsx) * [templates/skeleton/app/routes/account.profile.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account.profile.tsx) * [templates/skeleton/app/routes/account.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account.tsx) * [templates/skeleton/app/routes/account\_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) * [templates/skeleton/app/routes/account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account_.login.tsx) * [templates/skeleton/app/routes/account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/account_.logout.tsx) * [templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx) * [templates/skeleton/app/routes/blogs.$blogHandle.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx) * [templates/skeleton/app/routes/blogs.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/blogs._index.tsx) * [templates/skeleton/app/routes/collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/collections.$handle.tsx) * [templates/skeleton/app/routes/collections.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/collections._index.tsx) * [templates/skeleton/app/routes/collections.all.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/collections.all.tsx) * [templates/skeleton/app/routes/pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/pages.$handle.tsx) * [templates/skeleton/app/routes/policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/policies.$handle.tsx) * [templates/skeleton/app/routes/policies.\_index.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/policies._index.tsx) * [templates/skeleton/app/routes/search.tsx](https://github.com/Shopify/hydrogen/blob/12374c8f03f82c6800000cf08e327c4db4c287bb/templates/skeleton/templates/skeleton/app/routes/search.tsx) *** ## Next steps * Test your implementation by going to your store and selecting a different market from the country selector. * Refer to the [Shopify Help Center](https://help.shopify.com/en/manual/markets) for more information on how to optimize and manage your international markets. *** * [Requirements](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#requirements) * [Ingredients](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#ingredients) * [Step 1: Add localization utilities and update core components](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#step-1-add-localization-utilities-and-update-core-components) * [Step 2: Localizing the individual routes](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#step-2-localizing-the-individual-routes) * [Deleted Files](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#deleted-files) * [Next steps](https://shopify.dev/docs/storefronts/headless/hydrogen/cookbook/markets#next-steps)