Skip to main content

Markets in Hydrogen

This recipe shows how to add support for Shopify 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.


  • Set up your store's regions and languages using Shopify 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.

New files added to the template by this recipe.

FileDescription
app/components/CountrySelector.tsxA component that displays a country selector inside the Header.
app/components/Link.tsxA 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.tsComprehensive 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.tsxA route that renders a localized version of the home page.
app/routes/($locale).account.$.tsxFallback route for unauthenticated account pages with locale support
app/routes/($locale).account._index.tsxLocalized account dashboard redirect route
app/routes/($locale).account.addresses.tsxCustomer address management page with locale-aware forms and links
app/routes/($locale).account.orders.$id.tsxIndividual order details page with localized currency and date formatting
app/routes/($locale).account.orders._index.tsxCustomer order history listing with locale-specific pagination
app/routes/($locale).account.profile.tsxCustomer profile editing form with localized field labels
app/routes/($locale).account.tsxAccount layout wrapper with locale-aware navigation tabs
app/routes/($locale).account_.authorize.tsxOAuth authorization callback route with locale preservation
app/routes/($locale).account_.login.tsxCustomer login redirect with locale-specific return URL
app/routes/($locale).account_.logout.tsxLogout handler that maintains locale after sign out
app/routes/($locale).blogs.$blogHandle.$articleHandle.tsxBlog article page with locale-specific content and SEO metadata
app/routes/($locale).blogs.$blogHandle._index.tsxBlog listing page with localized article previews and pagination
app/routes/($locale).blogs._index.tsxAll blogs overview page with locale-aware navigation links
app/routes/($locale).cart.tsxA localized cart route.
app/routes/($locale).collections.$handle.tsxCollection page displaying products with locale-specific pricing and availability
app/routes/($locale).collections._index.tsxCollections listing page with localized collection names and images
app/routes/($locale).collections.all.tsxAll products page with locale-based filtering and sorting
app/routes/($locale).pages.$handle.tsxDynamic page route for locale-specific content pages
app/routes/($locale).policies.$handle.tsxPolicy page (privacy, terms, etc.) with locale-specific legal content
app/routes/($locale).policies._index.tsxPolicies index page listing all available store policies
app/routes/($locale).products.$handle.tsxA route that renders a localized version of the product page.
app/routes/($locale).search.tsxSearch results page with locale-aware product matching and predictive search
app/routes/($locale).tsxA utility route that makes sure the locale is valid.

Anchor to Step 1: Add localization utilities and update core componentsStep 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.

Update cart line items to use the unified Link component for product links.

@@ -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';

Anchor to Step 1.2: Create a CountrySelector componentStep 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

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 (
<details style={{position: 'relative', cursor: 'pointer'}}>
<summary>{label}</summary>
<div
style={{
position: 'absolute',
background: 'white',
width: 200,
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: 10,
border: '1px solid #ccc',
borderRadius: 4,
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.1)',
}}
>
{SUPPORTED_LOCALES.map((locale) => (
<LocaleForm
key={`locale-${locale.language}-${locale.country}`}
locale={locale}
/>
))}
</div>
</details>
);
}

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 (
<Form method="POST" action={action}>
<input type="hidden" name="redirectTo" value={newPath} />
<input
type="hidden"
name="cartFormInput"
value={JSON.stringify(variables)}
/>
<button type="submit">
{locale.language}-{locale.country}
</button>
</Form>
);
}

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

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)
* <Link to="/products">Products</Link>
*
* @example
* // Navigation link with active styles
* <Link variant="nav" to="/about" style={activeStyle}>About</Link>
*
* @example
* // Switch locale while preserving current path
* <Link to="/" locale={frenchLocale} preservePath>Français</Link>
*
* @example
* // Link to specific locale
* <Link to="/products" locale={canadianLocale}>Canadian Products</Link>
*/
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 <ReactNavLink {...(restProps as NavLinkProps)} to={to} />;
}
return <ReactLink {...(restProps as LinkProps)} to={to} />;
}

Anchor to Step 1.4: Create comprehensive i18n utilitiesStep 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

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 (

Replace standard react-router Link imports with the new unified Link component. This ensures all product links automatically include the correct locale prefix.

@@ -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,

Anchor to Step 1.6: Add the selected locale to the contextStep 1.6: Add the selected locale to the context

Detect the locale from the URL path, and add it to the HydrogenContext.

@@ -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,
},

  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

