Skip to main content
Legacy Hydrogen

These pages refer to Hydrogen versions 2026-04 and earlier. For versions 2026-10 and later, refer to the latest Hydrogen docs. If you're not using the latest version of Hydrogen, then we recommend that you migrate your storefront. To learn more, refer to Migrate from legacy Hydrogen.

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/lib/sanitizeHtml.tsAn allowlist HTML sanitizer for merchant-managed rich text (page bodies, article content, policies and product descriptions) before it is rendered with dangerouslySetInnerHTML.
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, LineItemChildrenMap} 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 {

Anchor to Step 1.2: Update AddToCartButton to use locale-aware cart routeStep 1.2: Update AddToCartButton to use locale-aware cart route

Update the AddToCartButton component to post to the localized cart route (e.g., /FR-CA/cart instead of /cart) so the cart is created with the correct market context and currency.

@@ -1,5 +1,6 @@
import {type FetcherWithComponents} from 'react-router';
import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen';
+import {useLocalizedPath} from '~/lib/i18n';
export function AddToCartButton({
analytics,
@@ -14,8 +15,10 @@ export function AddToCartButton({
lines: Array<OptimisticCartLineInput>;
onClick?: () => void;
}) {
+ const cartRoute = useLocalizedPath('/cart');
+
return (
- <CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
+ <CartForm route={cartRoute} inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
{(fetcher: FetcherWithComponents<any>) => (
<>
<input

Anchor to Step 1.3: Create a CountrySelector component with accessibility labelsStep 1.3: Create a CountrySelector component with accessibility labels

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
role="group"
aria-label="Country selector"
style={{position: 'relative', cursor: 'pointer'}}
>
<summary aria-label={`Current locale: ${label}`}>{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">
Switch to {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.5: Create comprehensive i18n utilitiesStep 1.5: 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 (
url.pathname
// take the first part of the pathname (split by /)
.split('/')
.at(1)
// replace the .data suffix, if present
?.replace(/\.data$/, '')
// normalize to uppercase
?.toUpperCase() ?? null
);
}

export function getLocaleFromRequest(request: Request): Locale {
const firstPathPart = getFirstPathPart(new URL(request.url));

type LocaleFromUrl = [Locale['language'], Locale['country']];

let pathPrefix = '';

// If the first path part is not a valid locale, return the default locale
if (firstPathPart == null || !RE_LOCALE_PREFIX.test(firstPathPart)) {
return DEFAULT_LOCALE;
}

pathPrefix = '/' + firstPathPart;
const [language, country] = firstPathPart.split('-') as LocaleFromUrl;
return {language, country, pathPrefix};
}

export interface WithLocale {
selectedLocale: Locale;
}

/**
* Get the currently selected locale from route data
* @returns Current locale or null if not set
*
* @example
* const locale = useSelectedLocale();
* // {language: 'FR', country: 'CA', pathPrefix: '/FR-CA'}
*/
export function useSelectedLocale(): Locale | null {
const [root] = useMatches();
const {selectedLocale} = root.data as WithLocale;

return selectedLocale ?? null;
}

/**
* Get pathname without locale prefix (case-insensitive)
*/
export function getPathWithoutLocale(
pathname: string,
selectedLocale: Locale | null,
): string {
if (!selectedLocale?.pathPrefix) return pathname;

const prefix = selectedLocale.pathPrefix.replace(/\/+$/, '');
// Case-insensitive check for locale prefix
if (pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
const pathWithoutPrefix = pathname.slice(prefix.length);
// Ensure it starts with /
return pathWithoutPrefix.startsWith('/')
? pathWithoutPrefix
: '/' + pathWithoutPrefix;
}
return pathname;
}

export function localeMatchesPrefix(localeSegment: string | null): boolean {
const prefix = '/' + (localeSegment ?? '');
return SUPPORTED_LOCALES.some((supportedLocale) => {
return supportedLocale.pathPrefix.toUpperCase() === prefix.toUpperCase();
});
}

/**
* Normalize a locale prefix (remove trailing slashes)
*/
export function normalizePrefix(prefix: string): string {
return prefix.replace(/\/+$/, '') || '';
}

/**
* Find a locale by its prefix in a path
*/
export function findLocaleByPrefix(path: string): Locale | null {
const normalizedPath = path.toLowerCase();
return (
SUPPORTED_LOCALES.find((locale) => {
if (locale.pathPrefix === '/') return false;
return normalizedPath.startsWith(locale.pathPrefix.toLowerCase());
}) ?? null
);
}

/**
* Remove locale or language prefixes from a path
* Examples: /fr/products → /products, /FR-CA/about → /about
*/
export function cleanPath(pathname: string): string {
const locale = findLocaleByPrefix(pathname);
if (locale) {
const prefix = normalizePrefix(locale.pathPrefix);
return pathname.slice(prefix.length) || '/';
}

// Remove language-only prefixes that aren't valid locales
const match = pathname.match(/^\/[a-z]{2}(-[a-z]{2})?\//i);
if (match && !findLocaleByPrefix(match[0])) {
return pathname.slice(match[0].length - 1);
}

return pathname;
}

/**
* Transform a path with appropriate locale prefix
*
* @param to - Target path
* @param locale - Optional specific locale to use
* @param preservePath - Keep current path when switching locales
* @returns Localized path
*
* @example
* // Add current locale to path
* useLocalizedPath('/products') // '/FR-CA/products'
*
* @example
* // Switch to different locale
* useLocalizedPath('/', frenchLocale, true) // '/FR-CA/current-path'
*
* @example
* // Force specific locale
* useLocalizedPath('/about', englishLocale) // '/EN-CA/about'
*/
export function useLocalizedPath(
to: string,
locale?: Locale,
preservePath?: boolean,
): string;
export function useLocalizedPath(
to: string | object,
locale?: Locale,
preservePath?: boolean,
): string | object;
export function useLocalizedPath(
to: string | object,
locale?: Locale,
preservePath = false,
): string | object {
const currentLocale = useSelectedLocale();
const {pathname} = useLocation();

if (typeof to !== 'string') return to;

// Locale switching: maintain current path
if (locale && preservePath) {
const cleanCurrentPath = cleanPath(pathname);
return normalizePrefix(locale.pathPrefix) + cleanCurrentPath;
}

// Explicit locale for specific link
if (locale) {
return normalizePrefix(locale.pathPrefix) + to;
}

// Skip if path already has locale
if (findLocaleByPrefix(to)) {
return to;
}

// Add current locale to path
return normalizePrefix(currentLocale?.pathPrefix || '') + to;
}

Anchor to Step 1.6: Create an HTML sanitizer for merchant-managed rich textStep 1.6: Create an HTML sanitizer for merchant-managed rich text

Pages, blog articles, policies and product descriptions come from the Storefront API as HTML written by merchants, staff and apps, and the routes render that HTML with dangerouslySetInnerHTML. Treat it as untrusted: create a sanitizeHtml() helper that keeps formatting, tables, images and safe links, and removes anything that can run script, such as <script> elements, event handler attributes and javascript: URLs. It also removes embeds (<iframe>, <video>, <audio>), style and id attributes, so YouTube embeds, inline styling and in-page anchor links won't render. Extend the allowlist if your store relies on them.

Call it in each route's loader, so the sanitized HTML is what reaches the browser in both the server render and client-side navigations.

File

/**
* Allowlist HTML sanitizer for rich text returned by the Storefront API
* (`Page.body`, `Article.contentHtml`, `ShopPolicy.body`,
* `Product.descriptionHtml`).
*
* @description That HTML is written by merchants, staff and apps, and the
* routes render it with `dangerouslySetInnerHTML`. Treat it as untrusted:
* anything that can run script on the storefront origin (script tags, event
* handlers, `javascript:` URLs, embedded documents, SVG/MathML, styles) is
* removed, and ordinary formatting, tables, images and safe links are kept.
*
* The input is tokenized and the output is rebuilt from scratch: only
* allowlisted tags and attributes are written, attribute values are always
* quoted and escaped, text is escaped, and every element that the output opens
* is also closed in the output. Where the browser implies an end tag for `li`,
* `dd`, `dt`, `p` and `table`, the output writes that end tag explicitly.
* Table parts outside a table are dropped, as the browser drops them.
*
* The sanitizer does not reproduce every HTML tree construction rule. Malformed
* input can still parse into a different structure than the output suggests.
* That can break the layout, but it cannot run script, because only
* allowlisted tags and attributes are written.
*
* The function has no dependencies and no DOM, so it runs the same in the
* Oxygen worker, in Node and in the browser.
*/

const ALLOWED_TAGS = new Set([
'a',
'abbr',
'b',
'blockquote',
'br',
'caption',
'cite',
'code',
'col',
'colgroup',
'dd',
'del',
'details',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'mark',
'ol',
'p',
'pre',
'q',
's',
'small',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'u',
'ul',
]);

const VOID_TAGS = new Set(['br', 'col', 'hr', 'img']);

/**
* Elements removed together with everything inside them. Their content is
* either script, styles, an embedded document, or markup in a foreign
* namespace (SVG/MathML) where the HTML parsing rules differ.
*
* Void elements such as `embed` and `frame` have no end tag, so they are not
* listed here. They are not allowlisted either, so only the tag is removed.
*/
const DROP_WITH_CONTENT_TAGS = new Set([
'applet',
'audio',
'frameset',
'head',
'iframe',
'math',
'noembed',
'noframes',
'noscript',
'object',
'plaintext',
'script',
'select',
'style',
'svg',
'template',
'textarea',
'title',
'video',
'xmp',
]);

/** Elements whose content the browser does not parse as markup. */
const RAW_TEXT_TAGS = new Set([
'iframe',
'noembed',
'noframes',
'noscript',
'script',
'style',
'textarea',
'title',
'xmp',
]);

/**
* Allowlisted elements that the HTML spec puts in the "special" category,
* except `div` and `p`. A start tag for `li`, `dd` or `dt` does not close an
* open element of the same kind outside one of these elements.
*/
const LIST_ITEM_BOUNDARY_TAGS = new Set([
'blockquote',
'caption',
'colgroup',
'dd',
'details',
'dl',
'dt',
'figcaption',
'figure',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'li',
'ol',
'pre',
'summary',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'ul',
]);

/** Allowlisted elements that end the button scope of the HTML spec. */
const BUTTON_SCOPE_BOUNDARY_TAGS = new Set(['caption', 'table', 'td', 'th']);

/** Table parts that the browser ignores outside a `table` element. */
const TABLE_PART_TAGS = new Set([
'caption',
'col',
'colgroup',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
]);

/** Allowlisted start tags that close an open `p` element in button scope. */
const CLOSES_P_TAGS = new Set([
'blockquote',
'dd',
'details',
'div',
'dl',
'dt',
'figcaption',
'figure',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'li',
'ol',
'p',
'pre',
'summary',
'table',
'ul',
]);

const GLOBAL_ATTRIBUTES = new Set(['class', 'dir', 'lang', 'title']);

const TAG_ATTRIBUTES: Record<string, Set<string>> = {
a: new Set(['href', 'rel', 'target']),
col: new Set(['span']),
colgroup: new Set(['span']),
details: new Set(['open']),
img: new Set(['alt', 'height', 'src', 'width']),
ol: new Set(['reversed', 'start', 'type']),
td: new Set(['colspan', 'rowspan']),
th: new Set(['colspan', 'rowspan', 'scope']),
};

const ALLOWED_REL_TOKENS = new Set([
'nofollow',
'noopener',
'noreferrer',
'sponsored',
'ugc',
]);

const URL_ATTRIBUTES = new Set(['href', 'src']);
const SAFE_LINK_PROTOCOLS = new Set(['http', 'https', 'mailto', 'tel']);
const SAFE_IMAGE_PROTOCOLS = new Set(['http', 'https']);
const NUMERIC_ATTRIBUTES = new Set([
'colspan',
'height',
'rowspan',
'span',
'start',
'width',
]);

/** Named references decoded before a URL is checked. */
const NAMED_REFERENCES: Record<string, string> = {
amp: '&',
apos: "'",
colon: ':',
gt: '>',
lpar: '(',
lt: '<',
newline: '\n',
nbsp: '\u00a0',
quot: '"',
rpar: ')',
sol: '/',
tab: '\t',
};

const END_TAG_NAME = /[a-zA-Z][^\s/>]*/y;

type Attribute = {name: string; value: string};

export function sanitizeHtml(html: string | null | undefined): string {
if (!html) return '';

const input = html.replace(/\0/g, '');
const length = input.length;
const openTags: string[] = [];
// Counts open elements by name, so an end tag with no open element is
// skipped without a scan of the stack. Without it, input such as
// `<div>` x N followed by `</p>` x N takes quadratic time.
const openCounts = new Map<string, number>();
// For each open element, the stack index of the element that a new `li`,
// `dd`/`dt`, `table` or `p`-closing start tag implicitly closes, or -1. The
// value is set when the element opens, so a start tag never scans the stack.
const listItemTargets: number[] = [];
const definitionTargets: number[] = [];
const paragraphTargets: number[] = [];
const tableTargets: number[] = [];
let output = '';
let index = 0;

const open = (name: string) => {
const position = openTags.length;
const inherit = (targets: number[]) => targets[position - 1] ?? -1;
listItemTargets.push(
name === 'li'
? position
: LIST_ITEM_BOUNDARY_TAGS.has(name)
? -1
: inherit(listItemTargets),
);
definitionTargets.push(
name === 'dd' || name === 'dt'
? position
: LIST_ITEM_BOUNDARY_TAGS.has(name)
? -1
: inherit(definitionTargets),
);
paragraphTargets.push(
name === 'p'
? position
: BUTTON_SCOPE_BOUNDARY_TAGS.has(name)
? -1
: inherit(paragraphTargets),
);
// Outside a cell, a nested `table` start tag closes the open table.
tableTargets.push(
name === 'table'
? position
: name === 'td' || name === 'th'
? -1
: inherit(tableTargets),
);
openTags.push(name);
openCounts.set(name, (openCounts.get(name) ?? 0) + 1);
};

/** Closes the element at `position` and every element inside it. */
const closeFrom = (position: number) => {
if (position < 0) return;
while (openTags.length > position) {
const closed = openTags.pop();
if (closed === undefined) break;
listItemTargets.pop();
definitionTargets.pop();
paragraphTargets.pop();
tableTargets.pop();
openCounts.set(closed, (openCounts.get(closed) ?? 1) - 1);
output += `</${closed}>`;
}
};

const top = (targets: number[]) => targets[targets.length - 1] ?? -1;

while (index < length) {
const tagStart = input.indexOf('<', index);

if (tagStart === -1) {
output += escapeText(input.slice(index));
break;
}

output += escapeText(input.slice(index, tagStart));
index = tagStart;

const next = input.charAt(index + 1);

// Comments, doctypes, CDATA and processing instructions are dropped.
if (input.startsWith('<!--', index)) {
const end = input.indexOf('-->', index + 4);
index = end === -1 ? length : end + 3;
continue;
}
if (next === '!' || next === '?') {
const end = input.indexOf('>', index + 2);
index = end === -1 ? length : end + 1;
continue;
}

// End tag.
if (next === '/') {
END_TAG_NAME.lastIndex = index + 2;
const nameMatch = END_TAG_NAME.exec(input);
const end = input.indexOf('>', index + 2);
if (!nameMatch) {
// `</>` and `</ ...>` are not end tags; the browser drops them.
index = end === -1 ? length : end + 1;
continue;
}
index = end === -1 ? length : end + 1;
const name = nameMatch[0].toLowerCase();
if (!openCounts.get(name)) continue;
closeFrom(openTags.lastIndexOf(name));
continue;
}

// Anything that is not `<` followed by a letter is text.
if (!/[a-zA-Z]/.test(next)) {
output += '&lt;';
index += 1;
continue;
}

const tag = readStartTag(input, index);
if (!tag) {
// Unterminated tag at the end of the input; the browser drops it too.
break;
}
index = tag.end;

if (DROP_WITH_CONTENT_TAGS.has(tag.name)) {
// `<svg/>` and `<math/>` are complete elements; everything else is
// dropped up to its end tag.
const closedForeignElement =
tag.selfClosing && (tag.name === 'svg' || tag.name === 'math');
if (!closedForeignElement) index = skipElement(input, index, tag.name);
continue;
}

if (!ALLOWED_TAGS.has(tag.name)) {
// Unknown or unsafe wrapper: drop the tag, keep its text content.
continue;
}

if (TABLE_PART_TAGS.has(tag.name) && !openCounts.get('table')) continue;

// Write the end tags that the browser implies, so the browser does not
// close an element that the sanitizer still treats as open.
if (tag.name === 'table') closeFrom(top(tableTargets));
if (tag.name === 'li') closeFrom(top(listItemTargets));
if (tag.name === 'dd' || tag.name === 'dt') {
closeFrom(top(definitionTargets));
}
if (CLOSES_P_TAGS.has(tag.name)) closeFrom(top(paragraphTargets));

output += `<${tag.name}${serializeAttributes(tag.name, tag.attributes)}>`;
// The browser ignores `/>` on non-void HTML elements, so `<a/>` opens an
// element that stays open until its end tag.
if (!VOID_TAGS.has(tag.name)) open(tag.name);
}

closeFrom(0);

return output;
}

function readStartTag(
input: string,
start: number,
):
| {name: string; attributes: Attribute[]; selfClosing: boolean; end: number}
| undefined {
const length = input.length;
let index = start + 1;
let name = '';

while (index < length && !/[\s/>]/.test(input.charAt(index))) {
name += input.charAt(index);
index += 1;
}

const attributes: Attribute[] = [];
const seen = new Set<string>();
let selfClosing = false;

while (index < length) {
const char = input.charAt(index);

if (char === '>') {
return {
name: name.toLowerCase(),
attributes,
selfClosing,
end: index + 1,
};
}

if (/\s/.test(char)) {
index += 1;
continue;
}

if (char === '/') {
selfClosing = input.charAt(index + 1) === '>';
index += 1;
continue;
}

selfClosing = false;

// Attribute name. A leading `=` belongs to the name, as in the HTML spec.
let attributeName = char;
index += 1;
while (index < length && !/[\s/>=]/.test(input.charAt(index))) {
attributeName += input.charAt(index);
index += 1;
}

while (index < length && /\s/.test(input.charAt(index))) index += 1;

let value = '';
if (input.charAt(index) === '=') {
index += 1;
while (index < length && /\s/.test(input.charAt(index))) index += 1;

const quote = input.charAt(index);
if (quote === '"' || quote === "'") {
const close = input.indexOf(quote, index + 1);
if (close === -1) return undefined;
value = input.slice(index + 1, close);
index = close + 1;
} else {
const valueStart = index;
while (index < length && !/[\s>]/.test(input.charAt(index))) {
index += 1;
}
value = input.slice(valueStart, index);
}
}

const normalizedName = attributeName.toLowerCase();
if (!seen.has(normalizedName)) {
seen.add(normalizedName);
attributes.push({name: normalizedName, value});
}
}

return undefined;
}

/** Returns the index just past the element's end tag, or the input length. */
function skipElement(input: string, start: number, name: string): number {
if (name === 'plaintext') return input.length;

// Raw text elements end at the first matching end tag. Other elements can
// nest, so count same-named start and end tags.
const pattern = RAW_TEXT_TAGS.has(name)
? new RegExp(`<(/)${name}(?=[\\s/>])`, 'gi')
: new RegExp(`<(/?)${name}(?=[\\s/>])`, 'gi');
// In SVG and MathML, `/>` closes the element, so a nested `<svg/>` or
// `<math/>` does not add a level.
const foreign = name === 'svg' || name === 'math';
let depth = 1;
let index = start;
while (depth > 0) {
pattern.lastIndex = index;
const match = pattern.exec(input);
if (!match) return input.length;
if (match[1]) {
const end = input.indexOf('>', match.index);
if (end === -1) return input.length;
depth -= 1;
index = end + 1;
} else if (foreign) {
const tag = readStartTag(input, match.index);
if (!tag) return input.length;
if (!tag.selfClosing) depth += 1;
index = tag.end;
} else {
const end = input.indexOf('>', match.index);
if (end === -1) return input.length;
depth += 1;
index = end + 1;
}
}
return index;
}

function serializeAttributes(tag: string, attributes: Attribute[]): string {
const allowed = TAG_ATTRIBUTES[tag];
let output = '';
let opensNewWindow = false;
const relTokens = new Set<string>();

for (const {name, value} of attributes) {
if (!GLOBAL_ATTRIBUTES.has(name) && !allowed?.has(name)) continue;
// `rel` is written below so links can't opt out of `noopener`.
if (name === 'rel') {
for (const token of value.toLowerCase().split(/\s+/)) {
if (ALLOWED_REL_TOKENS.has(token)) relTokens.add(token);
}
continue;
}

if (URL_ATTRIBUTES.has(name)) {
const url = sanitizeUrl(
value,
tag === 'img' ? SAFE_IMAGE_PROTOCOLS : SAFE_LINK_PROTOCOLS,
);
if (url === undefined) continue;
output += ` ${name}="${escapeAttributeValue(url)}"`;
continue;
}

if (name === 'target') {
const target = value.trim().toLowerCase();
if (!['_blank', '_parent', '_self', '_top'].includes(target)) continue;
opensNewWindow = target === '_blank';
output += ` target="${target}"`;
continue;
}

if (NUMERIC_ATTRIBUTES.has(name)) {
const number = value.trim();
if (!/^\d{1,5}%?$/.test(number)) continue;
output += ` ${name}="${number}"`;
continue;
}

if (name === 'dir' && !/^(ltr|rtl|auto)$/i.test(value.trim())) continue;
if (
name === 'scope' &&
!/^(row|col|rowgroup|colgroup)$/i.test(value.trim())
)
continue;
if (name === 'type' && !/^[1aAiI]$/.test(value.trim())) continue;

if (name === 'open' || name === 'reversed') {
output += ` ${name}`;
continue;
}

// These attributes can't run script, so references are kept as written
// and only a bare `&`, `<`, `>` or `"` is escaped.
output += ` ${name}="${escapeText(value).replace(/"/g, '&quot;')}"`;
}

if (tag === 'a' && opensNewWindow) {
relTokens.add('noopener');
relTokens.add('noreferrer');
}
if (relTokens.size) {
output += ` rel="${[...relTokens].join(' ')}"`;
}

return output;
}

/**
* Returns the URL to write, or `undefined` to drop the attribute. The value is
* decoded first so `&#106;avascript:` and `java&Tab;script:` are caught, and
* the decoded value is what gets written back (re-escaped), so the browser
* sees exactly the URL that was checked.
*/
function sanitizeUrl(
value: string,
allowedProtocols: Set<string>,
): string | undefined {
// The URL parser trims C0 controls and spaces and ignores tabs and newlines.
// The trailing run is trimmed with a loop: a `+$` regex is quadratic on a
// long run of spaces that is not at the end of the value.
const decoded = decodeReferences(value);
let end = decoded.length;
while (end > 0 && isTrimmedUrlChar(decoded.charCodeAt(end - 1))) end -= 1;
const url = decoded
.slice(0, end)
.replace(/^[\u0000-\u0020\u007f-\u009f]+/, '')
.replace(/[\t\n\r]/g, '');

if (url === '') return undefined;

const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(url);
if (!scheme) {
// A colon before any `/`, `?` or `#` would make the browser read a scheme.
const firstColon = url.indexOf(':');
const firstDelimiter = url.search(/[/?#]/);
if (
firstColon !== -1 &&
(firstDelimiter === -1 || firstColon < firstDelimiter)
) {
return undefined;
}
return url;
}

return allowedProtocols.has(scheme[1].toLowerCase()) ? url : undefined;
}

function isTrimmedUrlChar(code: number): boolean {
return code <= 0x20 || (code >= 0x7f && code <= 0x9f);
}

/**
* Decodes numeric character references and a small set of named ones. Any
* other `&` survives as a literal character and is escaped on output, so the
* browser can never decode something this function did not.
*/
function decodeReferences(value: string): string {
return value.replace(
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));?/g,
(match, decimal?: string, hex?: string, named?: string) => {
if (decimal || hex) {
const codePoint = parseInt(decimal ?? hex ?? '', decimal ? 10 : 16);
if (
!Number.isFinite(codePoint) ||
codePoint === 0 ||
codePoint > 0x10ffff ||
(codePoint >= 0xd800 && codePoint <= 0xdfff)
) {
return '\ufffd';
}
return String.fromCodePoint(codePoint);
}
if (named && match.endsWith(';')) {
const decoded = NAMED_REFERENCES[named.toLowerCase()];
if (decoded !== undefined) return decoded;
}
return match;
},
);
}

/** Escapes text, leaving well-formed character references intact. */
function escapeText(text: string): string {
return text
.replace(/&(?!#\d+;|#[xX][0-9a-fA-F]+;|[a-zA-Z][a-zA-Z0-9]*;)/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

function escapeAttributeValue(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

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.8: Add the selected locale to the contextStep 1.8: Add the selected locale to the context

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

@@ -2,6 +2,7 @@
import {AppSession} from '~/lib/session';
import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
import type {CartApiQueryFragment} from 'storefrontapi.generated';
+import {getLocaleFromRequest} from './i18n';
// Define the additional context object
const additionalContext = {
@@ -45,6 +46,8 @@
AppSession.init(request, [env.SESSION_SECRET]),
]);
+ const i18n = getLocaleFromRequest(request);
+
const hydrogenContext = createHydrogenContext(
{
env,
@@ -52,8 +55,7 @@
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.10: Add the selected locale to the root routeStep 1.10: 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, type CartLine} 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 {
featuredCollection: collections.nodes[0],
};
}

/**
* 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) {
const recommendedProducts = context.storefront
.query(RECOMMENDED_PRODUCTS_QUERY)
.catch((error: Error) => {
// Log query errors, but don't throw them so the page can still render
console.error(error);
return null;
});

return {
recommendedProducts,
};
}

export default function Homepage() {
const data = useLoaderData<typeof loader>();
return (
<div className="home">
<FeaturedCollection collection={data.featuredCollection} />
<RecommendedProducts products={data.recommendedProducts} />
</div>
);
}

function FeaturedCollection({
collection,
}: {
collection: FeaturedCollectionFragment;
}) {
if (!collection) return null;
const image = collection?.image;
return (
<Link
className="featured-collection"
to={`/collections/${collection.handle}`}
>
{image && (
<div className="featured-collection-image">
<Image data={image} sizes="100vw" />
</div>
)}
<h1>{collection.title}</h1>
</Link>
);
}

function RecommendedProducts({
products,
}: {
products: Promise<RecommendedProductsQuery | null>;
}) {
return (
<div className="recommended-products">
<h2>Recommended Products</h2>
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={products}>
{(response) => (
<div className="recommended-products-grid">
{response
? response.products.nodes.map((product) => (
<ProductItem key={product.id} product={product} />
))
: null}
</div>
)}
</Await>
</Suspense>
<br />
</div>
);
}

const FEATURED_COLLECTION_QUERY = `#graphql
fragment FeaturedCollection on Collection {
id
title
image {
id
url
altText
width
height
}
handle
}
query FeaturedCollection($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
nodes {
...FeaturedCollection
}
}
}
` as const;

const RECOMMENDED_PRODUCTS_QUERY = `#graphql
fragment RecommendedProduct on Product {
id
title
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
featuredImage {
id
url
altText
width
height
}
}
query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
products(first: 4, sortKey: UPDATED_AT, reverse: true) {
nodes {
...RecommendedProduct
}
}
}
` as const;

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,
type HeadersFunction,
} 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: 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.GiftCardCodesAdd: {
const formGiftCardCode = inputs.giftCardCode;

const giftCardCodes = (
formGiftCardCode ? [formGiftCardCode] : []
) as string[];

result = await cart.addGiftCardCodes(giftCardCodes);
break;
}
case CartForm.ACTIONS.GiftCardCodesRemove: {
const appliedGiftCardIds = inputs.giftCardCodes as string[];
result = await cart.removeGiftCardCodes(appliedGiftCardIds);
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.
  3. Sanitize the product's descriptionHtml with sanitizeHtml() in the loader.
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';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

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

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;

if (!handle) {
throw new Error('Expected product handle to be defined');
}

const [{product}] = await Promise.all([
storefront.query(PRODUCT_QUERY, {
variables: {
handle,
selectedOptions: getSelectedProductOptions(request),
country: storefront.i18n.country,
language: storefront.i18n.language,
},
}),
// Add other queries here, so that they are loaded in parallel
]);

if (!product?.id) {
throw new Response(null, {status: 404});
}

// The API handle might be localized, so redirect to the localized handle
redirectIfHandleIsLocalized(request, {handle, data: product});

return {
// Product descriptions are merchant-managed HTML: sanitize before rendering it raw.
product: {
...product,
descriptionHtml: sanitizeHtml(product.descriptionHtml),
},
};
}

/**
* 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, params}: Route.LoaderArgs) {
// Put any API calls that is not critical to be available on first page render
// For example: product reviews, product recommendations, social feeds.

return {};
}

export default function Product() {
const {product} = useLoaderData<typeof loader>();

// Optimistically selects a variant with given available variant information
const selectedVariant = useOptimisticVariant(
product.selectedOrFirstAvailableVariant,
getAdjacentAndFirstAvailableVariants(product),
);

// Sets the search param to the selected variant without navigation
// only when no search params are set in the url
useSelectedOptionInUrlParam(selectedVariant.selectedOptions);

// Get the product options array
const productOptions = getProductOptions({
...product,
selectedOrFirstAvailableVariant: selectedVariant,
});

const {title, descriptionHtml} = product;

return (
<div className="product">
<ProductImage image={selectedVariant?.image} />
<div className="product-main">
<h1>{title}</h1>
<ProductPrice
price={selectedVariant?.price}
compareAtPrice={selectedVariant?.compareAtPrice}
/>
<br />
<ProductForm
productOptions={productOptions}
selectedVariant={selectedVariant}
/>
<br />
<br />
<p>
<strong>Description</strong>
</p>
<br />
<div dangerouslySetInnerHTML={{__html: descriptionHtml}} />
<br />
</div>
<Analytics.ProductView
data={{
products: [
{
id: product.id,
title: product.title,
price: selectedVariant?.price.amount || '0',
vendor: product.vendor,
variantId: selectedVariant?.id || '',
variantTitle: selectedVariant?.title || '',
quantity: 1,
},
],
}}
/>
</div>
);
}

const PRODUCT_VARIANT_FRAGMENT = `#graphql
fragment ProductVariant on ProductVariant {
availableForSale
compareAtPrice {
amount
currencyCode
}
id
image {
__typename
id
url
altText
width
height
}
price {
amount
currencyCode
}
product {
title
handle
}
selectedOptions {
name
value
}
sku
title
unitPrice {
amount
currencyCode
}
}
` as const;

const PRODUCT_FRAGMENT = `#graphql
fragment Product on Product {
id
title
vendor
handle
descriptionHtml
description
encodedVariantExistence
encodedVariantAvailability
options {
name
optionValues {
name
firstSelectableVariant {
...ProductVariant
}
swatch {
color
image {
previewImage {
url
}
}
}
}
}
selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
...ProductVariant
}
adjacentVariants (selectedOptions: $selectedOptions) {
...ProductVariant
}
seo {
description
title
}
}
${PRODUCT_VARIANT_FRAGMENT}
` as const;

const PRODUCT_QUERY = `#graphql
query Product(
$country: CountryCode
$handle: String!
$language: LanguageCode
$selectedOptions: [SelectedOptionInput!]!
) @inContext(country: $country, language: $language) {
product(handle: $handle) {
...Product
}
}
${PRODUCT_FRAGMENT}
` as const;

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

return {};
}

export async function action({request, context}: Route.ActionArgs) {
const {customerAccount} = context;

try {
const form = await request.formData();

const addressId = form.has('addressId')
? String(form.get('addressId'))
: null;
if (!addressId) {
throw new Error('You must provide an address id.');
}

// this will ensure redirecting to login never happen for mutatation
const isLoggedIn = await customerAccount.isLoggedIn();
if (!isLoggedIn) {
return data(
{error: {[addressId]: 'Unauthorized'}},
{
status: 401,
},
);
}

const defaultAddress = form.has('defaultAddress')
? String(form.get('defaultAddress')) === 'on'
: false;
const address: CustomerAddressInput = {};
const keys: (keyof CustomerAddressInput)[] = [
'address1',
'address2',
'city',
'company',
'territoryCode',
'firstName',
'lastName',
'phoneNumber',
'zoneCode',
'zip',
];

for (const key of keys) {
const value = form.get(key);
if (typeof value === 'string') {
address[key] = value;
}
}

switch (request.method) {
case 'POST': {
// handle new address creation
try {
const {data, errors} = await customerAccount.mutate(
CREATE_ADDRESS_MUTATION,
{
variables: {
address,
defaultAddress,
language: customerAccount.i18n.language,
},
},
);

if (errors?.length) {
throw new Error(errors[0].message);
}

if (data?.customerAddressCreate?.userErrors?.length) {
throw new Error(data?.customerAddressCreate?.userErrors[0].message);
}

if (!data?.customerAddressCreate?.customerAddress) {
throw new Error('Customer address create failed.');
}

return {
error: null,
createdAddress: data?.customerAddressCreate?.customerAddress,
defaultAddress,
};
} catch (error: unknown) {
if (error instanceof Error) {
return data(
{error: {[addressId]: error.message}},
{
status: 400,
},
);
}
return data(
{error: {[addressId]: error}},
{
status: 400,
},
);
}
}

case 'PUT': {
// handle address updates
try {
const {data, errors} = await customerAccount.mutate(
UPDATE_ADDRESS_MUTATION,
{
variables: {
address,
addressId: decodeURIComponent(addressId),
defaultAddress,
language: customerAccount.i18n.language,
},
},
);

if (errors?.length) {
throw new Error(errors[0].message);
}

if (data?.customerAddressUpdate?.userErrors?.length) {
throw new Error(data?.customerAddressUpdate?.userErrors[0].message);
}

if (!data?.customerAddressUpdate?.customerAddress) {
throw new Error('Customer address update failed.');
}

return {
error: null,
updatedAddress: address,
defaultAddress,
};
} catch (error: unknown) {
if (error instanceof Error) {
return data(
{error: {[addressId]: error.message}},
{
status: 400,
},
);
}
return data(
{error: {[addressId]: error}},
{
status: 400,
},
);
}
}

case 'DELETE': {
// handles address deletion
try {
const {data, errors} = await customerAccount.mutate(
DELETE_ADDRESS_MUTATION,
{
variables: {
addressId: decodeURIComponent(addressId),
language: customerAccount.i18n.language,
},
},
);

if (errors?.length) {
throw new Error(errors[0].message);
}

if (data?.customerAddressDelete?.userErrors?.length) {
throw new Error(data?.customerAddressDelete?.userErrors[0].message);
}

if (!data?.customerAddressDelete?.deletedAddressId) {
throw new Error('Customer address delete failed.');
}

return {error: null, deletedAddress: addressId};
} catch (error: unknown) {
if (error instanceof Error) {
return data(
{error: {[addressId]: error.message}},
{
status: 400,
},
);
}
return data(
{error: {[addressId]: error}},
{
status: 400,
},
);
}
}

default: {
return data(
{error: {[addressId]: 'Method not allowed'}},
{
status: 405,
},
);
}
}
} catch (error: unknown) {
if (error instanceof Error) {
return data(
{error: error.message},
{
status: 400,
},
);
}
return data(
{error},
{
status: 400,
},
);
}
}

export default function Addresses() {
const {customer} = useOutletContext<{customer: CustomerFragment}>();
const {defaultAddress, addresses} = customer;

return (
<div className="account-addresses">
<h2>Addresses</h2>
<br />
{!addresses.nodes.length ? (
<p>You have no addresses saved.</p>
) : (
<div>
<div>
<legend>Create address</legend>
<NewAddressForm />
</div>
<br />
<hr />
<br />
<ExistingAddresses
addresses={addresses}
defaultAddress={defaultAddress}
/>
</div>
)}
</div>
);
}

function NewAddressForm() {
const newAddress = {
address1: '',
address2: '',
city: '',
company: '',
territoryCode: '',
firstName: '',
id: 'new',
lastName: '',
phoneNumber: '',
zoneCode: '',
zip: '',
} as CustomerAddressInput;

return (
<AddressForm
addressId={'NEW_ADDRESS_ID'}
address={newAddress}
defaultAddress={null}
>
{({stateForMethod}) => (
<div>
<button
disabled={stateForMethod('POST') !== 'idle'}
formMethod="POST"
type="submit"
>
{stateForMethod('POST') !== 'idle' ? 'Creating' : 'Create'}
</button>
</div>
)}
</AddressForm>
);
}

function ExistingAddresses({
addresses,
defaultAddress,
}: Pick<CustomerFragment, 'addresses' | 'defaultAddress'>) {
return (
<div>
<legend>Existing addresses</legend>
{addresses.nodes.map((address) => (
<AddressForm
key={address.id}
addressId={address.id}
address={address}
defaultAddress={defaultAddress}
>
{({stateForMethod}) => (
<div>
<button
disabled={stateForMethod('PUT') !== 'idle'}
formMethod="PUT"
type="submit"
>
{stateForMethod('PUT') !== 'idle' ? 'Saving' : 'Save'}
</button>
<button
disabled={stateForMethod('DELETE') !== 'idle'}
formMethod="DELETE"
type="submit"
>
{stateForMethod('DELETE') !== 'idle' ? 'Deleting' : 'Delete'}
</button>
</div>
)}
</AddressForm>
))}
</div>
);
}

export function AddressForm({
addressId,
address,
defaultAddress,
children,
}: {
addressId: AddressFragment['id'];
address: CustomerAddressInput;
defaultAddress: CustomerFragment['defaultAddress'];
children: (props: {
stateForMethod: (method: 'PUT' | 'POST' | 'DELETE') => Fetcher['state'];
}) => React.ReactNode;
}) {
const {state, formMethod} = useNavigation();
const action = useActionData<ActionResponse>();
const error = action?.error?.[addressId];
const isDefaultAddress = defaultAddress?.id === addressId;
return (
<Form id={addressId}>
<fieldset>
<input type="hidden" name="addressId" defaultValue={addressId} />
<label htmlFor="firstName">First name*</label>
<input
aria-label="First name"
autoComplete="given-name"
defaultValue={address?.firstName ?? ''}
id="firstName"
name="firstName"
placeholder="First name"
required
type="text"
/>
<label htmlFor="lastName">Last name*</label>
<input
aria-label="Last name"
autoComplete="family-name"
defaultValue={address?.lastName ?? ''}
id="lastName"
name="lastName"
placeholder="Last name"
required
type="text"
/>
<label htmlFor="company">Company</label>
<input
aria-label="Company"
autoComplete="organization"
defaultValue={address?.company ?? ''}
id="company"
name="company"
placeholder="Company"
type="text"
/>
<label htmlFor="address1">Address line*</label>
<input
aria-label="Address line 1"
autoComplete="address-line1"
defaultValue={address?.address1 ?? ''}
id="address1"
name="address1"
placeholder="Address line 1*"
required
type="text"
/>
<label htmlFor="address2">Address line 2</label>
<input
aria-label="Address line 2"
autoComplete="address-line2"
defaultValue={address?.address2 ?? ''}
id="address2"
name="address2"
placeholder="Address line 2"
type="text"
/>
<label htmlFor="city">City*</label>
<input
aria-label="City"
autoComplete="address-level2"
defaultValue={address?.city ?? ''}
id="city"
name="city"
placeholder="City"
required
type="text"
/>
<label htmlFor="zoneCode">State / Province*</label>
<input
aria-label="State/Province"
autoComplete="address-level1"
defaultValue={address?.zoneCode ?? ''}
id="zoneCode"
name="zoneCode"
placeholder="State / Province"
required
type="text"
/>
<label htmlFor="zip">Zip / Postal Code*</label>
<input
aria-label="Zip"
autoComplete="postal-code"
defaultValue={address?.zip ?? ''}
id="zip"
name="zip"
placeholder="Zip / Postal Code"
required
type="text"
/>
<label htmlFor="territoryCode">Country Code*</label>
<input
aria-label="territoryCode"
autoComplete="country"
defaultValue={address?.territoryCode ?? ''}
id="territoryCode"
name="territoryCode"
placeholder="Country"
required
type="text"
maxLength={2}
/>
<label htmlFor="phoneNumber">Phone</label>
<input
aria-label="Phone Number"
autoComplete="tel"
defaultValue={address?.phoneNumber ?? ''}
id="phoneNumber"
name="phoneNumber"
placeholder="+16135551111"
pattern="^\+?[1-9]\d{3,14}$"
type="tel"
/>
<div>
<input
defaultChecked={isDefaultAddress}
id="defaultAddress"
name="defaultAddress"
type="checkbox"
/>
<label htmlFor="defaultAddress">Set as default address</label>
</div>
{error ? (
<p>
<mark>
<small>{error}</small>
</mark>
</p>
) : (
<br />
)}
{children({
stateForMethod: (method) => (formMethod === method ? state : 'idle'),
})}
</fieldset>
</Form>
);
}

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) {
const {customerAccount} = context;
if (!params.id) {
return redirect('/account/orders');
}

const orderId = atob(params.id);
const {data, errors}: {data: OrderQuery; errors?: Array<{message: string}>} =
await customerAccount.query(CUSTOMER_ORDER_QUERY, {
variables: {
orderId,
language: 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;

// Extract discount applications directly from nodes array
const discountApplications = order.discountApplications.nodes;

// Get fulfillment status from first fulfillment node
const fulfillmentStatus = order.fulfillments.nodes[0]?.status ?? 'N/A';

// Get first discount value with proper type checking
const firstDiscount = discountApplications[0]?.value;

// Type guard for MoneyV2 discount
const discountValue =
firstDiscount?.__typename === 'MoneyV2'
? (firstDiscount as Extract<
typeof firstDiscount,
{__typename: 'MoneyV2'}
>)
: null;

// Type guard for percentage discount
const discountPercentage =
firstDiscount?.__typename === 'PricingPercentageValue'
? (
firstDiscount as Extract<
typeof firstDiscount,
{__typename: 'PricingPercentageValue'}
>
).percentage
: null;

return {
order,
lineItems,
discountValue,
discountPercentage,
fulfillmentStatus,
};
}

export default function OrderRoute() {
const {
order,
lineItems,
discountValue,
discountPercentage,
fulfillmentStatus,
} = useLoaderData<typeof loader>();
return (
<div className="account-order">
<h2>Order {order.name}</h2>
<p>Placed on {new Date(order.processedAt!).toDateString()}</p>
{order.confirmationNumber && (
<p>Confirmation: {order.confirmationNumber}</p>
)}
<br />
<div>
<table>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Price</th>
<th scope="col">Quantity</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
{lineItems.map((lineItem, lineItemIndex) => (
// eslint-disable-next-line react/no-array-index-key
<OrderLineRow key={lineItemIndex} lineItem={lineItem} />
))}
</tbody>
<tfoot>
{((discountValue && discountValue.amount) ||
discountPercentage) && (
<tr>
<th scope="row" colSpan={3}>
<p>Discounts</p>
</th>
<th scope="row">
<p>Discounts</p>
</th>
<td>
{discountPercentage ? (
<span>-{discountPercentage}% OFF</span>
) : (
discountValue && <Money data={discountValue!} />
)}
</td>
</tr>
)}
<tr>
<th scope="row" colSpan={3}>
<p>Subtotal</p>
</th>
<th scope="row">
<p>Subtotal</p>
</th>
<td>
<Money data={order.subtotal!} />
</td>
</tr>
<tr>
<th scope="row" colSpan={3}>
Tax
</th>
<th scope="row">
<p>Tax</p>
</th>
<td>
<Money data={order.totalTax!} />
</td>
</tr>
<tr>
<th scope="row" colSpan={3}>
Total
</th>
<th scope="row">
<p>Total</p>
</th>
<td>
<Money data={order.totalPrice!} />
</td>
</tr>
</tfoot>
</table>
<div>
<h3>Shipping Address</h3>
{order?.shippingAddress ? (
<address>
<p>{order.shippingAddress.name}</p>
{order.shippingAddress.formatted ? (
<p>{order.shippingAddress.formatted}</p>
) : (
''
)}
{order.shippingAddress.formattedArea ? (
<p>{order.shippingAddress.formattedArea}</p>
) : (
''
)}
</address>
) : (
<p>No shipping address defined</p>
)}
<h3>Status</h3>
<div>
<p>{fulfillmentStatus}</p>
</div>
</div>
</div>
<br />
<p>
<a target="_blank" href={order.statusPageUrl} rel="noreferrer">
View Order Status →
</a>
</p>
</div>
);
}

function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) {
return (
<tr key={lineItem.id}>
<td>
<div>
{lineItem?.image && (
<div>
<Image data={lineItem.image} width={96} height={96} />
</div>
)}
<div>
<p>{lineItem.title}</p>
<small>{lineItem.variantTitle}</small>
</div>
</div>
</td>
<td>
<Money data={lineItem.price!} />
</td>
<td>{lineItem.quantity}</td>
<td>
<Money data={lineItem.totalDiscount!} />
</td>
</tr>
);
}

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,
useNavigation,
useSearchParams,
} from 'react-router';
import {useRef} from 'react';
import {Link} from '~/components/Link';
import type {Route} from './+types/($locale).account.orders._index';
import {
Money,
getPaginationVariables,
flattenConnection,
} from '@shopify/hydrogen';
import {
buildOrderSearchQuery,
parseOrderFilters,
ORDER_FILTER_FIELDS,
type OrderFilterParams,
} from '~/lib/orderFilters';
import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery';
import type {
CustomerOrdersFragment,
OrderItemFragment,
} from 'customer-accountapi.generated';
import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';

type OrdersLoaderData = {
customer: CustomerOrdersFragment;
filters: OrderFilterParams;
};

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

export async function loader({request, context}: Route.LoaderArgs) {
const {customerAccount} = context;
const paginationVariables = getPaginationVariables(request, {
pageBy: 20,
});

const url = new URL(request.url);
const filters = parseOrderFilters(url.searchParams);
const query = buildOrderSearchQuery(filters);

const {data, errors} = await customerAccount.query(CUSTOMER_ORDERS_QUERY, {
variables: {
...paginationVariables,
query,
language: customerAccount.i18n.language,
},
});

if (errors?.length || !data?.customer) {
throw Error('Customer orders not found');
}

return {customer: data.customer, filters};
}

export default function Orders() {
const {customer, filters} = useLoaderData<OrdersLoaderData>();
const {orders} = customer;

return (
<div className="orders">
<OrderSearchForm currentFilters={filters} />
<OrdersTable orders={orders} filters={filters} />
</div>
);
}

function OrdersTable({
orders,
filters,
}: {
orders: CustomerOrdersFragment['orders'];
filters: OrderFilterParams;
}) {
const hasFilters = !!(filters.name || filters.confirmationNumber);

return (
<div className="acccount-orders" aria-live="polite">
{orders?.nodes.length ? (
<PaginatedResourceSection connection={orders}>
{({node: order}) => <OrderItem key={order.id} order={order} />}
</PaginatedResourceSection>
) : (
<EmptyOrders hasFilters={hasFilters} />
)}
</div>
);
}

function EmptyOrders({hasFilters = false}: {hasFilters?: boolean}) {
return (
<div>
{hasFilters ? (
<>
<p>No orders found matching your search.</p>
<br />
<p>
<Link to="/account/orders">Clear filters →</Link>
</p>
</>
) : (
<>
<p>You haven&apos;t placed any orders yet.</p>
<br />
<p>
<Link to="/collections">Start Shopping →</Link>
</p>
</>
)}
</div>
);
}

function OrderSearchForm({
currentFilters,
}: {
currentFilters: OrderFilterParams;
}) {
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSearching =
navigation.state !== 'idle' &&
navigation.location?.pathname?.includes('orders');
const formRef = useRef<HTMLFormElement>(null);

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const params = new URLSearchParams();

const name = formData.get(ORDER_FILTER_FIELDS.NAME)?.toString().trim();
const confirmationNumber = formData
.get(ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER)
?.toString()
.trim();

if (name) params.set(ORDER_FILTER_FIELDS.NAME, name);
if (confirmationNumber)
params.set(ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER, confirmationNumber);

setSearchParams(params);
};

const hasFilters = currentFilters.name || currentFilters.confirmationNumber;

return (
<form
ref={formRef}
onSubmit={handleSubmit}
className="order-search-form"
aria-label="Search orders"
>
<fieldset className="order-search-fieldset">
<legend className="order-search-legend">Filter Orders</legend>

<div className="order-search-inputs">
<input
type="search"
name={ORDER_FILTER_FIELDS.NAME}
placeholder="Order #"
aria-label="Order number"
defaultValue={currentFilters.name || ''}
className="order-search-input"
/>
<input
type="search"
name={ORDER_FILTER_FIELDS.CONFIRMATION_NUMBER}
placeholder="Confirmation #"
aria-label="Confirmation number"
defaultValue={currentFilters.confirmationNumber || ''}
className="order-search-input"
/>
</div>

<div className="order-search-buttons">
<button type="submit" disabled={isSearching}>
{isSearching ? 'Searching' : 'Search'}
</button>
{hasFilters && (
<button
type="button"
disabled={isSearching}
onClick={() => {
setSearchParams(new URLSearchParams());
formRef.current?.reset();
}}
>
Clear
</button>
)}
</div>
</fieldset>
</form>
);
}

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>
{order.confirmationNumber && (
<p>Confirmation: {order.confirmationNumber}</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: 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 {customerAccount} = context;
const {data, errors} = await 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({request, context}: Route.LoaderArgs) {
const url = new URL(request.url);
const acrValues = url.searchParams.get('acr_values') || undefined;
const loginHint = url.searchParams.get('login_hint') || undefined;
const loginHintMode = url.searchParams.get('login_hint_mode') || undefined;
const locale = url.searchParams.get('locale') || undefined;

return context.customerAccount.login({
countryCode: context.storefront.i18n.country,
acrValues,
loginHint,
loginHintMode,
locale,
});
}

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. Sanitize the article's contentHtml with sanitizeHtml() in the loader.

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';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

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,
// Article content is merchant-managed HTML: sanitize before rendering it raw.
contentHtml: sanitizeHtml(blog.articleByHandle.contentHtml),
};

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

if (!params.blogHandle) {
throw new Response(`blog not found`, {status: 404});
}

const [{blog}] = await Promise.all([
context.storefront.query(BLOGS_QUERY, {
variables: {
blogHandle: params.blogHandle,
...paginationVariables,
},
}),
// Add other queries here, so that they are loaded in parallel
]);

if (!blog?.articles) {
throw new Response('Not found', {status: 404});
}

redirectIfHandleIsLocalized(request, {handle: params.blogHandle, data: blog});

return {blog};
}

/**
* 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 Blog() {
const {blog} = useLoaderData<typeof loader>();
const {articles} = blog;

return (
<div className="blog">
<h1>{blog.title}</h1>
<div className="blog-grid">
<PaginatedResourceSection<ArticleItemFragment> connection={articles}>
{({node: article, index}) => (
<ArticleItem
article={article}
key={article.id}
loading={index < 2 ? 'eager' : 'lazy'}
/>
)}
</PaginatedResourceSection>
</div>
</div>
);
}

function ArticleItem({
article,
loading,
}: {
article: ArticleItemFragment;
loading?: HTMLImageElement['loading'];
}) {
const publishedAt = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(article.publishedAt!));
return (
<div className="blog-article" key={article.id}>
<Link to={`/blogs/${article.blog.handle}/${article.handle}`}>
{article.image && (
<div className="blog-article-image">
<Image
alt={article.image.altText || article.title}
aspectRatio="3/2"
data={article.image}
loading={loading}
sizes="(min-width: 768px) 50vw, 100vw"
/>
</div>
)}
<h3>{article.title}</h3>
<small>{publishedAt}</small>
</Link>
</div>
);
}

// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
const BLOGS_QUERY = `#graphql
query Blog(
$language: LanguageCode
$blogHandle: String!
$first: Int
$last: Int
$startCursor: String
$endCursor: String
) @inContext(language: $language) {
blog(handle: $blogHandle) {
title
handle
seo {
title
description
}
articles(
first: $first,
last: $last,
before: $startCursor,
after: $endCursor
) {
nodes {
...ArticleItem
}
pageInfo {
hasPreviousPage
hasNextPage
hasNextPage
endCursor
startCursor
}

}
}
}
fragment ArticleItem on Article {
author: authorV2 {
name
}
contentHtml
handle
id
image {
id
altText
url
width
height
}
publishedAt
title
blog {
handle
}
}
` as const;

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

const [{collection}] = await Promise.all([
storefront.query(COLLECTION_QUERY, {
variables: {handle, ...paginationVariables},
// Add other queries here, so that they are loaded in parallel
}),
]);

if (!collection) {
throw new Response(`Collection ${handle} not found`, {
status: 404,
});
}

// The API handle might be localized, so redirect to the localized handle
redirectIfHandleIsLocalized(request, {handle, data: collection});

return {
collection,
};
}

/**
* 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 {collection} = useLoaderData<typeof loader>();

return (
<div className="collection">
<h1>{collection.title}</h1>
<p className="collection-description">{collection.description}</p>
<PaginatedResourceSection<ProductItemFragment>
connection={collection.products}
resourcesClassName="products-grid"
>
{({node: product, index}) => (
<ProductItem
key={product.id}
product={product}
loading={index < 8 ? 'eager' : undefined}
/>
)}
</PaginatedResourceSection>
<Analytics.CollectionView
data={{
collection: {
id: collection.id,
handle: collection.handle,
},
}}
/>
</div>
);
}

const PRODUCT_ITEM_FRAGMENT = `#graphql
fragment MoneyProductItem on MoneyV2 {
amount
currencyCode
}
fragment ProductItem on Product {
id
handle
title
featuredImage {
id
altText
url
width
height
}
priceRange {
minVariantPrice {
...MoneyProductItem
}
maxVariantPrice {
...MoneyProductItem
}
}
}
` as const;

// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
const COLLECTION_QUERY = `#graphql
${PRODUCT_ITEM_FRAGMENT}
query Collection(
$handle: String!
$country: CountryCode
$language: LanguageCode
$first: Int
$last: Int
$startCursor: String
$endCursor: String
) @inContext(country: $country, language: $language) {
collection(handle: $handle) {
id
handle
title
description
products(
first: $first,
last: $last,
before: $startCursor,
after: $endCursor
) {
nodes {
...ProductItem
}
pageInfo {
hasPreviousPage
hasNextPage
endCursor
startCursor
}
}
}
}
` as const;

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. Sanitize the page body with sanitizeHtml() in the loader.

File

import {useLoaderData} from 'react-router';
import type {Route} from './+types/($locale).pages.$handle';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

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 bodies are merchant-managed HTML: sanitize before rendering it raw.
page: {...page, body: sanitizeHtml(page.body)},
};
}

/**
* 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. Sanitize the policy body with sanitizeHtml() in the loader.

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';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

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

// Policy bodies are merchant-managed HTML: sanitize before rendering it raw.
return {policy: {...policy, body: sanitizeHtml(policy.body)}};
}

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
*/
export default function SearchPage() {
const {type, term, result, error} = useLoaderData<typeof loader>();
if (type === 'predictive') return null;

return (
<div className="search">
<h1>Search</h1>
<SearchForm>
{({inputRef}) => (
<>
<input
defaultValue={term}
name="q"
placeholder="Search…"
ref={inputRef}
type="search"
/>
&nbsp;
<button type="submit">Search</button>
</>
)}
</SearchForm>
{error && <p style={{color: 'red'}}>{error}</p>}
{!term || !result?.total ? (
<SearchResults.Empty />
) : (
<SearchResults result={result} term={term}>
{({articles, pages, products, term}) => (
<div>
<SearchResults.Products products={products} term={term} />
<SearchResults.Pages pages={pages} term={term} />
<SearchResults.Articles articles={articles} term={term} />
</div>
)}
</SearchResults>
)}
<Analytics.SearchView data={{searchTerm: term, searchResults: result}} />
</div>
);
}

/**
* Regular search query and fragments
* (adjust as needed)
*/
const SEARCH_PRODUCT_FRAGMENT = `#graphql
fragment SearchProduct on Product {
__typename
handle
id
publishedAt
title
trackingParameters
vendor
selectedOrFirstAvailableVariant(
selectedOptions: []
ignoreUnknownOptions: true
caseInsensitiveMatch: true
) {
id
image {
url
altText
width
height
}
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
selectedOptions {
name
value
}
product {
handle
title
}
}
}
` as const;

const SEARCH_PAGE_FRAGMENT = `#graphql
fragment SearchPage on Page {
__typename
handle
id
title
trackingParameters
}
` as const;

const SEARCH_ARTICLE_FRAGMENT = `#graphql
fragment SearchArticle on Article {
__typename
handle
id
title
trackingParameters
}
` as const;

const PAGE_INFO_FRAGMENT = `#graphql
fragment PageInfoFragment on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
` as const;

// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/search
export const SEARCH_QUERY = `#graphql
query RegularSearch(
$country: CountryCode
$endCursor: String
$first: Int
$language: LanguageCode
$last: Int
$term: String!
$startCursor: String
) @inContext(country: $country, language: $language) {
articles: search(
query: $term,
types: [ARTICLE],
first: $first,
) {
nodes {
...on Article {
...SearchArticle
}
}
}
pages: search(
query: $term,
types: [PAGE],
first: $first,
) {
nodes {
...on Page {
...SearchPage
}
}
}
products: search(
after: $endCursor,
before: $startCursor,
first: $first,
last: $last,
query: $term,
sortKey: RELEVANCE,
types: [PRODUCT],
unavailableProducts: HIDE,
) {
nodes {
...on Product {
...SearchProduct
}
}
pageInfo {
...PageInfoFragment
}
}
}
${SEARCH_PRODUCT_FRAGMENT}
${SEARCH_PAGE_FRAGMENT}
${SEARCH_ARTICLE_FRAGMENT}
${PAGE_INFO_FRAGMENT}
` as const;

/**
* Regular search fetcher
*/
async function regularSearch({
request,
context,
}: Pick<
Route.LoaderArgs,
'request' | 'context'
>): Promise<RegularSearchReturn> {
const {storefront} = context;
const url = new URL(request.url);
const variables = getPaginationVariables(request, {pageBy: 8});
const term = String(url.searchParams.get('q') || '');

// Search articles, pages, and products for the `q` term
const {errors, ...items}: {errors?: Array<{message: string}>} & RegularSearchQuery = await storefront.query(SEARCH_QUERY, {
variables: {...variables, term},
});

if (!items) {
throw new Error('No search data returned from Shopify API');
}

const total = Object.values(items).reduce(
(acc: number, {nodes}: {nodes: Array<unknown>}) => acc + nodes.length,
0,
);

const error = errors
? errors.map(({message}: {message: string}) => message).join(', ')
: undefined;

return {type: 'regular', term, error, result: {total, items}};
}

/**
* Predictive search query and fragments
* (adjust as needed)
*/
const PREDICTIVE_SEARCH_ARTICLE_FRAGMENT = `#graphql
fragment PredictiveArticle on Article {
__typename
id
title
handle
blog {
handle
}
image {
url
altText
width
height
}
trackingParameters
}
` as const;

const PREDICTIVE_SEARCH_COLLECTION_FRAGMENT = `#graphql
fragment PredictiveCollection on Collection {
__typename
id
title
handle
image {
url
altText
width
height
}
trackingParameters
}
` as const;

const PREDICTIVE_SEARCH_PAGE_FRAGMENT = `#graphql
fragment PredictivePage on Page {
__typename
id
title
handle
trackingParameters
}
` as const;

const PREDICTIVE_SEARCH_PRODUCT_FRAGMENT = `#graphql
fragment PredictiveProduct on Product {
__typename
id
title
handle
trackingParameters
selectedOrFirstAvailableVariant(
selectedOptions: []
ignoreUnknownOptions: true
caseInsensitiveMatch: true
) {
id
image {
url
altText
width
height
}
price {
amount
currencyCode
}
}
}
` as const;

const PREDICTIVE_SEARCH_QUERY_FRAGMENT = `#graphql
fragment PredictiveQuery on SearchQuerySuggestion {
__typename
text
styledText
trackingParameters
}
` as const;

// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/predictiveSearch
const PREDICTIVE_SEARCH_QUERY = `#graphql
query PredictiveSearch(
$country: CountryCode
$language: LanguageCode
$limit: Int!
$limitScope: PredictiveSearchLimitScope!
$term: String!
$types: [PredictiveSearchType!]
) @inContext(country: $country, language: $language) {
predictiveSearch(
limit: $limit,
limitScope: $limitScope,
query: $term,
types: $types,
) {
articles {
...PredictiveArticle
}
collections {
...PredictiveCollection
}
pages {
...PredictivePage
}
products {
...PredictiveProduct
}
queries {
...PredictiveQuery
}
}
}
${PREDICTIVE_SEARCH_ARTICLE_FRAGMENT}
${PREDICTIVE_SEARCH_COLLECTION_FRAGMENT}
${PREDICTIVE_SEARCH_PAGE_FRAGMENT}
${PREDICTIVE_SEARCH_PRODUCT_FRAGMENT}
${PREDICTIVE_SEARCH_QUERY_FRAGMENT}
` as const;

/**
* Predictive search fetcher
*/
async function predictiveSearch({
request,
context,
}: Pick<
Route.ActionArgs,
'request' | 'context'
>): Promise<PredictiveSearchReturn> {
const {storefront} = context;
const url = new URL(request.url);
const term = String(url.searchParams.get('q') || '').trim();
const limit = Number(url.searchParams.get('limit') || 10);
const type = 'predictive';

if (!term) return {type, term, result: getEmptyPredictiveSearchResult()};

// Predictively search articles, collections, pages, products, and queries (suggestions)
const {predictiveSearch: items, errors}: PredictiveSearchQuery & {errors?: Array<{message: string}>} = await storefront.query(
PREDICTIVE_SEARCH_QUERY,
{
variables: {
// customize search options as needed
limit,
limitScope: 'EACH',
term,
},
},
);

if (errors) {
throw new Error(
`Shopify API errors: ${errors.map(({message}: {message: string}) => message).join(', ')}`,
);
}

if (!items) {
throw new Error('No predictive search data returned from Shopify API');
}

const total = Object.values(items).reduce(
(acc: number, item: Array<unknown>) => acc + item.length,
0,
);

return {type, term, result: {items, total}};
}


  • 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?