---
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 CartLineItem 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 CountrySelector 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 ProductItem 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 CountrySelector 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 CartMain 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 (
);
}
function OrderItem({order}: {order: OrderItemFragment}) {
const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status;
return (
<>
>
);
}
```
### 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
);
}
```
### 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 (
);
}
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 (
);
}
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) => (
))}
);
}
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)