@@ -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 (
<header className="header">
- <NavLink prefetch="intent" to="/" style={activeLinkStyle} end>
+ <Link variant="nav" prefetch="intent" to="/" style={activeLinkStyle} end>
<strong>{shop.name}</strong>
- </NavLink>
+ </Link>
<HeaderMenu
menu={menu}
viewport="desktop"
@@ -57,7 +59,8 @@ export function HeaderMenu({
return (
<nav className={className} role="navigation">
{viewport === 'mobile' && (
- <NavLink
+ <Link
+ variant="nav"
end
onClick={close}
prefetch="intent"
@@ -65,7 +68,7 @@ export function HeaderMenu({
to="/"
>
Home
- </NavLink>
+ </Link>
)}
{(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 (
- <NavLink
+ <Link
+ variant="nav"
className="header-menu-item"
end
key={item.id}
@@ -88,7 +92,7 @@ export function HeaderMenu({
to={url}
>
{item.title}
- </NavLink>
+ </Link>
);
})}
</nav>
@@ -102,13 +106,14 @@ function HeaderCtas({
return (
<nav className="header-ctas" role="navigation">
<HeaderMenuMobileToggle />
- <NavLink prefetch="intent" to="/account" style={activeLinkStyle}>
+ <CountrySelector />
+ <Link variant="nav" prefetch="intent" to="/account" style={activeLinkStyle}>
<Suspense fallback="Sign in">
<Await resolve={isLoggedIn} errorElement="Sign in">
{(isLoggedIn) => (isLoggedIn ? 'Account' : 'Sign in')}
</Await>
</Suspense>
- </NavLink>
+ </Link>
<SearchToggle />
<CartToggle cart={cart} />
</nav>

Anchor to Step 1.8: Add the selected locale to the root routeStep 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.
@@ -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}
>
- <PageLayout {...data}>
+ <PageLayout
+ key={`${data.selectedLocale.language}-${data.selectedLocale.country}`}
+ {...data}
+ >
<Outlet />
</PageLayout>
</Analytics.Provider>

Anchor to Step 2: Localizing the individual routesStep 2: Localizing the individual routes

In this section, we'll add localization to the individual routes using the language dynamic segment.

Replace all Link imports to use the unified locale-aware Link component for consistent navigation.

@@ -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';

Anchor to Step 2.2: Add language dynamic segment to the desired routesStep 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.

Anchor to Step 2.3: Add localization to the home pageStep 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

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 {

Anchor to Step 2.4: Add localization to the cart pageStep 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

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<typeof loader>();

return (
<div className="cart">
<h1>Cart</h1>
<CartMain layout="page" cart={cart} />
</div>
);
}

Anchor to Step 2.5: Add localization to the product pageStep 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

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,
},
];
};

Anchor to Step 2.6: Add a utility route to validate the locale.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

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

Anchor to Step 2.7: Handle unauthenticated account pagesStep 2.7: Handle unauthenticated account pages

Add a fallback route for unauthenticated account pages with locale support.

File

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

Anchor to Step 2.8: Redirect to account dashboardStep 2.8: Redirect to account dashboard

Add a localized account dashboard redirect route.

File

import {redirect} from 'react-router';

export async function loader() {
return redirect('/account/orders');
}

Anchor to Step 2.9: Add address managementStep 2.9: Add address management

Add a customer address management page with locale-aware forms and links.

File

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<AddressFragment['id'], string> | null;
updatedAddress?: AddressFragment;
};

export const meta: Route.MetaFunction = () => {
return [{title: 'Addresses'}];
};

export async function loader({context}: Route.LoaderArgs) {
context.customerAccount.handleAuthStatus();

Anchor to Step 2.10: Show order detailsStep 2.10: Show order details

Add an individual order details page with localized currency and date formatting.

File

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;

Anchor to Step 2.11: Display order historyStep 2.11: Display order history

Implement customer order history listing with locale-specific pagination.

File

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 (
<div className="orders">
{orders.nodes.length ? <OrdersTable orders={orders} /> : <EmptyOrders />}
</div>
);
}

function OrdersTable({orders}: Pick<CustomerOrdersFragment, 'orders'>) {
return (
<div className="acccount-orders">
{orders?.nodes.length ? (
<PaginatedResourceSection connection={orders}>
{({node: order}) => <OrderItem key={order.id} order={order} />}
</PaginatedResourceSection>
) : (
<EmptyOrders />
)}
</div>
);
}

function EmptyOrders() {
return (
<div>
<p>You haven&apos;t placed any orders yet.</p>
<br />
<p>
<Link to="/collections">Start Shopping →</Link>
</p>
</div>
);
}

function OrderItem({order}: {order: OrderItemFragment}) {
const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status;
return (
<>
<fieldset>
<Link to={`/account/orders/${btoa(order.id)}`}>
<strong>#{order.number}</strong>
</Link>
<p>{new Date(order.processedAt).toDateString()}</p>
<p>{order.financialStatus}</p>
{fulfillmentStatus && <p>{fulfillmentStatus}</p>}
<Money data={order.totalPrice} />
<Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
</fieldset>
<br />
</>
);
}

Anchor to Step 2.12: Build customer profile pageStep 2.12: Build customer profile page

Add a customer profile editing form with localized field labels.

File

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<ActionResponse>();
const customer = action?.customer ?? account?.customer;

return (
<div className="account-profile">
<h2>My profile</h2>
<br />
<Form method="PUT">
<legend>Personal information</legend>
<fieldset>
<label htmlFor="firstName">First name</label>
<input
id="firstName"
name="firstName"
type="text"
autoComplete="given-name"
placeholder="First name"
aria-label="First name"
defaultValue={customer.firstName ?? ''}
minLength={2}
/>
<label htmlFor="lastName">Last name</label>
<input
id="lastName"
name="lastName"
type="text"
autoComplete="family-name"
placeholder="Last name"
aria-label="Last name"
defaultValue={customer.lastName ?? ''}
minLength={2}
/>
</fieldset>
{action?.error ? (
<p>
<mark>
<small>{action.error}</small>
</mark>
</p>
) : (
<br />
)}
<button type="submit" disabled={state !== 'idle'}>
{state !== 'idle' ? 'Updating' : 'Update'}
</button>
</Form>
</div>
);
}

Anchor to Step 2.13: Create account layoutStep 2.13: Create account layout

Add an account layout wrapper with locale-aware navigation tabs.

File

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<typeof loader>();

const heading = customer
? customer.firstName
? `Welcome, ${customer.firstName}`
: `Welcome to your account.`
: 'Account Details';

return (
<div className="account">
<h1>{heading}</h1>
<br />
<AccountMenu />
<br />
<br />
<Outlet context={{customer}} />
</div>
);
}

function AccountMenu() {
function isActiveStyle({
isActive,
isPending,
}: {
isActive: boolean;
isPending: boolean;
}) {
return {
fontWeight: isActive ? 'bold' : undefined,
color: isPending ? 'grey' : 'black',
};
}

return (
<nav role="navigation">
<Link variant="nav" to="/account/orders" style={isActiveStyle}>
Orders &nbsp;
</Link>
&nbsp;|&nbsp;
<Link variant="nav" to="/account/profile" style={isActiveStyle}>
&nbsp; Profile &nbsp;
</Link>
&nbsp;|&nbsp;
<Link variant="nav" to="/account/addresses" style={isActiveStyle}>
&nbsp; Addresses &nbsp;
</Link>
&nbsp;|&nbsp;
<Logout />
</nav>
);
}

function Logout() {
return (
<Form className="account-logout" method="POST" action="/account/logout">
&nbsp;<button type="submit">Sign out</button>
</Form>
);
}

Anchor to Step 2.14: Handle OAuth authorizationStep 2.14: Handle OAuth authorization

Add an OAuth authorization callback route with locale preservation.

File

import type {Route} from './+types/($locale).account_.authorize';

export async function loader({context}: Route.LoaderArgs) {
return context.customerAccount.authorize();
}

Anchor to Step 2.15: Create login redirectStep 2.15: Create login redirect

Add a customer login redirect with a locale-specific return URL.

File

import type {Route} from './+types/($locale).account_.login';

export async function loader({context}: Route.LoaderArgs) {
return context.customerAccount.login();
}

Anchor to Step 2.16: Handle logoutStep 2.16: Handle logout

Add a logout handler that maintains locale after the user signs out.

File

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

Anchor to Step 2.17: Show blog articlesStep 2.17: Show blog articles

Add a blog article page with locale-specific content and SEO metadata.

File

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<typeof loader>();
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 (
<div className="article">
<h1>
{title}
<div>
<time dateTime={article.publishedAt}>{publishedDate}</time> &middot;{' '}
<address>{author?.name}</address>
</div>
</h1>

{image && <Image data={image} sizes="90vw" loading="eager" />}
<div
dangerouslySetInnerHTML={{__html: contentHtml}}
className="article"
/>
</div>
);
}

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

Anchor to Step 2.18: List blog postsStep 2.18: List blog posts

Add a blog listing page with localized article previews and pagination.

File

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

Anchor to Step 2.19: Display all blogsStep 2.19: Display all blogs

Add an overview page for all blogs with locale-aware navigation links.

File

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<typeof loader>();

return (
<div className="blogs">
<h1>Blogs</h1>
<div className="blogs-grid">
<PaginatedResourceSection<BlogNode> connection={blogs}>
{({node: blog}) => (
<Link
className="blog"
key={blog.handle}
prefetch="intent"
to={`/blogs/${blog.handle}`}
>
<h2>{blog.title}</h2>
</Link>
)}
</PaginatedResourceSection>
</div>
</div>
);
}

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

Anchor to Step 2.20: Show collection pagesStep 2.20: Show collection pages

Add a collection page displaying products with locale-specific pricing and availability.

File

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

Anchor to Step 2.21: List all collectionsStep 2.21: List all collections

Add a collections listing page with localized collection names and images.

File

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<typeof loader>();

return (
<div className="collections">
<h1>Collections</h1>
<PaginatedResourceSection<CollectionFragment>
connection={collections}
resourcesClassName="collections-grid"
>
{({node: collection, index}) => (
<CollectionItem
key={collection.id}
collection={collection}
index={index}
/>
)}
</PaginatedResourceSection>
</div>
);
}

