@shopify/create-hydrogen 5.0.19 → 5.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,52 @@
1
1
  # skeleton
2
2
 
3
+ ## 2025.1.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix an issue with our starter template where duplicate content can exist on URLs that use internationalized handles. For example, if you have a product handle in english of `the-havoc` and translate it to `das-chaos` in German, duplicate content exists at both: ([#2821](https://github.com/Shopify/hydrogen/pull/2821)) by [@blittle](https://github.com/blittle)
8
+
9
+ 1. https://hydrogen.shop/de-de/products/das-chaos
10
+ 2. https://hydrogen.shop/de-de/products/the-havoc
11
+
12
+ We've changed the starter template to make the second redirect to the first.
13
+
14
+ - Added the Cursor rule for the subscriptions recipe. ([#2874](https://github.com/Shopify/hydrogen/pull/2874)) by [@ruggishop](https://github.com/ruggishop)
15
+
16
+ - Fix faulty truthiness check for cart quantity ([#2855](https://github.com/Shopify/hydrogen/pull/2855)) by [@frontsideair](https://github.com/frontsideair)
17
+
18
+ - Refactor ProductItem into a separate component ([#2872](https://github.com/Shopify/hydrogen/pull/2872)) by [@juanpprieto](https://github.com/juanpprieto)
19
+
20
+ - Updated dependencies [[`f80f3bc7`](https://github.com/Shopify/hydrogen/commit/f80f3bc7239b3ee6641cb468a17e15c77bb7815b), [`61ddf924`](https://github.com/Shopify/hydrogen/commit/61ddf92487524b3c04632ae2cfdaa2869a3ae02c), [`642bde4f`](https://github.com/Shopify/hydrogen/commit/642bde4f3df11511e125b013abd977618da25692)]:
21
+ - @shopify/hydrogen@2025.1.4
22
+
23
+ ## 2025.1.6
24
+
25
+ ### Patch Changes
26
+
27
+ - Moved the `Layout` component back into `root.tsx` to avoid issues with styled errors. ([#2829](https://github.com/Shopify/hydrogen/pull/2829)) by [@ruggishop](https://github.com/ruggishop)
28
+
29
+ 1. If you have a separate `app/layout.tsx` file, delete it and move its default exported component into your `root.tsx`. For example:
30
+
31
+ ```ts
32
+ // /app/root.tsx
33
+ export function Layout({children}: {children?: React.ReactNode}) {
34
+ const nonce = useNonce();
35
+ const data = useRouteLoaderData<RootLoader>('root');
36
+
37
+ return (
38
+ <html lang="en">
39
+ ...
40
+ );
41
+ }
42
+ ```
43
+
44
+ ## 2025.1.5
45
+
46
+ ### Patch Changes
47
+
48
+ - Fixed an issue with the creation of JavaScript projects. ([#2818](https://github.com/Shopify/hydrogen/pull/2818)) by [@seanparsons](https://github.com/seanparsons)
49
+
3
50
  ## 2025.1.4
4
51
 
5
52
  ### Patch Changes
@@ -33,7 +80,30 @@
33
80
 
34
81
  Please refer to the Remix documentation for more details on `v3_routeConfig` future flag: [https://remix.run/docs/en/main/start/future-flags#v3_routeconfig](https://remix.run/docs/en/main/start/future-flags#v3_routeconfig)
35
82
 
36
- 1. Add the following npm package dev dependencies:
83
+ 1. Update your `vite.config.ts`.
84
+
85
+ ```diff
86
+ export default defineConfig({
87
+ plugins: [
88
+ hydrogen(),
89
+ oxygen(),
90
+ remix({
91
+ - presets: [hydrogen.preset()],
92
+ + presets: [hydrogen.v3preset()],
93
+ future: {
94
+ v3_fetcherPersist: true,
95
+ v3_relativeSplatPath: true,
96
+ v3_throwAbortReason: true,
97
+ v3_lazyRouteDiscovery: true,
98
+ v3_singleFetch: true,
99
+ + v3_routeConfig: true,
100
+ },
101
+ }),
102
+ tsconfigPaths(),
103
+ ],
104
+ ```
105
+
106
+ 1. Update your `package.json` and install the new packages. Make sure to match the Remix version along with other Remix npm packages and ensure the versions are 2.16.1 or above:
37
107
 
38
108
  ```diff
39
109
  "devDependencies": {
@@ -42,22 +112,29 @@
42
112
  + "@remix-run/route-config": "^2.16.1",
43
113
  ```
44
114
 
45
- 1. If you have `export function Layout` in your `root.tsx`, move this export into its own file. For example:
115
+ 1. Move the `Layout` component export from `root.tsx` into its own file. Make sure to supply an `<Outlet>` so Remix knows where to inject your route content.
46
116
 
47
117
  ```ts
48
118
  // /app/layout.tsx
119
+ import {Outlet} from '@remix-run/react';
120
+
49
121
  export default function Layout() {
50
122
  const nonce = useNonce();
51
123
  const data = useRouteLoaderData<RootLoader>('root');
52
124
 
53
125
  return (
54
126
  <html lang="en">
55
- ...
127
+ ...
128
+ <Outlet />
129
+ ...
130
+ </html>
56
131
  );
57
132
  }
133
+
134
+ // Remember to remove the Layout export from your root.tsx
58
135
  ```
59
136
 
60
- 1. Create a `routes.ts` file.
137
+ 1. Add a routes.ts file. This is your new Remix route configuration file.
61
138
 
62
139
  ```ts
63
140
  import { flatRoutes } from "@remix-run/fs-routes";
@@ -70,18 +147,6 @@
70
147
  ]) satisfies RouteConfig;
71
148
  ```
72
149
 
73
- 1. Update your `vite.config.ts`.
74
-
75
- ```diff
76
- export default defineConfig({
77
- plugins: [
78
- hydrogen(),
79
- oxygen(),
80
- remix({
81
- - presets: [hydrogen.preset()],
82
- + presets: [hydrogen.v3preset()],
83
- ```
84
-
85
150
  - Updated dependencies [[`0425e50d`](https://github.com/Shopify/hydrogen/commit/0425e50dafe2f42326cba67076e5fcea2905e885), [`74ef1ba7`](https://github.com/Shopify/hydrogen/commit/74ef1ba7d41988350e9d2c81731c90381943d1f0)]:
86
151
  - @shopify/remix-oxygen@2.0.12
87
152
  - @shopify/hydrogen@2025.1.3
@@ -123,6 +123,7 @@ function CartLineRemoveButton({
123
123
  }) {
124
124
  return (
125
125
  <CartForm
126
+ fetcherKey={getUpdateKey(lineIds)}
126
127
  route="/cart"
127
128
  action={CartForm.ACTIONS.LinesRemove}
128
129
  inputs={{lineIds}}
@@ -141,8 +142,11 @@ function CartLineUpdateButton({
141
142
  children: React.ReactNode;
142
143
  lines: CartLineUpdateInput[];
143
144
  }) {
145
+ const lineIds = lines.map((line) => line.id);
146
+
144
147
  return (
145
148
  <CartForm
149
+ fetcherKey={getUpdateKey(lineIds)}
146
150
  route="/cart"
147
151
  action={CartForm.ACTIONS.LinesUpdate}
148
152
  inputs={{lines}}
@@ -151,3 +155,14 @@ function CartLineUpdateButton({
151
155
  </CartForm>
152
156
  );
153
157
  }
158
+
159
+ /**
160
+ * Returns a unique key for the update action. This is used to make sure actions modifying the same line
161
+ * items are not run concurrently, but cancel each other. For example, if the user clicks "Increase quantity"
162
+ * and "Decrease quantity" in rapid succession, the actions will cancel each other and only the last one will run.
163
+ * @param lineIds - line ids affected by the update
164
+ * @returns
165
+ */
166
+ function getUpdateKey(lineIds: string[]) {
167
+ return [CartForm.ACTIONS.LinesUpdate, ...lineIds].join('-');
168
+ }
@@ -26,7 +26,7 @@ export function CartMain({layout, cart: originalCart}: CartMainProps) {
26
26
  cart &&
27
27
  Boolean(cart?.discountCodes?.filter((code) => code.applicable)?.length);
28
28
  const className = `cart-main ${withDiscount ? 'with-discount' : ''}`;
29
- const cartHasItems = cart?.totalQuantity && cart?.totalQuantity > 0;
29
+ const cartHasItems = cart?.totalQuantity ? cart.totalQuantity > 0 : false;
30
30
 
31
31
  return (
32
32
  <div className={className}>
@@ -0,0 +1,44 @@
1
+ import {Link} from '@remix-run/react';
2
+ import {Image, Money} from '@shopify/hydrogen';
3
+ import type {
4
+ ProductItemFragment,
5
+ CollectionItemFragment,
6
+ RecommendedProductFragment,
7
+ } from 'storefrontapi.generated';
8
+ import {useVariantUrl} from '~/lib/variants';
9
+
10
+ export function ProductItem({
11
+ product,
12
+ loading,
13
+ }: {
14
+ product:
15
+ | CollectionItemFragment
16
+ | ProductItemFragment
17
+ | RecommendedProductFragment;
18
+ loading?: 'eager' | 'lazy';
19
+ }) {
20
+ const variantUrl = useVariantUrl(product.handle);
21
+ const image = product.featuredImage;
22
+ return (
23
+ <Link
24
+ className="product-item"
25
+ key={product.id}
26
+ prefetch="intent"
27
+ to={variantUrl}
28
+ >
29
+ {image && (
30
+ <Image
31
+ alt={image.altText || product.title}
32
+ aspectRatio="1/1"
33
+ data={image}
34
+ loading={loading}
35
+ sizes="(min-width: 45em) 400px, 100vw"
36
+ />
37
+ )}
38
+ <h4>{product.title}</h4>
39
+ <small>
40
+ <Money data={product.priceRange.minVariantPrice} />
41
+ </small>
42
+ </Link>
43
+ );
44
+ }
@@ -0,0 +1,23 @@
1
+ import {redirect} from '@shopify/remix-oxygen';
2
+
3
+ export function redirectIfHandleIsLocalized(
4
+ request: Request,
5
+ ...localizedResources: Array<{
6
+ handle: string;
7
+ data: {handle: string} & unknown;
8
+ }>
9
+ ) {
10
+ const url = new URL(request.url);
11
+ let shouldRedirect = false;
12
+
13
+ localizedResources.forEach(({handle, data}) => {
14
+ if (handle !== data.handle) {
15
+ url.pathname = url.pathname.replace(handle, data.handle);
16
+ shouldRedirect = true;
17
+ }
18
+ });
19
+
20
+ if (shouldRedirect) {
21
+ throw redirect(url.toString());
22
+ }
23
+ }
@@ -1,13 +1,21 @@
1
- import {getShopAnalytics} from '@shopify/hydrogen';
1
+ import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen';
2
2
  import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
3
3
  import {
4
4
  Outlet,
5
5
  useRouteError,
6
6
  isRouteErrorResponse,
7
7
  type ShouldRevalidateFunction,
8
+ Links,
9
+ Meta,
10
+ Scripts,
11
+ ScrollRestoration,
12
+ useRouteLoaderData,
8
13
  } from '@remix-run/react';
9
14
  import favicon from '~/assets/favicon.svg';
10
15
  import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments';
16
+ import resetStyles from '~/styles/reset.css?url';
17
+ import appStyles from '~/styles/app.css?url';
18
+ import {PageLayout} from './components/PageLayout';
11
19
 
12
20
  export type RootLoader = typeof loader;
13
21
 
@@ -25,9 +33,9 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
25
33
  // revalidate when manually revalidating via useRevalidator
26
34
  if (currentUrl.toString() === nextUrl.toString()) return true;
27
35
 
28
- // Defaulting to no revalidation for root loader data to improve performance.
29
- // When using this feature, you risk your UI getting out of sync with your server.
30
- // Use with caution. If you are uncomfortable with this optimization, update the
36
+ // Defaulting to no revalidation for root loader data to improve performance.
37
+ // When using this feature, you risk your UI getting out of sync with your server.
38
+ // Use with caution. If you are uncomfortable with this optimization, update the
31
39
  // line below to `return defaultShouldRevalidate` instead.
32
40
  // For more details see: https://remix.run/docs/en/main/route/should-revalidate
33
41
  return false;
@@ -133,6 +141,39 @@ function loadDeferredData({context}: LoaderFunctionArgs) {
133
141
  };
134
142
  }
135
143
 
144
+ export function Layout({children}: {children?: React.ReactNode}) {
145
+ const nonce = useNonce();
146
+ const data = useRouteLoaderData<RootLoader>('root');
147
+
148
+ return (
149
+ <html lang="en">
150
+ <head>
151
+ <meta charSet="utf-8" />
152
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
153
+ <link rel="stylesheet" href={resetStyles}></link>
154
+ <link rel="stylesheet" href={appStyles}></link>
155
+ <Meta />
156
+ <Links />
157
+ </head>
158
+ <body>
159
+ {data ? (
160
+ <Analytics.Provider
161
+ cart={data.cart}
162
+ shop={data.shop}
163
+ consent={data.consent}
164
+ >
165
+ <PageLayout {...data}>{children}</PageLayout>
166
+ </Analytics.Provider>
167
+ ) : (
168
+ children
169
+ )}
170
+ <ScrollRestoration nonce={nonce} />
171
+ <Scripts nonce={nonce} />
172
+ </body>
173
+ </html>
174
+ );
175
+ }
176
+
136
177
  export default function App() {
137
178
  return <Outlet />;
138
179
  }
@@ -6,6 +6,7 @@ import type {
6
6
  FeaturedCollectionFragment,
7
7
  RecommendedProductsQuery,
8
8
  } from 'storefrontapi.generated';
9
+ import {ProductItem} from '~/components/ProductItem';
9
10
 
10
11
  export const meta: MetaFunction = () => {
11
12
  return [{title: 'Hydrogen | Home'}];
@@ -101,21 +102,7 @@ function RecommendedProducts({
101
102
  <div className="recommended-products-grid">
102
103
  {response
103
104
  ? response.products.nodes.map((product) => (
104
- <Link
105
- key={product.id}
106
- className="recommended-product"
107
- to={`/products/${product.handle}`}
108
- >
109
- <Image
110
- data={product.images.nodes[0]}
111
- aspectRatio="1/1"
112
- sizes="(min-width: 45em) 20vw, 50vw"
113
- />
114
- <h4>{product.title}</h4>
115
- <small>
116
- <Money data={product.priceRange.minVariantPrice} />
117
- </small>
118
- </Link>
105
+ <ProductItem key={product.id} product={product} />
119
106
  ))
120
107
  : null}
121
108
  </div>
@@ -161,14 +148,12 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
161
148
  currencyCode
162
149
  }
163
150
  }
164
- images(first: 1) {
165
- nodes {
166
- id
167
- url
168
- altText
169
- width
170
- height
171
- }
151
+ featuredImage {
152
+ id
153
+ url
154
+ altText
155
+ width
156
+ height
172
157
  }
173
158
  }
174
159
  query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
@@ -1,6 +1,7 @@
1
1
  import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2
2
  import {useLoaderData, type MetaFunction} from '@remix-run/react';
3
3
  import {Image} from '@shopify/hydrogen';
4
+ import {redirectIfHandleIsLocalized} from '~/lib/redirect';
4
5
 
5
6
  export const meta: MetaFunction<typeof loader> = ({data}) => {
6
7
  return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
@@ -20,7 +21,11 @@ export async function loader(args: LoaderFunctionArgs) {
20
21
  * Load data necessary for rendering content above the fold. This is the critical data
21
22
  * needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
22
23
  */
23
- async function loadCriticalData({context, params}: LoaderFunctionArgs) {
24
+ async function loadCriticalData({
25
+ context,
26
+ request,
27
+ params,
28
+ }: LoaderFunctionArgs) {
24
29
  const {blogHandle, articleHandle} = params;
25
30
 
26
31
  if (!articleHandle || !blogHandle) {
@@ -38,6 +43,18 @@ async function loadCriticalData({context, params}: LoaderFunctionArgs) {
38
43
  throw new Response(null, {status: 404});
39
44
  }
40
45
 
46
+ redirectIfHandleIsLocalized(
47
+ request,
48
+ {
49
+ handle: articleHandle,
50
+ data: blog.articleByHandle,
51
+ },
52
+ {
53
+ handle: blogHandle,
54
+ data: blog,
55
+ },
56
+ );
57
+
41
58
  const article = blog.articleByHandle;
42
59
 
43
60
  return {article};
@@ -67,7 +84,8 @@ export default function Article() {
67
84
  <h1>
68
85
  {title}
69
86
  <div>
70
- {publishedDate} &middot; {author?.name}
87
+ <time dateTime={article.publishedAt}>{publishedDate}</time> &middot;{' '}
88
+ <address>{author?.name}</address>
71
89
  </div>
72
90
  </h1>
73
91
 
@@ -89,7 +107,9 @@ const ARTICLE_QUERY = `#graphql
89
107
  $language: LanguageCode
90
108
  ) @inContext(language: $language, country: $country) {
91
109
  blog(handle: $blogHandle) {
110
+ handle
92
111
  articleByHandle(handle: $articleHandle) {
112
+ handle
93
113
  title
94
114
  contentHtml
95
115
  publishedAt
@@ -3,6 +3,7 @@ import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
3
3
  import {Image, getPaginationVariables} from '@shopify/hydrogen';
4
4
  import type {ArticleItemFragment} from 'storefrontapi.generated';
5
5
  import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
6
+ import {redirectIfHandleIsLocalized} from '~/lib/redirect';
6
7
 
7
8
  export const meta: MetaFunction<typeof loader> = ({data}) => {
8
9
  return [{title: `Hydrogen | ${data?.blog.title ?? ''} blog`}];
@@ -49,6 +50,8 @@ async function loadCriticalData({
49
50
  throw new Response('Not found', {status: 404});
50
51
  }
51
52
 
53
+ redirectIfHandleIsLocalized(request, {handle: params.blogHandle, data: blog});
54
+
52
55
  return {blog};
53
56
  }
54
57
 
@@ -128,6 +131,7 @@ const BLOGS_QUERY = `#graphql
128
131
  ) @inContext(language: $language) {
129
132
  blog(handle: $blogHandle) {
130
133
  title
134
+ handle
131
135
  seo {
132
136
  title
133
137
  description
@@ -1,14 +1,9 @@
1
1
  import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2
- import {useLoaderData, Link, type MetaFunction} from '@remix-run/react';
3
- import {
4
- getPaginationVariables,
5
- Image,
6
- Money,
7
- Analytics,
8
- } from '@shopify/hydrogen';
9
- import type {ProductItemFragment} from 'storefrontapi.generated';
10
- import {useVariantUrl} from '~/lib/variants';
2
+ import {useLoaderData, type MetaFunction} from '@remix-run/react';
3
+ import {getPaginationVariables, Analytics} from '@shopify/hydrogen';
11
4
  import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
5
+ import {redirectIfHandleIsLocalized} from '~/lib/redirect';
6
+ import {ProductItem} from '~/components/ProductItem';
12
7
 
13
8
  export const meta: MetaFunction<typeof loader> = ({data}) => {
14
9
  return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}];
@@ -56,6 +51,9 @@ async function loadCriticalData({
56
51
  });
57
52
  }
58
53
 
54
+ // The API handle might be localized, so redirect to the localized handle
55
+ redirectIfHandleIsLocalized(request, {handle, data: collection});
56
+
59
57
  return {
60
58
  collection,
61
59
  };
@@ -101,38 +99,6 @@ export default function Collection() {
101
99
  );
102
100
  }
103
101
 
104
- function ProductItem({
105
- product,
106
- loading,
107
- }: {
108
- product: ProductItemFragment;
109
- loading?: 'eager' | 'lazy';
110
- }) {
111
- const variantUrl = useVariantUrl(product.handle);
112
- return (
113
- <Link
114
- className="product-item"
115
- key={product.id}
116
- prefetch="intent"
117
- to={variantUrl}
118
- >
119
- {product.featuredImage && (
120
- <Image
121
- alt={product.featuredImage.altText || product.title}
122
- aspectRatio="1/1"
123
- data={product.featuredImage}
124
- loading={loading}
125
- sizes="(min-width: 45em) 400px, 100vw"
126
- />
127
- )}
128
- <h4>{product.title}</h4>
129
- <small>
130
- <Money data={product.priceRange.minVariantPrice} />
131
- </small>
132
- </Link>
133
- );
134
- }
135
-
136
102
  const PRODUCT_ITEM_FRAGMENT = `#graphql
137
103
  fragment MoneyProductItem on MoneyV2 {
138
104
  amount
@@ -1,9 +1,8 @@
1
1
  import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2
- import {useLoaderData, Link, type MetaFunction} from '@remix-run/react';
3
- import {getPaginationVariables, Image, Money} from '@shopify/hydrogen';
4
- import type {ProductItemFragment} from 'storefrontapi.generated';
5
- import {useVariantUrl} from '~/lib/variants';
2
+ import {useLoaderData, type MetaFunction} from '@remix-run/react';
3
+ import {getPaginationVariables} from '@shopify/hydrogen';
6
4
  import {PaginatedResourceSection} from '~/components/PaginatedResourceSection';
5
+ import {ProductItem} from '~/components/ProductItem';
7
6
 
8
7
  export const meta: MetaFunction<typeof loader> = () => {
9
8
  return [{title: `Hydrogen | Products`}];
@@ -69,44 +68,12 @@ export default function Collection() {
69
68
  );
70
69
  }
71
70
 
72
- function ProductItem({
73
- product,
74
- loading,
75
- }: {
76
- product: ProductItemFragment;
77
- loading?: 'eager' | 'lazy';
78
- }) {
79
- const variantUrl = useVariantUrl(product.handle);
80
- return (
81
- <Link
82
- className="product-item"
83
- key={product.id}
84
- prefetch="intent"
85
- to={variantUrl}
86
- >
87
- {product.featuredImage && (
88
- <Image
89
- alt={product.featuredImage.altText || product.title}
90
- aspectRatio="1/1"
91
- data={product.featuredImage}
92
- loading={loading}
93
- sizes="(min-width: 45em) 400px, 100vw"
94
- />
95
- )}
96
- <h4>{product.title}</h4>
97
- <small>
98
- <Money data={product.priceRange.minVariantPrice} />
99
- </small>
100
- </Link>
101
- );
102
- }
103
-
104
- const PRODUCT_ITEM_FRAGMENT = `#graphql
105
- fragment MoneyProductItem on MoneyV2 {
71
+ const COLLECTION_ITEM_FRAGMENT = `#graphql
72
+ fragment MoneyCollectionItem on MoneyV2 {
106
73
  amount
107
74
  currencyCode
108
75
  }
109
- fragment ProductItem on Product {
76
+ fragment CollectionItem on Product {
110
77
  id
111
78
  handle
112
79
  title
@@ -119,16 +86,16 @@ const PRODUCT_ITEM_FRAGMENT = `#graphql
119
86
  }
120
87
  priceRange {
121
88
  minVariantPrice {
122
- ...MoneyProductItem
89
+ ...MoneyCollectionItem
123
90
  }
124
91
  maxVariantPrice {
125
- ...MoneyProductItem
92
+ ...MoneyCollectionItem
126
93
  }
127
94
  }
128
95
  }
129
96
  ` as const;
130
97
 
131
- // NOTE: https://shopify.dev/docs/api/storefront/2024-01/objects/product
98
+ // NOTE: https://shopify.dev/docs/api/storefront/latest/objects/product
132
99
  const CATALOG_QUERY = `#graphql
133
100
  query Catalog(
134
101
  $country: CountryCode
@@ -140,7 +107,7 @@ const CATALOG_QUERY = `#graphql
140
107
  ) @inContext(country: $country, language: $language) {
141
108
  products(first: $first, last: $last, before: $startCursor, after: $endCursor) {
142
109
  nodes {
143
- ...ProductItem
110
+ ...CollectionItem
144
111
  }
145
112
  pageInfo {
146
113
  hasPreviousPage
@@ -150,5 +117,5 @@ const CATALOG_QUERY = `#graphql
150
117
  }
151
118
  }
152
119
  }
153
- ${PRODUCT_ITEM_FRAGMENT}
120
+ ${COLLECTION_ITEM_FRAGMENT}
154
121
  ` as const;
@@ -1,5 +1,6 @@
1
1
  import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2
2
  import {useLoaderData, type MetaFunction} from '@remix-run/react';
3
+ import {redirectIfHandleIsLocalized} from '~/lib/redirect';
3
4
 
4
5
  export const meta: MetaFunction<typeof loader> = ({data}) => {
5
6
  return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
@@ -19,7 +20,11 @@ export async function loader(args: LoaderFunctionArgs) {
19
20
  * Load data necessary for rendering content above the fold. This is the critical data
20
21
  * needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
21
22
  */
22
- async function loadCriticalData({context, params}: LoaderFunctionArgs) {
23
+ async function loadCriticalData({
24
+ context,
25
+ request,
26
+ params,
27
+ }: LoaderFunctionArgs) {
23
28
  if (!params.handle) {
24
29
  throw new Error('Missing page handle');
25
30
  }
@@ -37,6 +42,8 @@ async function loadCriticalData({context, params}: LoaderFunctionArgs) {
37
42
  throw new Response('Not Found', {status: 404});
38
43
  }
39
44
 
45
+ redirectIfHandleIsLocalized(request, {handle: params.handle, data: page});
46
+
40
47
  return {
41
48
  page,
42
49
  };
@@ -72,6 +79,7 @@ const PAGE_QUERY = `#graphql
72
79
  )
73
80
  @inContext(language: $language, country: $country) {
74
81
  page(handle: $handle) {
82
+ handle
75
83
  id
76
84
  title
77
85
  body
@@ -1,4 +1,4 @@
1
- import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
1
+ import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
2
2
  import {useLoaderData, type MetaFunction} from '@remix-run/react';
3
3
  import {
4
4
  getSelectedProductOptions,
@@ -11,6 +11,7 @@ import {
11
11
  import {ProductPrice} from '~/components/ProductPrice';
12
12
  import {ProductImage} from '~/components/ProductImage';
13
13
  import {ProductForm} from '~/components/ProductForm';
14
+ import {redirectIfHandleIsLocalized} from '~/lib/redirect';
14
15
 
15
16
  export const meta: MetaFunction<typeof loader> = ({data}) => {
16
17
  return [
@@ -59,6 +60,9 @@ async function loadCriticalData({
59
60
  throw new Response(null, {status: 404});
60
61
  }
61
62
 
63
+ // The API handle might be localized, so redirect to the localized handle
64
+ redirectIfHandleIsLocalized(request, {handle, data: product});
65
+
62
66
  return {
63
67
  product,
64
68
  };