function CollectionItem({
collection,
index,
}: {
collection: CollectionFragment;
index: number;
}) {
return (
<Link
className="collection-item"
key={collection.id}
to={`/collections/${collection.handle}`}
prefetch="intent"
>
{collection?.image && (
<Image
alt={collection.image.altText || collection.title}
aspectRatio="1/1"
data={collection.image}
loading={index < 3 ? 'eager' : undefined}
sizes="(min-width: 45em) 400px, 100vw"
/>
)}
<h5>{collection.title}</h5>
</Link>
);
}

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;

Anchor to Step 2.22: Show all productsStep 2.22: Show all products

Add an "All products" page with locale-based filtering and sorting.

File

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<typeof loader>();

return (
<div className="collection">
<h1>Products</h1>
<PaginatedResourceSection<CollectionItemFragment>
connection={products}
resourcesClassName="products-grid"
>
{({node: product, index}) => (
<ProductItem
key={product.id}
product={product}
loading={index < 8 ? 'eager' : undefined}
/>
)}
</PaginatedResourceSection>
</div>
);
}

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;

Anchor to Step 2.23: Display content pagesStep 2.23: Display content pages

Add a dynamic page route for locale-specific content pages.

File

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<typeof loader>();

return (
<div className="page">
<header>
<h1>{page.title}</h1>
</header>
<main dangerouslySetInnerHTML={{__html: page.body}} />
</div>
);
}

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;

Anchor to Step 2.24: Show policy pagesStep 2.24: Show policy pages

Add a policy page (privacy, terms, etc.) with locale-specific legal content.

File

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<typeof loader>();

return (
<div className="policy">
<br />
<br />
<div>
<Link to="/policies">← Back to Policies</Link>
</div>
<br />
<h1>{policy.title}</h1>
<div dangerouslySetInnerHTML={{__html: policy.body}} />
</div>
);
}

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

Anchor to Step 2.25: List all policiesStep 2.25: List all policies

Add a policies index page that lists all available store policies.

File

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<typeof loader>();

return (
<div className="policies">
<h1>Policies</h1>
<div>
{policies.map((policy) => (
<fieldset key={policy.id}>
<Link to={`/policies/${policy.handle}`}>{policy.title}</Link>
</fieldset>
))}
</div>
</div>
);
}

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;

Anchor to Step 2.26: Build search functionalityStep 2.26: Build search functionality

Add a search results page with locale-aware product matching and predictive search.

File

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<PredictiveSearchReturn | RegularSearchReturn> =
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


  • Test your implementation by going to your store and selecting a different market from the country selector.
  • Refer to the Shopify Help Center for more information on how to optimize and manage your international markets.

Was this page helpful?