From c366ab9fe72f67017a8d8b2804567f0890bdfcc1 Mon Sep 17 00:00:00 2001 From: freddie Date: Fri, 19 Jun 2026 17:06:37 +0100 Subject: [PATCH 1/8] feat(hydrogen): support React Router 8 Update Hydrogen, CLI, skeleton, and cookbook surfaces for React Router 8's React 19.2.7, Node 22.22, Vite 7+, ESM-only, and no react-router-dom baseline. Remove obsolete v8 future flags, move split route modules to the stable config surface, migrate meta data access to loaderData, and update CLI build output detection for RR8's server bundle layout. Also refresh cookbook recipes and docs so generated examples follow the RR8 dependency and context.get(hydrogenContext.*) patterns. Assisted-By: devx/c578cdc2-d220-4587-bb29-636977eb6de8 --- .changeset/react-router-8-support.md | 14 + .claude/commands/changelog-update.md | 32 +- .docs/dependencies.md | 23 + cookbook/llms/b2b.prompt.md | 164 +- cookbook/llms/bundles.prompt.md | 134 +- cookbook/llms/combined-listings.prompt.md | 139 +- cookbook/llms/custom-cart-method.prompt.md | 125 +- cookbook/llms/express.prompt.md | 255 +-- cookbook/llms/gtm.prompt.md | 101 +- cookbook/llms/infinite-scroll.prompt.md | 63 +- .../legacy-customer-account-flow.prompt.md | 309 ++-- cookbook/llms/markets.prompt.md | 587 +++---- cookbook/llms/metaobjects.prompt.md | 226 ++- cookbook/llms/multipass.prompt.md | 352 ++-- cookbook/llms/partytown.prompt.md | 85 +- cookbook/llms/subscriptions.prompt.md | 138 +- cookbook/llms/third-party-api.prompt.md | 136 +- cookbook/recipes/b2b/README.md | 183 +-- .../b2b/patches/package.json.8e0ff5.patch | 39 - .../patches/products.$handle.tsx.30ba8d.patch | 11 +- cookbook/recipes/b2b/recipe.yaml | 97 +- cookbook/recipes/bundles/README.md | 140 +- .../bundles/patches/package.json.8e0ff5.patch | 39 - .../patches/products.$handle.tsx.30ba8d.patch | 2 +- cookbook/recipes/bundles/recipe.yaml | 46 +- cookbook/recipes/combined-listings/README.md | 143 +- .../collections.$handle.tsx.7abca5.patch | 4 +- .../patches/package.json.8e0ff5.patch | 39 - .../patches/products.$handle.tsx.30ba8d.patch | 6 +- .../recipes/combined-listings/recipe.yaml | 45 +- cookbook/recipes/custom-cart-method/README.md | 122 +- .../patches/README.md.1764cd.patch | 6 +- .../patches/package.json.8e0ff5.patch | 39 - .../recipes/custom-cart-method/recipe.yaml | 23 +- cookbook/recipes/express/README.md | 493 +++--- .../express/patches/README.md.a0354d.patch | 12 +- .../express/patches/package.json.acbf33.patch | 23 +- .../patches/products.$handle.tsx.2292d3.patch | 12 +- cookbook/recipes/express/recipe.yaml | 4 +- cookbook/recipes/gtm/README.md | 106 +- .../gtm/patches/README.md.1764cd.patch | 6 +- .../gtm/patches/package.json.8e0ff5.patch | 39 - cookbook/recipes/gtm/recipe.yaml | 24 +- cookbook/recipes/infinite-scroll/README.md | 61 +- .../patches/README.md.db10ed.patch | 8 +- .../collections.$handle.tsx.f062a9.patch | 2 +- .../patches/package.json.acbf33.patch | 6 +- .../legacy-customer-account-flow/README.md | 313 ++-- .../patches/README.md.1764cd.patch | 8 +- .../account.orders.$id.tsx.767824.patch | 4 +- .../patches/package.json.8e0ff5.patch | 39 - .../legacy-customer-account-flow/recipe.yaml | 48 +- cookbook/recipes/markets/README.md | 647 ++++---- .../skeleton/app/components/Link.tsx | 30 +- .../skeleton/app/routes/($locale)._index.tsx | 7 +- .../app/routes/($locale).account.$.tsx | 3 +- .../routes/($locale).account.addresses.tsx | 3 +- .../routes/($locale).account.orders.$id.tsx | 13 +- .../($locale).account.orders._index.tsx | 6 +- .../app/routes/($locale).account.profile.tsx | 3 +- .../skeleton/app/routes/($locale).account.tsx | 19 +- .../routes/($locale).account_.authorize.tsx | 3 +- .../app/routes/($locale).account_.login.tsx | 5 +- .../app/routes/($locale).account_.logout.tsx | 3 +- ...cale).blogs.$blogHandle.$articleHandle.tsx | 12 +- .../($locale).blogs.$blogHandle._index.tsx | 19 +- .../app/routes/($locale).blogs._index.tsx | 4 +- .../skeleton/app/routes/($locale).cart.tsx | 6 +- .../routes/($locale).collections.$handle.tsx | 15 +- .../routes/($locale).collections._index.tsx | 8 +- .../app/routes/($locale).collections.all.tsx | 4 +- .../app/routes/($locale).pages.$handle.tsx | 19 +- .../app/routes/($locale).policies.$handle.tsx | 31 +- .../app/routes/($locale).policies._index.tsx | 7 +- .../app/routes/($locale).products.$handle.tsx | 3 +- .../skeleton/app/routes/($locale).search.tsx | 30 +- .../markets/patches/context.ts.c15041.patch | 6 +- .../markets/patches/package.json.8e0ff5.patch | 39 - .../markets/patches/root.tsx.8f1e92.patch | 6 +- cookbook/recipes/markets/recipe.yaml | 105 +- cookbook/recipes/metaobjects/README.md | 288 ++-- .../patches/README.md.a0354d.patch | 8 +- .../patches/package.json.acbf33.patch | 10 +- cookbook/recipes/multipass/README.md | 363 +++-- .../multipass/patches/README.md.1764cd.patch | 12 +- .../account.orders.$id.tsx.767824.patch | 4 +- cookbook/recipes/multipass/recipe.yaml | 67 +- cookbook/recipes/partytown/README.md | 137 +- .../partytown/patches/README.md.1764cd.patch | 8 +- cookbook/recipes/partytown/recipe.yaml | 22 +- cookbook/recipes/subscriptions/README.md | 154 +- .../patches/package.json.8e0ff5.patch | 39 - .../patches/products.$handle.tsx.30ba8d.patch | 8 +- cookbook/recipes/subscriptions/recipe.yaml | 48 +- cookbook/recipes/third-party-api/README.md | 153 +- .../patches/README.md.1764cd.patch | 6 +- .../patches/context.ts.c15041.patch | 4 +- .../patches/package.json.8e0ff5.patch | 39 - cookbook/recipes/third-party-api/recipe.yaml | 18 +- docs/preview/package.json | 13 +- .../ts/app/routes/collections.$handle.tsx | 6 +- .../ts/app/routes/products.$handle.tsx | 10 +- package.json | 13 +- packages/cli/package.json | 8 +- packages/cli/src/commands/hydrogen/build.ts | 11 +- .../cli/src/commands/hydrogen/upgrade.test.ts | 51 + packages/cli/src/commands/hydrogen/upgrade.ts | 33 +- .../cli/src/lib/setups/i18n/replacers.test.ts | 9 +- .../cli/src/lib/setups/routes/generate.ts | 2 +- packages/cli/src/lib/vite-config.ts | 19 +- packages/hydrogen-react/package.json | 3 +- .../hydrogen-react/src/AddToCartButton.tsx | 1 + packages/hydrogen-react/src/BaseButton.tsx | 1 + packages/hydrogen-react/src/BuyNowButton.tsx | 1 + .../hydrogen-react/src/CartCheckoutButton.tsx | 1 + packages/hydrogen-react/src/CartCost.tsx | 1 + .../hydrogen-react/src/CartLineProvider.tsx | 1 + .../hydrogen-react/src/CartLineQuantity.tsx | 1 + .../src/CartLineQuantityAdjustButton.tsx | 1 + packages/hydrogen-react/src/CartProvider.tsx | 1 + packages/hydrogen-react/src/ExternalVideo.tsx | 1 + packages/hydrogen-react/src/MediaFile.tsx | 1 + packages/hydrogen-react/src/ModelViewer.tsx | 29 +- packages/hydrogen-react/src/Money.tsx | 1 + packages/hydrogen-react/src/ProductPrice.tsx | 1 + .../hydrogen-react/src/ProductProvider.tsx | 1 + packages/hydrogen-react/src/RichText.tsx | 1 + packages/hydrogen-react/src/ShopPayButton.tsx | 3 +- .../hydrogen-react/src/ShopifyProvider.tsx | 1 + packages/hydrogen-react/src/Video.tsx | 1 + packages/hydrogen-react/src/useMachine.ts | 8 +- packages/hydrogen/README.md | 2 +- packages/hydrogen/package.json | 18 +- packages/hydrogen/react-router.d.ts | 14 +- .../AnalyticsProvider.test.tsx | 1 + .../analytics-manager/AnalyticsProvider.tsx | 1 + .../src/cache/InMemoryCache.example.js | 13 +- packages/hydrogen/src/cart/CartForm.test.tsx | 1 + packages/hydrogen/src/cart/CartForm.tsx | 1 + .../createHydrogenContext-middleware.test.ts | 5 +- packages/hydrogen/src/csp/Script.tsx | 1 + packages/hydrogen/src/exports.test.ts | 2 +- packages/hydrogen/src/product/test-helpers.ts | 28 +- .../hydrogen/src/react-router-preset.test.ts | 44 +- packages/hydrogen/src/react-router-preset.ts | 55 +- .../hydrogen/src/seo/getSeoMeta.example.tsx | 4 +- packages/hydrogen/src/seo/seo.test.ts | 143 +- packages/hydrogen/src/seo/seo.ts | 8 +- .../components/FlameChartWrapper.tsx | 4 +- .../components/RequestDetails.tsx | 4 +- packages/hydrogen/tsup.config.ts | 6 +- packages/remix-oxygen/package.json | 7 +- packages/remix-oxygen/src/index.ts | 2 - packages/remix-oxygen/src/server.ts | 14 +- packages/remix-oxygen/tsup.config.ts | 6 +- pnpm-lock.yaml | 1430 +++++++++++------ pnpm-workspace.yaml | 8 +- templates/TEMPLATE_GUIDELINES.md | 18 +- .../.cursor/rules/hydrogen-react-router.mdc | 6 +- templates/skeleton/README.md | 8 +- .../components/PaginatedResourceSection.tsx | 2 +- .../skeleton/app/components/SearchForm.tsx | 20 +- templates/skeleton/app/lib/context.ts | 9 +- templates/skeleton/app/lib/redirect.ts | 3 +- templates/skeleton/app/root.tsx | 24 +- .../skeleton/app/routes/[sitemap.xml].tsx | 9 +- templates/skeleton/app/routes/_index.tsx | 9 +- templates/skeleton/app/routes/account.$.tsx | 3 +- .../skeleton/app/routes/account.addresses.tsx | 5 +- .../app/routes/account.orders.$id.tsx | 18 +- .../app/routes/account.orders._index.tsx | 3 +- .../skeleton/app/routes/account.profile.tsx | 5 +- templates/skeleton/app/routes/account.tsx | 3 +- .../app/routes/account_.authorize.tsx | 3 +- .../skeleton/app/routes/account_.login.tsx | 5 +- .../skeleton/app/routes/account_.logout.tsx | 3 +- .../blogs.$blogHandle.$articleHandle.tsx | 12 +- .../app/routes/blogs.$blogHandle._index.tsx | 21 +- .../skeleton/app/routes/blogs._index.tsx | 4 +- templates/skeleton/app/routes/cart.$lines.tsx | 3 +- templates/skeleton/app/routes/cart.tsx | 15 +- .../app/routes/collections.$handle.tsx | 23 +- .../app/routes/collections._index.tsx | 8 +- .../skeleton/app/routes/collections.all.tsx | 9 +- .../skeleton/app/routes/discount.$code.tsx | 3 +- .../skeleton/app/routes/pages.$handle.tsx | 11 +- .../skeleton/app/routes/policies.$handle.tsx | 27 +- .../skeleton/app/routes/policies._index.tsx | 5 +- .../skeleton/app/routes/products.$handle.tsx | 18 +- templates/skeleton/app/routes/search.tsx | 10 +- .../app/routes/sitemap.$type.$page[.xml].tsx | 10 +- templates/skeleton/package.json | 9 +- templates/skeleton/react-router.config.ts | 2 +- 193 files changed, 4637 insertions(+), 5285 deletions(-) create mode 100644 .changeset/react-router-8-support.md create mode 100644 .docs/dependencies.md delete mode 100644 cookbook/recipes/b2b/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/bundles/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/combined-listings/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/custom-cart-method/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/gtm/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/legacy-customer-account-flow/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/markets/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/subscriptions/patches/package.json.8e0ff5.patch delete mode 100644 cookbook/recipes/third-party-api/patches/package.json.8e0ff5.patch diff --git a/.changeset/react-router-8-support.md b/.changeset/react-router-8-support.md new file mode 100644 index 0000000000..5033fc0527 --- /dev/null +++ b/.changeset/react-router-8-support.md @@ -0,0 +1,14 @@ +--- +'@shopify/hydrogen': major +'@shopify/cli-hydrogen': major +'@shopify/remix-oxygen': major +'skeleton': major +'@shopify/create-hydrogen': patch +'@shopify/hydrogen-react': patch +--- + +Support React Router 8 in Hydrogen. + +New Hydrogen projects now use React Router 8, React 19.2.7, and Node 22.22.0 or newer. The Hydrogen preset emits React Router 8-compatible configuration, the scaffold no longer installs `react-router-dom`, and route `meta` exports use `loaderData` instead of the removed `data` parameter. + +Hydrogen and `@shopify/remix-oxygen` now publish ESM entrypoints only because React Router 8 is ESM-only. If your app still depends on CommonJS `require()` entrypoints for these packages, migrate those imports to ESM before upgrading. diff --git a/.claude/commands/changelog-update.md b/.claude/commands/changelog-update.md index 5b914e09e5..2a4d56fcd2 100644 --- a/.claude/commands/changelog-update.md +++ b/.claude/commands/changelog-update.md @@ -286,7 +286,7 @@ For packages that need to be removed during migration (like Remix → React Rout #### Framework Package Types (include only if changed in skeleton) - `@shopify/hydrogen` (usually changes with every major release) -- React Router packages (`react-router`, `react-router-dom`, `@react-router/*`) +- React Router packages (`react-router`, `@react-router/*`) - `@shopify/cli` (when CLI is updated) - `@shopify/mini-oxygen` (when dev server is updated) - `@shopify/hydrogen-codegen`, `@shopify/oxygen-workers-types` @@ -298,6 +298,8 @@ For packages that need to be removed during migration (like Remix → React Rout - Most eslint, typescript, prettier packages - User tooling preferences - Application-specific dependencies +**Exception**: Include `react`, `react-dom`, and React type packages when a framework dependency raises its minimum React baseline. For example, React Router 8 requires React 19.2.7+, so the changelog entry for that migration must update React packages or merchant upgrades can leave an unsupported React 18 install behind. + #### Comprehensive Dependency Analysis Example ```bash @@ -309,23 +311,25 @@ git diff PREVIOUS_RELEASE_HASH:templates/skeleton/package.json CURRENT_RELEASE_H # Step 3: Analyze the diff output Example diff showing: -+ "@shopify/cli": "~3.80.4" # CLI version bump - INCLUDE -- "@shopify/cli": "~3.79.2" -+ "react-router": "7.6.0" # New React Router - INCLUDE -+ "react-router-dom": "7.6.0" # New React Router DOM - INCLUDE ++ "@shopify/cli": "~3.94.0" # CLI version bump - INCLUDE +- "@shopify/cli": "~3.93.2" ++ "react-router": "8.0.1" # React Router baseline - INCLUDE ++ "react": "19.2.7" # Required by framework baseline - INCLUDE ++ "react-dom": "19.2.7" # Required by framework baseline - INCLUDE +- "react-router-dom": "7.16.0" # Removed by React Router 8 - REMOVE - "@remix-run/react": "^2.16.1" # Removed Remix - REMOVE from changelog - "@remix-run/server-runtime": "^2.16.1" # Removed Remix - REMOVE from changelog - "react": "^18.2.0" # Unchanged - EXCLUDE (user dependency) "graphql": "^16.10.0" # Unchanged - EXCLUDE (user dependency) # Step 4: Final inclusion decision INCLUDE in changelog: -- "@shopify/cli": "~3.80.4" # Framework tool, version changed -- "react-router": "7.6.0" # Framework package, newly added -- "react-router-dom": "7.6.0" # Framework package, newly added +- "@shopify/cli": "~3.94.0" # Framework tool, version changed +- "react-router": "8.0.1" # Framework package, version changed +- "react", "react-dom" # Required by framework baseline +- "react-router-dom" # Remove (no longer published for React Router 8) EXCLUDE from changelog: -- "react", "graphql", etc. # User application dependencies +- "graphql", etc. # User application dependencies - "@remix-run/react" # Remove (no longer in skeleton) ``` @@ -445,8 +449,10 @@ git diff PREV:templates/skeleton/package.json CURR:templates/skeleton/package.js // CORRECT - Only changed framework dependencies { "dependencies": { - "@shopify/hydrogen": "2025.5.0", // Framework - changed - "react-router": "7.6.0" // Framework - newly added + "@shopify/hydrogen": "2026.5.0", // Framework - changed + "react-router": "8.0.1", // Framework - changed + "react": "19.2.7", // Required by React Router 8 + "react-dom": "19.2.7" // Required by React Router 8 } } ``` @@ -627,4 +633,4 @@ Please confirm: Should I proceed with updating the changelog? (y/n) - **Acknowledge**: "Understood. The changelog entry has been generated but not added to the file." - **Preserve**: "You can copy the generated JSON entry if you want to add it manually later." -**IMPORTANT**: Only modify the changelog.json file if the user explicitly confirms. Never assume or proceed without clear permission. \ No newline at end of file +**IMPORTANT**: Only modify the changelog.json file if the user explicitly confirms. Never assume or proceed without clear permission. diff --git a/.docs/dependencies.md b/.docs/dependencies.md new file mode 100644 index 0000000000..417d1e2340 --- /dev/null +++ b/.docs/dependencies.md @@ -0,0 +1,23 @@ +# Documentation Dependencies + +Quick lookup: "I changed X, what docs do I update?" + +## React Router Baseline + +**Code**: +- `package.json` +- `pnpm-workspace.yaml` +- `packages/hydrogen/src/react-router-preset.ts` +- `packages/cli/src/commands/hydrogen/upgrade.ts` +- `templates/skeleton/package.json` +- `templates/skeleton/README.md` +- `templates/skeleton/react-router.config.ts` +- `docs/preview/package.json` + +**Docs to Update**: +- `templates/skeleton/.cursor/rules/hydrogen-react-router.mdc` - import guidance for generated projects +- `templates/TEMPLATE_GUIDELINES.md` - route API terminology +- `packages/hydrogen/react-router.d.ts` - React Router augmentation comments +- `.claude/commands/changelog-update.md` - release changelog dependency rules +- `cookbook/recipes/*/README.md` and `cookbook/llms/*.prompt.md` - generated recipe examples after route or dependency baseline changes +- `docs/changelog.json` - release metadata when the release PR is generated diff --git a/cookbook/llms/b2b.prompt.md b/cookbook/llms/b2b.prompt.md index ee0b2dcb7d..07e51c2d33 100644 --- a/cookbook/llms/b2b.prompt.md +++ b/cookbook/llms/b2b.prompt.md @@ -41,8 +41,8 @@ This recipe adds comprehensive B2B functionality to your Hydrogen storefront, en - See volume-based price breaks for bulk purchases - Access B2B-specific pricing through contextualized GraphQL queries -The implementation includes a location selector modal, quantity rule displays on product pages, -and cart functionality that respects B2B quantity rules. All product queries are contextualized +The implementation includes a location selector modal, quantity rule displays on product pages, +and cart functionality that respects B2B quantity rules. All product queries are contextualized with buyer information (company location + customer token) to ensure accurate B2B pricing. ## Notes @@ -74,11 +74,11 @@ Update the README file with comprehensive B2B commerce documentation, explaining #### File: /README.md -~~~diff +```diff @@ -18,6 +18,45 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - TypeScript and JavaScript flavors - Minimal setup of components and routes - + +## B2B Features + +This template includes B2B (Business-to-Business) functionality for stores on Shopify Plus plans: @@ -119,9 +119,9 @@ Update the README file with comprehensive B2B commerce documentation, explaining +> In this implementation, only the product display page (`app/routes/products.$handle.tsx`) uses contextualized queries. For production, all product queries should be contextualized with buyer information. + ## Getting started - + **Requirements:** -~~~ +``` ### Step 2: Create a React context provider to manage company location state across the app @@ -129,7 +129,7 @@ Create a React context provider that manages the selected company location state #### File: [B2BLocationProvider.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/B2BLocationProvider.tsx) -~~~tsx +```tsx import {createContext, useContext, useEffect, useState, useMemo} from 'react'; import {useFetcher} from 'react-router'; import {type CustomerCompany} from '~/root'; @@ -181,7 +181,7 @@ export function B2BLocationProvider({children}: {children: React.ReactNode}) { export function useB2BLocation(): B2BLocationContextValue { return useContext(B2BLocationContext); } -~~~ +``` ### Step 3: Update quantity controls to respect B2B minimum, maximum, and increment rules @@ -189,7 +189,7 @@ Update cart line item quantity controls to honor B2B quantity rules including mi #### File: /app/components/CartLineItem.tsx -~~~diff +```diff @@ -105,8 +105,13 @@ export function CartLineItem({ function CartLineQuantity({line}: {line: CartLine}) { if (!line || typeof line?.quantity === 'undefined') return null; @@ -203,7 +203,7 @@ Update cart line item quantity controls to honor B2B quantity rules including mi + quantity % increment === 0 ? increment : quantity % increment; + const prevQuantity = Number(Math.max(0, quantity - prevIncrement).toFixed(0)); + const nextQuantity = Number((quantity + nextIncrement).toFixed(0)); - + return (
@@ -114,7 +119,7 @@ function CartLineQuantity({line}: {line: CartLine}) { @@ -226,7 +226,7 @@ Update cart line item quantity controls to honor B2B quantity rules including mi > + -~~~ +``` ### Step 4: Build a modal that lets customers choose which company location to use for pricing @@ -234,7 +234,7 @@ Create a modal component that displays available company locations and allows B2 #### File: [B2BLocationSelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/B2BLocationSelector.tsx) -~~~tsx +```tsx import React from 'react'; import {CartForm} from '@shopify/hydrogen'; import type { @@ -306,7 +306,7 @@ export function B2BLocationSelector() {
); } -~~~ +``` ### Step 5: Add a location selector button to the header for switching between company locations @@ -314,7 +314,7 @@ Add a location selector button to the site header that allows B2B customers to e #### File: /app/components/Header.tsx -~~~diff +```diff @@ -7,6 +7,9 @@ import { } from '@shopify/hydrogen'; import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated'; @@ -322,7 +322,7 @@ Add a location selector button to the site header that allows B2B customers to e +// @description Import B2B types and hooks for company location management +import {type CustomerCompanyLocationConnection} from '~/root'; +import {useB2BLocation} from './B2BLocationProvider'; - + interface HeaderProps { header: HeaderQuery; @@ -91,6 +94,8 @@ export function HeaderMenu({ @@ -337,7 +337,7 @@ Add a location selector button to the site header that allows B2B customers to e @@ -175,6 +180,29 @@ function CartBanner() { return ; } - + +// @description Add B2B location change button for company location selection +function ChangeLocation() { + const {company, companyLocationId, setModalOpen} = useB2BLocation(); @@ -364,7 +364,7 @@ Add a location selector button to the site header that allows B2B customers to e const FALLBACK_HEADER_MENU = { id: 'gid://shopify/Menu/199655587896', items: [ -~~~ +``` ### Step 6: Display volume pricing tiers that show bulk purchase discounts @@ -372,7 +372,7 @@ Create a component that displays volume pricing breaks, showing customers the di #### File: [PriceBreaks.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/PriceBreaks.tsx) -~~~tsx +```tsx import {Money} from '@shopify/hydrogen'; import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; @@ -412,7 +412,7 @@ export function PriceBreaks({priceBreaks}: PriceBreaksProps) { ); } -~~~ +``` ### Step 7: Accept quantity parameter to support B2B minimum quantity and increment rules @@ -420,11 +420,11 @@ Update the ProductForm component to accept and handle quantity parameters that e #### File: /app/components/ProductForm.tsx -~~~diff +```diff @@ -8,12 +8,15 @@ import {AddToCartButton} from './AddToCartButton'; import {useAside} from './Aside'; import type {ProductFragment} from 'storefrontapi.generated'; - + +// @description Add quantity parameter for B2B quantity support export function ProductForm({ productOptions, @@ -447,7 +447,7 @@ Update the ProductForm component to accept and handle quantity parameters that e selectedVariant, }, ] -~~~ +``` ### Step 8: Show minimum, maximum, and increment quantity requirements for B2B products @@ -455,7 +455,7 @@ Create a component that displays B2B quantity rules to customers, showing minimu #### File: [QuantityRules.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/QuantityRules.tsx) -~~~tsx +```tsx import type {Maybe} from '@shopify/hydrogen/customer-account-api-types'; export type QuantityRulesProps = { @@ -500,7 +500,7 @@ export function QuantityRules({ ); } -~~~ +``` ### Step 9: Add quantity rules and price breaks to cart GraphQL fragments @@ -508,7 +508,7 @@ Update GraphQL fragments to include quantity rules and price break data needed f #### File: /app/lib/fragments.ts -~~~diff +```diff @@ -52,6 +52,21 @@ export const CART_QUERY_FRAGMENT = `#graphql name value @@ -553,52 +553,7 @@ Update GraphQL fragments to include quantity rules and price break data needed f } } lineComponents { -~~~ - -### Step 10: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ### Step 10: Query company locations from the Customer Account API for B2B customers @@ -606,7 +561,7 @@ Create a GraphQL query that fetches all available company locations for B2B cust #### File: [CustomerLocationsQuery.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/graphql/customer-account/CustomerLocationsQuery.ts) -~~~ts +```ts // NOTE: https://shopify.dev/docs/api/customer/latest/objects/Customer export const CUSTOMER_LOCATIONS_QUERY = `#graphql query CustomerLocations { @@ -640,7 +595,7 @@ export const CUSTOMER_LOCATIONS_QUERY = `#graphql } } ` as const; -~~~ +``` ### Step 11: Wrap the app with B2B location provider and add company location types @@ -648,7 +603,7 @@ Wrap the application with the B2B location provider context and add TypeScript t #### File: /app/root.tsx -~~~diff +```diff @@ -16,9 +16,39 @@ import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments'; import resetStyles from '~/styles/reset.css?url'; import appStyles from '~/styles/app.css?url'; @@ -662,9 +617,9 @@ Wrap the application with the B2B location provider context and add TypeScript t + CompanyLocation, + Maybe, +} from '@shopify/hydrogen/customer-account-api-types'; - + export type RootLoader = typeof loader; - + +// @description Define B2B customer company types +export type CustomerCompanyLocation = Pick & { + shippingAddress?: @@ -706,7 +661,7 @@ Wrap the application with the B2B location provider context and add TypeScript t ); } -~~~ +``` ### Step 12: Handle location selection and automatically set location if customer has only one @@ -714,7 +669,7 @@ Create a route handler that processes location selection requests and automatica #### File: [b2blocations.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/routes/b2blocations.tsx) -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/b2blocations'; import {B2BLocationSelector} from '../components/B2BLocationSelector'; @@ -753,7 +708,7 @@ export async function loader({context}: Route.LoaderArgs) { export default function CartRoute() { return ; } -~~~ +``` ### Step 13: Persist B2B company location in session when updating buyer identity @@ -761,17 +716,17 @@ Update the cart route to call customerAccount.setBuyer() when processing BuyerId #### File: /app/routes/cart.tsx -~~~diff +```diff @@ -11,7 +11,8 @@ export const meta: Route.MetaFunction = () => { export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; - + export async function action({request, context}: Route.ActionArgs) { - const {cart} = context; + // @description Destructure customerAccount for B2B location session persistence + const {cart, customerAccount} = context; - + const formData = await request.formData(); - + @@ -64,6 +65,12 @@ export async function action({request, context}: Route.ActionArgs) { break; } @@ -785,18 +740,18 @@ Update the cart route to call customerAccount.setBuyer() when processing BuyerId result = await cart.updateBuyerIdentity({ ...inputs.buyerIdentity, }); -~~~ +``` ### Step 14: Clear company location and customer data from cart when logging out Update the logout process to clear B2B-specific data including selected company location and customer context from the cart session -#### File: /app/routes/account_.logout.tsx +#### File: /app/routes/account\_.logout.tsx -~~~diff +```diff @@ -7,5 +7,10 @@ export async function loader() { } - + export async function action({context}: Route.ActionArgs) { + // @description Clear B2B company location on logout + await context.cart.updateBuyerIdentity({ @@ -805,7 +760,7 @@ Update the logout process to clear B2B-specific data including selected company + }); return context.customerAccount.logout(); } -~~~ +``` ### Step 15: Contextualize product queries with buyer information and display B2B pricing details @@ -813,7 +768,7 @@ Update product queries to include buyer context (company location and customer t #### File: /app/routes/products.$handle.tsx -~~~diff +```diff @@ -12,6 +12,19 @@ import {ProductPrice} from '~/components/ProductPrice'; import {ProductImage} from '~/components/ProductImage'; import {ProductForm} from '~/components/ProductForm'; @@ -831,12 +786,12 @@ Update product queries to include buyer context (company location and customer t + }; + } + | {}; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { return [ -@@ -24,11 +37,24 @@ export const meta: Route.MetaFunction = ({data}) => { +@@ -24,11 +37,24 @@ export const meta: Route.MetaFunction = ({loaderData}) => { }; - + export async function loader(args: Route.LoaderArgs) { + // @description Get B2B buyer context for contextualized product queries + const buyer = await args.context.customerAccount.getBuyer(); @@ -854,29 +809,30 @@ Update product queries to include buyer context (company location and customer t // Start fetching non-critical data without blocking time to first byte - const deferredData = loadDeferredData(args); + const deferredData = loadDeferredData({...args, buyerVariables}); - + // Await the critical data required to render initial state of the page - const criticalData = await loadCriticalData(args); + const criticalData = await loadCriticalData({...args, buyerVariables}); - + return {...deferredData, ...criticalData}; } @@ -37,7 +63,12 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +-async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { +async function loadCriticalData({ + context, + params, + request, ++ url, + buyerVariables, +}: Route.LoaderArgs & {buyerVariables: BuyerVariables}) { const {handle} = params; const {storefront} = context; - -@@ -47,7 +78,11 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { - + +@@ -47,7 +78,11 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { + const [{product}] = await Promise.all([ storefront.query(PRODUCT_QUERY, { - variables: {handle, selectedOptions: getSelectedProductOptions(request)}, @@ -888,7 +844,7 @@ Update product queries to include buyer context (company location and customer t }), // Add other queries here, so that they are loaded in parallel ]); -@@ -69,7 +104,11 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -69,7 +104,11 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { * 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. */ @@ -900,7 +856,7 @@ Update product queries to include buyer context (company location and customer t +}: Route.LoaderArgs & {buyerVariables: BuyerVariables}) { // Put any API calls that is not critical to be available on first page render // For example: product reviews, product recommendations, social feeds. - + @@ -110,8 +149,26 @@ export default function Product() { \ No newline at end of file + diff --git a/cookbook/llms/bundles.prompt.md b/cookbook/llms/bundles.prompt.md index aa10041bbd..f177474a35 100644 --- a/cookbook/llms/bundles.prompt.md +++ b/cookbook/llms/bundles.prompt.md @@ -17,11 +17,12 @@ Display product bundles on your Hydrogen storefront. # User Intent Recognition + - How do I show product bundles on my Hydrogen storefront? - How do I add badges to bundled products on my Hydrogen storefront? - How do I display product bundles on applicable line items in the cart? - How do I detect if a product is a bundle? - + # Troubleshooting @@ -49,12 +50,12 @@ when they're viewing product and collection pages. In this recipe you'll make the following changes: 1. Set up the Shopify Bundles app in your Shopify admin and create a new -product bundle. + product bundle. 2. Update the GraphQL fragments to query for bundles to identify bundled -products. + products. 3. Update the product and collection templates to display badges on product -listings, update the copy for the cart buttons, and display bundle-specific -information on product and collection pages. + listings, update the copy for the cart buttons, and display bundle-specific + information on product and collection pages. 4. Update the cart line item template to display the bundle badge as needed. ## Requirements @@ -83,7 +84,7 @@ Create a new BundleBadge component to be displayed on bundle product listings. #### File: [BundleBadge.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundleBadge.tsx) -~~~tsx +```tsx export function BundleBadge() { return (
); } - -~~~ +``` ### Step 3: Create a new BundledVariants component @@ -110,7 +110,7 @@ Create a new `BundledVariants` component that wraps the variants of a bundle pro #### File: [BundledVariants.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundledVariants.tsx) -~~~tsx +```tsx import {Link} from 'react-router'; import {Image} from '@shopify/hydrogen'; import type { @@ -174,16 +174,15 @@ export function BundledVariants({
); } - -~~~ +``` ### Step 4: Query bundle pricing for recommended products Add `maxVariantPrice` to the `RecommendedProducts` query's product fields. -#### File: /app/routes/_index.tsx +#### File: /app/routes/\_index.tsx -~~~diff +```diff @@ -157,6 +157,10 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql amount currencyCode @@ -195,30 +194,30 @@ Add `maxVariantPrice` to the `RecommendedProducts` query's product fields. } featuredImage { id -~~~ +``` ### Step 5: Show bundled products on the product page 1. Add the `requiresComponents` field to the `Product` fragment, which is -used to identify bundled products. + used to identify bundled products. 2. Pass the `isBundle` flag to the `ProductImage` component. #### File: /app/routes/products.$handle.tsx -~~~diff +```diff @@ -12,6 +12,8 @@ import {ProductPrice} from '~/components/ProductPrice'; import {ProductImage} from '~/components/ProductImage'; import {ProductForm} from '~/components/ProductForm'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; +import type {ProductVariantComponent} from '@shopify/hydrogen/storefront-api-types'; +import {BundledVariants} from '~/components/BundledVariants'; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { return [ @@ -97,9 +99,12 @@ export default function Product() { - + const {title, descriptionHtml} = product; - + + const isBundle = Boolean(product.isBundle?.requiresComponents); + const bundledVariants = isBundle ? product.isBundle?.components.nodes : null; + @@ -280,7 +279,7 @@ used to identify bundled products. + } } ` as const; - + @@ -209,6 +245,25 @@ const PRODUCT_FRAGMENT = `#graphql adjacentVariants (selectedOptions: $selectedOptions) { ...ProductVariant @@ -307,7 +306,7 @@ used to identify bundled products. seo { description title -~~~ +``` ### Step 6: Detect bundles in collection listings @@ -315,7 +314,7 @@ Like the previous step, use the `requiresComponents` field to detect if the prod #### File: /app/routes/collections.$handle.tsx -~~~diff +```diff @@ -120,10 +120,16 @@ const PRODUCT_ITEM_FRAGMENT = `#graphql ...MoneyProductItem } @@ -328,13 +327,13 @@ Like the previous step, use the `requiresComponents` field to detect if the prod + } } ` as const; - + -// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection +// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/collection const COLLECTION_QUERY = `#graphql ${PRODUCT_ITEM_FRAGMENT} query Collection( -~~~ +``` ### Step 7: Identify bundles in the cart @@ -342,7 +341,7 @@ Use the `requiresComponents` field to determine if a cart line item is a bundle. #### File: /app/lib/fragments.ts -~~~diff +```diff @@ -52,6 +52,19 @@ export const CART_QUERY_FRAGMENT = `#graphql name value @@ -392,7 +391,7 @@ Use the `requiresComponents` field to determine if a cart line item is a bundle. } } lineComponents { -~~~ +``` ### Step 8: Show bundle badges in the cart @@ -400,15 +399,15 @@ If a product is a bundle, show the `BundleBadge` component in the cart line item #### File: /app/components/CartLineItem.tsx -~~~diff +```diff @@ -9,6 +9,7 @@ import type { CartApiQueryFragment, CartLineFragment, } from 'storefrontapi.generated'; +import {BundleBadge} from '~/components/BundleBadge'; - + export type CartLine = OptimisticCartLine; - + @@ -31,6 +32,7 @@ export function CartLineItem({ const {product, title, image, selectedOptions} = merchandise; const lineItemUrl = useVariantUrl(product.handle, selectedOptions); @@ -416,11 +415,11 @@ If a product is a bundle, show the `BundleBadge` component in the cart line item + const isBundle = Boolean(line.merchandise.requiresComponents); const lineItemChildren = childrenMap[id]; const childrenLabelId = `cart-line-children-${id}`; - + @@ -48,8 +50,9 @@ export function CartLineItem({ /> )} - + -
+
    -~~~ +``` ### Step 9: Update the cart button text for bundles @@ -448,7 +447,7 @@ If a product is a bundle, update the text of the product button. #### File: /app/components/ProductForm.tsx -~~~diff +```diff @@ -11,9 +11,11 @@ import type {ProductFragment} from 'storefrontapi.generated'; export function ProductForm({ productOptions, @@ -474,7 +473,7 @@ If a product is a bundle, update the text of the product button.
); -~~~ +``` ### Step 10: Show bundle badges on product images @@ -482,12 +481,12 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductImage` #### File: /app/components/ProductImage.tsx -~~~diff +```diff @@ -1,10 +1,13 @@ import type {ProductVariantFragment} from 'storefrontapi.generated'; import {Image} from '@shopify/hydrogen'; +import {BundleBadge} from './BundleBadge'; - + export function ProductImage({ image, + isBundle = false, @@ -505,52 +504,7 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductImage`
); } -~~~ - -### Step 10: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ### Step 11: Show bundle badges on product cards @@ -558,7 +512,7 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductItem` #### File: /app/components/ProductItem.tsx -~~~diff +```diff @@ -1,24 +1,19 @@ import {Link} from 'react-router'; import {Image, Money} from '@shopify/hydrogen'; @@ -570,7 +524,7 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductItem` +import type {ProductItemFragment} from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {BundleBadge} from '~/components/BundleBadge'; - + export function ProductItem({ product, loading, @@ -625,7 +579,7 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductItem` ); } -~~~ +``` ### Step 12: Position bundle badges on images @@ -633,11 +587,11 @@ Make sure the bundle badge is positioned relative to the product image. #### File: /app/styles/app.css -~~~diff +```diff @@ -506,6 +506,10 @@ button.reset:hover:not(:has(> *)) { margin-top: 0; } - + +.product-image { + position: relative; +} @@ -645,6 +599,6 @@ Make sure the bundle badge is positioned relative to the product image. .product-image img { height: auto; width: 100%; -~~~ +``` - \ No newline at end of file + diff --git a/cookbook/llms/combined-listings.prompt.md b/cookbook/llms/combined-listings.prompt.md index b8dcb53eff..8d5ada4021 100644 --- a/cookbook/llms/combined-listings.prompt.md +++ b/cookbook/llms/combined-listings.prompt.md @@ -17,12 +17,13 @@ Handle combined listings on product pages and in search results. # User Intent Recognition + - How can I show combined listings on product pages and search results using Hydrogen? - How can I display the featured image of the combined listing parent product instead of the variant image? - How can I redirect to the first variant of a combined listing when the handle is requested? - How can I filter out combined listings from the product list when using Shopify headless? - How can I show the price range for combined listings instead of the variant price? - + # Troubleshooting @@ -52,7 +53,7 @@ In this recipe, you'll make the following changes: ## Notes > [!NOTE] -> This recipe is compatible with React Router 7.9.x and uses consolidated imports from 'react-router' instead of separate '@shopify/remix-oxygen' and '@remix-run/react' packages. +> This recipe is compatible with React Router framework mode and uses consolidated imports from 'react-router' instead of separate '@shopify/remix-oxygen' and '@remix-run/react' packages. ## Requirements @@ -97,7 +98,7 @@ Create a new `combined-listings.ts` file that contains utilities and settings fo #### File: [combined-listings.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/combined-listings/ingredients/templates/skeleton/app/lib/combined-listings.ts) -~~~ts +```ts // Edit these values to customize combined listings' behavior export const combinedListingsSettings = { // If true, loading the product page will redirect to the first variant @@ -133,8 +134,7 @@ export function isCombinedListing(product: unknown) { product.tags.includes(combinedListingsSettings.combinedListingTag) ); } - -~~~ +``` ### Step 4: Hide the cart button for combined listing parent products @@ -143,7 +143,7 @@ export function isCombinedListing(product: unknown) { #### File: /app/components/ProductForm.tsx -~~~diff +```diff @@ -11,9 +11,11 @@ import type {ProductFragment} from 'storefrontapi.generated'; export function ProductForm({ productOptions, @@ -235,7 +235,7 @@ export function isCombinedListing(product: unknown) { ); } -~~~ +``` ### Step 5: Support product and variant images @@ -243,7 +243,7 @@ Update the `ProductImage` component to support images from both product variants #### File: /app/components/ProductImage.tsx -~~~diff +```diff @@ -1,10 +1,13 @@ -import type {ProductVariantFragment} from 'storefrontapi.generated'; +import type { @@ -251,7 +251,7 @@ Update the `ProductImage` component to support images from both product variants + ProductFragment, +} from 'storefrontapi.generated'; import {Image} from '@shopify/hydrogen'; - + export function ProductImage({ image, }: { @@ -260,7 +260,7 @@ Update the `ProductImage` component to support images from both product variants }) { if (!image) { return
; -~~~ +``` ### Step 6: Show a range of prices for combined listings in ProductItem @@ -268,13 +268,13 @@ Update `ProductItem.tsx` to show a range of prices for the combined listing pare #### File: /app/components/ProductItem.tsx -~~~diff +```diff @@ -6,6 +6,7 @@ import type { RecommendedProductFragment, } from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {isCombinedListing} from '../lib/combined-listings'; - + export function ProductItem({ product, @@ -36,9 +37,17 @@ export function ProductItem({ @@ -298,7 +298,7 @@ Update `ProductItem.tsx` to show a range of prices for the combined listing pare ); } -~~~ +``` ### Step 7: (Optional) Redirect to the first variant @@ -306,12 +306,12 @@ If you want to redirect automatically to the first variant of a combined listing #### File: /app/lib/redirect.ts -~~~diff +```diff @@ -1,4 +1,6 @@ import {redirect} from 'react-router'; +import type {ProductFragment} from 'storefrontapi.generated'; +import {isCombinedListing} from './combined-listings'; - + export function redirectIfHandleIsLocalized( request: Request, @@ -21,3 +23,23 @@ export function redirectIfHandleIsLocalized( @@ -338,7 +338,7 @@ If you want to redirect automatically to the first variant of a combined listing + throw redirect(url.toString()); + } +} -~~~ +``` ### Step 8: Filter combined listings from the all products page @@ -346,7 +346,7 @@ Update the "all products" collection page to filter out combined listing parent #### File: /app/routes/collections.all.tsx -~~~diff +```diff @@ -4,6 +4,10 @@ import {getPaginationVariables, Image, Money} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {ProductItem} from '~/components/ProductItem'; @@ -355,11 +355,11 @@ Update the "all products" collection page to filter out combined listing parent + combinedListingsSettings, + maybeFilterOutCombinedListingsQuery, +} from '../lib/combined-listings'; - + export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Products`}]; @@ -31,7 +35,12 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { - + const [{products}] = await Promise.all([ storefront.query(CATALOG_QUERY, { - variables: {...paginationVariables}, @@ -391,22 +391,22 @@ Update the "all products" collection page to filter out combined listing parent nodes { ...CollectionItem } -~~~ +``` ### Step 9: Filter recommended products 1. Add the `tags` property to the items returned by the product query. 2. (Optional) Add the filtering query to the product query to exclude combined listings. -#### File: /app/routes/_index.tsx +#### File: /app/routes/\_index.tsx -~~~diff +```diff @@ -8,6 +8,7 @@ import type { } from 'storefrontapi.generated'; import {ProductItem} from '~/components/ProductItem'; import {MockShopNotice} from '~/components/MockShopNotice'; +import {maybeFilterOutCombinedListingsQuery} from '~/lib/combined-listings'; - + export const meta: Route.MetaFunction = () => { return [{title: 'Hydrogen | Home'}]; @@ -46,7 +47,11 @@ async function loadCriticalData({context}: Route.LoaderArgs) { @@ -462,7 +462,7 @@ Update the "all products" collection page to filter out combined listing parent nodes { ...RecommendedProduct } -~~~ +``` ### Step 10: (Optional) Filter out combined listings from collections pages @@ -470,7 +470,7 @@ Since it's not possible to directly apply query filters when retrieving collecti #### File: /app/routes/collections.$handle.tsx -~~~diff +```diff @@ -5,6 +5,10 @@ import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; import {ProductItem} from '~/components/ProductItem'; @@ -479,13 +479,13 @@ Since it's not possible to directly apply query filters when retrieving collecti + combinedListingsSettings, + isCombinedListing, +} from '~/lib/combined-listings'; - - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}]; + + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}]; @@ -68,12 +72,25 @@ function loadDeferredData({context}: Route.LoaderArgs) { export default function Collection() { const {collection} = useLoaderData(); - + + // Manually filter out combined listings from the collection products, because filtering + // would not work here. + const filteredCollectionProducts = { @@ -526,52 +526,7 @@ Since it's not possible to directly apply query filters when retrieving collecti ) { nodes { ...ProductItem -~~~ - -### Step 10: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ### Step 11: Show price ranges on product pages @@ -581,7 +536,7 @@ Since it's not possible to directly apply query filters when retrieving collecti #### File: /app/routes/products.$handle.tsx -~~~diff +```diff @@ -11,7 +11,14 @@ import { import {ProductPrice} from '~/components/ProductPrice'; import {ProductImage} from '~/components/ProductImage'; @@ -595,13 +550,13 @@ Since it's not possible to directly apply query filters when retrieving collecti + isCombinedListing, + combinedListingsSettings, +} from '../lib/combined-listings'; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { return [ -@@ -59,6 +66,10 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -59,6 +66,10 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); - + redirectIfHandleIsLocalized(url, {handle, data: product}); + + if (combinedListingsSettings.redirectToFirstVariant) { + redirectIfCombinedListing(request, product); + } @@ -610,35 +565,35 @@ Since it's not possible to directly apply query filters when retrieving collecti product, }; @@ -78,6 +89,7 @@ function loadDeferredData({context, params}: Route.LoaderArgs) { - + export default function Product() { const {product} = useLoaderData(); + const combinedListing = isCombinedListing(product); - + // Optimistically selects a variant with given available variant information const selectedVariant = useOptimisticVariant( @@ -87,7 +99,9 @@ export default function Product() { - + // Sets the search param to the selected variant without navigation // only when no search params are set in the url - useSelectedOptionInUrlParam(selectedVariant.selectedOptions); + useSelectedOptionInUrlParam( + combinedListing ? [] : selectedVariant.selectedOptions, + ); - + // Get the product options array const productOptions = getProductOptions({ @@ -95,21 +109,41 @@ export default function Product() { selectedOrFirstAvailableVariant: selectedVariant, }); - + - const {title, descriptionHtml} = product; + const {descriptionHtml, title} = product; + + const productImage = combinedListing + ? (product.featuredImage ?? selectedVariant?.image) + : selectedVariant?.image; - + return (
- @@ -700,7 +655,7 @@ Since it's not possible to directly apply query filters when retrieving collecti options { name optionValues { -~~~ +``` ### Step 12: Style the price range display @@ -708,11 +663,11 @@ Add a class to the product item to show a range of prices for combined listings. #### File: /app/styles/app.css -~~~diff +```diff @@ -489,6 +489,11 @@ button.reset:hover:not(:has(> *)) { width: 100%; } - + +.product-item .combined-listing-price { + display: flex; + grid-gap: 0.5rem; @@ -721,6 +676,6 @@ Add a class to the product item to show a range of prices for combined listings. /* * -------------------------------------------------- * routes/products.$handle.tsx -~~~ +``` - \ No newline at end of file + diff --git a/cookbook/llms/custom-cart-method.prompt.md b/cookbook/llms/custom-cart-method.prompt.md index 27bb9ea025..449e1b8f68 100644 --- a/cookbook/llms/custom-cart-method.prompt.md +++ b/cookbook/llms/custom-cart-method.prompt.md @@ -42,6 +42,7 @@ the cart again. Their cart updates will happen automatically, without requiring a full page refresh. Key features: + - Custom cart method `updateLineByOptions` for variant selection - Inline dropdown selectors for each product option in cart - Automatic cart updates when options are changed @@ -56,8 +57,6 @@ Key features: ## New files added to the template by this recipe - - ## Steps ### Step 1: Document custom cart methods in the README @@ -66,18 +65,18 @@ Update the README file with custom cart method documentation and an implementati #### File: /README.md -~~~diff +````diff @@ -1,6 +1,8 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Custom Cart Method - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates how to implement custom cart methods for inline product option editing. Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates how to implement custom cart methods for inline product option editing. Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to enable users to change product variants (size, color, etc.) directly within the cart without removing and re-adding items, providing a smoother shopping experience. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,7 +18,29 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -106,13 +105,13 @@ Update the README file with custom cart method documentation and an implementati +2. Custom cart method queries for new variant +3. Cart line item updates with new variant +4. Total price and inventory automatically adjust - + ## Getting started - + @@ -28,6 +52,25 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov npm create @shopify/hydrogen@latest ``` - + +## Implementation Details + +### Custom Cart Method @@ -121,7 +120,7 @@ Update the README file with custom cart method documentation and an implementati + const {product} = await storefront.query(VARIANTS_QUERY, { + variables: {handle: productHandle, selectedOptions} + }); -+ ++ + return cart.updateLineItems([{ + id: lineId, + merchandiseId: product.variantBySelectedOptions?.id @@ -133,12 +132,12 @@ Update the README file with custom cart method documentation and an implementati +The recipe extends Hydrogen's cart context with proper TypeScript types for the custom method, ensuring type safety throughout your application. + ## Building for production - + ```bash @@ -40,6 +83,21 @@ npm run build npm run dev ``` - + +## Important Notes + +After applying this recipe: @@ -155,11 +154,11 @@ Update the README file with custom cart method documentation and an implementati +- Build advanced cart customization flows + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +```` ### Step 2: Let customers change variants directly in the cart @@ -167,7 +166,7 @@ Add variant selector functionality to cart line items for changing product optio #### File: /app/components/CartLineItem.tsx -~~~diff +```diff @@ -1,6 +1,13 @@ -import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; +import type { @@ -203,7 +202,7 @@ Add variant selector functionality to cart line items for changing product optio @@ -185,6 +187,115 @@ function CartLineUpdateButton({ ); } - + +/** Option shape for cart line option selects */ +type CartLineOption = { + name: string; @@ -316,7 +315,7 @@ Add variant selector functionality to cart line items for changing product optio /** * Returns a unique key for the update action. This is used to make sure actions modifying the same line * items are not run concurrently, but cancel each other. For example, if the user clicks "Increase quantity" -~~~ +``` ### Step 3: Add updateLineByOptions method to cart context @@ -324,8 +323,8 @@ Extend HydrogenCart context with updateLineByOptions method for variant switchin #### File: /app/lib/context.ts -~~~diff -@@ -1,6 +1,15 @@ +```diff +@@ -1,7 +1,16 @@ -import {createHydrogenContext} from '@shopify/hydrogen'; +import { + createHydrogenContext, @@ -336,18 +335,19 @@ Extend HydrogenCart context with updateLineByOptions method for variant switchin import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; +import {CART_QUERY_FRAGMENT, PRODUCT_VARIANT_QUERY} from '~/lib/fragments'; + import type {CartApiQueryFragment} from 'storefrontapi.generated'; +import type { + SelectedOptionInput, + CartLineUpdateInput, +} from '@shopify/hydrogen/storefront-api-types'; - + // Define the additional context object const additionalContext = { -@@ -16,6 +25,15 @@ type AdditionalContextType = typeof additionalContext; - - declare global { - interface HydrogenAdditionalContext extends AdditionalContextType {} -+ +@@ -21,6 +30,15 @@ + // Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so + // that context.cart.get() and all cart mutations return the extended cart type. + interface HydrogenCustomCartFragment extends CartApiQueryFragment {} ++ + // @description Augment the cart with custom methods for variant selection + interface HydrogenCustomCartMethods { + updateLineByOptions: ( @@ -357,19 +357,19 @@ Extend HydrogenCart context with updateLineByOptions method for variant switchin + ) => Promise; + } } - + /** -@@ -40,7 +58,8 @@ export async function createHydrogenRouterContext( +@@ -45,7 +63,8 @@ AppSession.init(request, [env.SESSION_SECRET]), ]); - + - const hydrogenContext = createHydrogenContext( + // @description Create a placeholder context first to reference in customMethods + const hydrogenContext: ReturnType = createHydrogenContext( { env, request, -@@ -51,6 +70,33 @@ export async function createHydrogenRouterContext( +@@ -56,6 +75,33 @@ i18n: {language: 'EN', country: 'US'}, cart: { queryFragment: CART_QUERY_FRAGMENT, @@ -403,7 +403,7 @@ Extend HydrogenCart context with updateLineByOptions method for variant switchin }, }, additionalContext, -~~~ +``` ### Step 4: Query product options for variant selection @@ -411,7 +411,7 @@ Add product options to cart fragments and create PRODUCT_VARIANT_QUERY for fetch #### File: /app/lib/fragments.ts -~~~diff +```diff @@ -40,13 +40,19 @@ export const CART_QUERY_FRAGMENT = `#graphql altText width @@ -471,7 +471,7 @@ Add product options to cart fragments and create PRODUCT_VARIANT_QUERY for fetch + } + } +`; -~~~ +``` ### Step 5: Handle variant updates in the cart route @@ -479,7 +479,7 @@ Implement the CustomUpdateLineByOptions action handler for processing variant ch #### File: /app/routes/cart.tsx -~~~diff +```diff @@ -2,6 +2,10 @@ import {useLoaderData, data, type HeadersFunction} from 'react-router'; import type {Route} from './+types/cart'; import type {CartQueryDataReturn} from '@shopify/hydrogen'; @@ -489,11 +489,11 @@ Implement the CustomUpdateLineByOptions action handler for processing variant ch + CartLineUpdateInput, +} from '@shopify/hydrogen/storefront-api-types'; import {CartMain} from '~/components/CartMain'; - + export const meta: Route.MetaFunction = () => { @@ -25,6 +29,21 @@ export async function action({request, context}: Route.ActionArgs) { let result: CartQueryDataReturn; - + switch (action) { + // @description Handle custom action for updating line item variant options + case 'CustomUpdateLineByOptions': @@ -513,51 +513,6 @@ Implement the CustomUpdateLineByOptions action handler for processing variant ch case CartForm.ACTIONS.LinesAdd: result = await cart.addLines(inputs.lines); break; -~~~ - -### Step 6: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - - \ No newline at end of file +``` + + diff --git a/cookbook/llms/express.prompt.md b/cookbook/llms/express.prompt.md index 34db900c51..28559e9e74 100644 --- a/cookbook/llms/express.prompt.md +++ b/cookbook/llms/express.prompt.md @@ -67,12 +67,12 @@ Technical details: - Environment variables are loaded from .env file using dotenv - Session management is handled through Express middleware with SESSION_SECRET - GraphQL codegen still works with Storefront API types -- Compatible with React Router 7.8.x +- Compatible with React Router framework mode - The .graphqlrc.ts file is preserved with customer account section commented out ## Requirements -- Node.js 20 or higher (less than 22.0.0) for production deployment +- Node.js 22.22.0 or higher for production deployment - npm or yarn package manager - Shopify Storefront API credentials @@ -121,19 +121,19 @@ Update README with Express-specific setup and deployment instructions -# Hydrogen template: Skeleton +# Hydrogen Express Skeleton --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +This is a Hydrogen skeleton template configured to run with NodeJS [Express](https://expressjs.com/) instead of Shopify Oxygen. + +Hydrogen is Shopify's stack for headless commerce, designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal Express setup** with basic components and routes to get started with Hydrogen on Node.js. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) --[Get familiar with Remix](https://remix.run/docs/en/v1) +-[Get familiar with React Router](https://reactrouter.com/) +[Get familiar with React Router](https://reactrouter.com/en/main) ## What's included --- Remix -+- React Router 7 +-- React Router ++- React Router - Hydrogen -- Oxygen +- Express server @@ -158,8 +158,8 @@ Update README with Express-specific setup and deployment instructions **Requirements:** --- Node.js version 18.0.0 or higher -+- Node.js version 18.0.0 or higher (but less than 22.0.0) +-- Node.js version 22.22.0 or higher ++- Node.js version 22.22.0 or higher + +### Environment Setup + @@ -229,7 +229,7 @@ Update README with Express-specific setup and deployment instructions Add environment type definitions for Hydrogen on Express -#### File: [env.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/app/env.ts) +#### File: [env.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/app/env.ts) ```ts // This file extends the Hydrogen types for this project @@ -271,7 +271,7 @@ Update client entry to use React Router hydration without Oxygen-specific code #### File: /app/entry.client.tsx ```diff -@@ -1,21 +1,13 @@ +@@ -1,20 +1,13 @@ import {HydratedRouter} from 'react-router/dom'; import {startTransition, StrictMode} from 'react'; import {hydrateRoot} from 'react-dom/client'; @@ -280,9 +280,8 @@ Update client entry to use React Router hydration without Oxygen-specific code if (!window.location.origin.includes('webcache.googleusercontent.com')) { startTransition(() => { - // Extract nonce from existing script tags -- const existingNonce = document -- .querySelector('script[nonce]') -- ?.nonce; +- const existingNonce = +- document.querySelector('script[nonce]')?.nonce; - hydrateRoot( document, @@ -300,7 +299,7 @@ Update client entry to use React Router hydration without Oxygen-specific code Add Express template favicon -#### File: [favicon.svg](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/public/favicon.svg) +#### File: [favicon.svg](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/public/favicon.svg) ```svg @@ -461,7 +460,7 @@ Replace Oxygen server rendering with Express-compatible Node.js SSR using PassTh Add development server orchestration script for Vite and nodemon -#### File: [dev.mjs](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/scripts/dev.mjs) +#### File: [dev.mjs](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/scripts/dev.mjs) ```mjs #!/usr/bin/env node @@ -772,7 +771,7 @@ Simplify root layout for Express template by removing complex components Add Express server with Hydrogen context, session management, and SSR support -#### File: [server.mjs](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/server.mjs) +#### File: [server.mjs](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/server.mjs) ```mjs import 'dotenv/config'; @@ -1074,12 +1073,8 @@ Simplify homepage route to basic Express example content #### File: /app/routes/\_index.tsx ```diff -@@ -1,171 +1,28 @@ --import { -- Await, -- useLoaderData, -- Link, --} from 'react-router'; +@@ -1,177 +1,28 @@ +-import {Await, useLoaderData, Link} from 'react-router'; -import type {Route} from './+types/_index'; -import {Suspense} from 'react'; -import {Image} from '@shopify/hydrogen'; @@ -1088,6 +1083,7 @@ Simplify homepage route to basic Express example content - RecommendedProductsQuery, -} from 'storefrontapi.generated'; -import {ProductItem} from '~/components/ProductItem'; +-import {MockShopNotice} from '~/components/MockShopNotice'; +import {useRouteError, isRouteErrorResponse, Link} from 'react-router'; -export const meta: Route.MetaFunction = () => { @@ -1115,6 +1111,7 @@ Simplify homepage route to basic Express example content - ]); - - return { +- isShopLinked: Boolean(context.env.PUBLIC_STORE_DOMAIN), - featuredCollection: collections.nodes[0], - }; -} @@ -1143,6 +1140,7 @@ Simplify homepage route to basic Express example content +export default function Index() { return ( -
+- {data.isShopLinked ? null : } - - -
@@ -1173,7 +1171,11 @@ Simplify homepage route to basic Express example content - > - {image && ( -
-- +- {image.altText -
- )} -

{collection.title}

@@ -1197,8 +1199,11 @@ Simplify homepage route to basic Express example content - products: Promise; -}) { - return ( --
--

Recommended Products

+-
+- - Loading...
}> - - {(response) => ( @@ -1213,7 +1218,7 @@ Simplify homepage route to basic Express example content - - -
--
+- - ); -} - @@ -1277,11 +1282,8 @@ Simplify product route to minimal implementation without cart functionality #### File: /app/routes/products.$handle.tsx ```diff -@@ -1,50 +1,7 @@ --import { -- redirect, -- useLoaderData, --} from 'react-router'; +@@ -1,43 +1,7 @@ +-import {redirect, useLoaderData} from 'react-router'; +import {useLoaderData} from 'react-router'; import type {Route} from './+types/products.$handle'; -import { @@ -1297,12 +1299,12 @@ Simplify product route to minimal implementation without cart functionality -import {ProductForm} from '~/components/ProductForm'; -import {redirectIfHandleIsLocalized} from '~/lib/redirect'; --export const meta: Route.MetaFunction = ({data}) => { +-export const meta: Route.MetaFunction = ({loaderData}) => { - return [ -- {title: `Hydrogen | ${data?.product.title ?? ''}`}, +- {title: `Hydrogen | ${loaderData?.product.title ?? ''}`}, - { - rel: 'canonical', -- href: `/products/${data?.product.handle}`, +- href: `/products/${loaderData?.product.handle}`, - }, - ]; -}; @@ -1321,16 +1323,12 @@ Simplify product route to minimal implementation without cart functionality - * 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) { +-async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { +export async function loader({params, context}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; -@@ -52,188 +9,42 @@ async function loadCriticalData({ +@@ -45,188 +9,42 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { throw new Error('Expected product handle to be defined'); } @@ -1349,7 +1347,7 @@ Simplify product route to minimal implementation without cart functionality } - // The API handle might be localized, so redirect to the localized handle -- redirectIfHandleIsLocalized(request, {handle, data: product}); +- redirectIfHandleIsLocalized(url, {handle, data: product}); - - return { - product, @@ -1540,14 +1538,31 @@ Simplify product route to minimal implementation without cart functionality ` as const; ``` -### Step 13: Add basic styles +### Step 13: Update TypeScript configuration + +Remove Oxygen Workers type definitions since Express uses standard Node.js types + +#### File: /tsconfig.json + +```diff +@@ -26,7 +26,6 @@ + "skipLibCheck": true, + "baseUrl": ".", + "types": [ +- "@shopify/oxygen-workers-types", + "react-router", + "@shopify/hydrogen/react-router-types", + "vite/client" +``` + +### Step 14: Add basic styles Replace skeleton styles with minimal Express template styling #### File: /app/styles/app.css ```diff -@@ -1,594 +1,44 @@ +@@ -1,645 +1,54 @@ -:root { - --aside-width: 400px; - --cart-aside-summary-height-with-discount: 300px; @@ -1574,6 +1589,58 @@ Replace skeleton styles with minimal Express template styling - -/* -* -------------------------------------------------- +-* components/MockShopNotice +-* -------------------------------------------------- +-*/ +-.mock-shop-notice { +- background: var(--color-light); +- border: 1px solid var(--color-dark); +- border-left-width: 0.5rem; +- margin: 1rem 0; ++body { + padding: 0; +- +- .inner { +- padding: 0.875rem 1rem; +- } +- +- h2 { +- font-size: 1.6rem; +- font-weight: 700; +- line-height: 1.4; +- } +- +- p { +- font-size: 1rem; +- line-height: 1.4; +- margin-bottom: 0.5rem; +- } +- +- footer { +- background: rgba(0, 0, 0, 0.06); +- padding: 0.5rem 1rem; +- } +- +- .small { +- font-size: 0.875rem; +- line-height: 1.4; +- margin-bottom: 0.5rem; +- } +- +- code { +- background: rgba(0, 0, 0, 0.06); +- border-radius: 0.25rem; +- padding: 0.1em 0.3em; +- } +- +- a { +- text-decoration: underline; +- text-underline-offset: 6px; +- } +-} +- +-/* +-* -------------------------------------------------- -* components/Aside -* -------------------------------------------------- -*/ @@ -1604,9 +1671,22 @@ Replace skeleton styles with minimal Express template styling -} - -aside header h3 { -- margin: 0; --} -- + margin: 0; ++ background: rgb(245, 245, 241); ++ font-family: ++ system-ui, ++ -apple-system, ++ BlinkMacSystemFont, ++ 'Segoe UI', ++ Roboto, ++ Oxygen, ++ Ubuntu, ++ Cantarell, ++ 'Open Sans', ++ 'Helvetica Neue', ++ sans-serif; + } + -aside header .close { - font-weight: bold; - opacity: 0.8; @@ -1633,7 +1713,10 @@ Replace skeleton styles with minimal Express template styling -} - -aside p:last-child { -- margin: 0; ++h1, ++h2, ++p { + margin: 0; -} - -aside li { @@ -1644,15 +1727,14 @@ Replace skeleton styles with minimal Express template styling - position: absolute; - width: 1px; - height: 1px; -+body { padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; --} -- + } + -.overlay { - background: rgba(0, 0, 0, 0.2); - bottom: 0; @@ -1707,22 +1789,14 @@ Replace skeleton styles with minimal Express template styling -} - -button.reset > * { - margin: 0; -+ background: rgb(245, 245, 241); -+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, -+ Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - } - +- margin: 0; +-} +- -button.reset:not(:has(> *)) { - height: 1.5rem; - line-height: 1.5rem; -+h1, -+h2, -+p { -+ margin: 0; -+ padding: 0; - } - +-} +- -button.reset:hover:not(:has(> *)) { - text-decoration: underline; - cursor: pointer; @@ -2171,7 +2245,7 @@ Replace skeleton styles with minimal Express template styling + */ ``` -### Step 14: Update ESLint configuration +### Step 15: Update ESLint configuration Simplify ESLint configuration for Express template @@ -2431,7 +2505,7 @@ Simplify ESLint configuration for Express template \ No newline at end of file ``` -### Step 15: Install Express dependencies +### Step 16: Install Express dependencies Update dependencies and scripts for Express server deployment (add express, nodemon, compression, remove Oxygen packages) @@ -2439,7 +2513,7 @@ Update dependencies and scripts for Express server deployment (add express, node ```diff @@ -5,59 +5,52 @@ - "version": "2026.1.0", + "version": "2026.1.1", "type": "module", "scripts": { - "build": "shopify hydrogen build --codegen", @@ -2455,41 +2529,39 @@ Update dependencies and scripts for Express server deployment (add express, node }, "prettier": "@shopify/prettier-config", "dependencies": { -+ "@react-router/express": "7.12.0", -+ "@react-router/node": "7.12.0", -+ "@remix-run/eslint-config": "^2.16.1", - "@shopify/hydrogen": "2026.1.0", ++ "@react-router/express": "8.0.1", ++ "@react-router/node": "8.0.1", + "@shopify/hydrogen": "workspace:*", + "compression": "^1.7.4", + "cross-env": "^7.0.3", -+ "express": "^4.19.2", ++ "express": "^4.22.2", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", "isbot": "^5.1.22", + "morgan": "^1.10.0", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-router": "7.12.0", - "react-router-dom": "7.12.0" + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "8.0.1" }, "devDependencies": { - "@eslint/compat": "^1.2.5", - "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@graphql-codegen/cli": "5.0.2", - "@react-router/dev": "7.12.0", - "@react-router/fs-routes": "7.12.0", - "@shopify/cli": "3.85.4", - "@shopify/hydrogen-codegen": "^0.3.3", -- "@shopify/mini-oxygen": "^4.0.0", + "@react-router/dev": "8.0.1", + "@react-router/fs-routes": "8.0.1", + "@shopify/cli": "3.93.2", + "@shopify/hydrogen-codegen": "workspace:*", +- "@shopify/mini-oxygen": "workspace:*", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "^1.1.2", +- "@shopify/prettier-config": "catalog:", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", + "@types/compression": "^1.7.2", + "@types/express": "^4.17.17", + "@types/morgan": "^1.9.4", - "@types/react": "^18.2.22", - "@types/react-dom": "^18.2.7", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", @@ -2508,24 +2580,27 @@ Update dependencies and scripts for Express server deployment (add express, node + "nodemon": "^2.0.22", + "npm-run-all": "^4.1.5", "typescript": "^5.9.2", - "vite": "^6.2.4", - "vite-tsconfig-paths": "^4.3.1" + "vite": "^8.0.1" }, "engines": { -- "node": ">=18.0.0" -+ "node": ">=20.0.0 <22.0.0" +- "node": "^22.22.0 || ^24" ++ "node": "^22.22.0 || ^24" } } ``` -### Step 16: Configure Vite for Node.js +### Step 17: Configure Vite for Node.js Configure Vite for Express deployment with Node.js module externalization #### File: /vite.config.ts ```diff -@@ -5,13 +5,15 @@ import {reactRouter} from '@react-router/dev/vite'; +@@ -1,17 +1,18 @@ + import {defineConfig} from 'vite'; + import {hydrogen} from '@shopify/hydrogen/vite'; +-import {oxygen} from '@shopify/mini-oxygen/vite'; + import {reactRouter} from '@react-router/dev/vite'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ @@ -2542,7 +2617,7 @@ Configure Vite for Express deployment with Node.js module externalization optimizeDeps: { /** * Include dependencies here if they throw CJS<>ESM errors. -@@ -23,10 +25,7 @@ export default defineConfig({ +@@ -23,14 +24,7 @@ export default defineConfig({ * Include 'example-dep' in the array below. * @see https://vitejs.dev/config/dep-optimization-options */ diff --git a/cookbook/llms/gtm.prompt.md b/cookbook/llms/gtm.prompt.md index 93bb2e243f..08769240d5 100644 --- a/cookbook/llms/gtm.prompt.md +++ b/cookbook/llms/gtm.prompt.md @@ -39,6 +39,7 @@ This recipe integrates Google Tag Manager (GTM) into your Hydrogen storefront, e Hydrogen includes built-in support for the Customer Privacy API, a browser-based JavaScript API that you can use to display cookie-consent banners and verify data processing permissions. Key features: + - GTM script integration with proper CSP nonce support - Content Security Policy configuration for GTM domains - Analytics integration for product views via dataLayer @@ -48,6 +49,7 @@ Key features: - Support for various analytics events by subscribing to event types using the GoogleTagManager component The recipe includes: + 1. Content Security Policy updates in entry.server.tsx for GTM domains 2. GTM script tags in the head and body sections 3. GoogleTagManager component that subscribes to analytics events @@ -61,12 +63,14 @@ The recipe includes: ## Requirements Prerequisites: + - A Google Tag Manager account and container ID - Customer privacy settings configured in your Shopify admin (for cookie consent) - Basic understanding of GTM and dataLayer events - Knowledge of Shopify's analytics events To enable cookie consent: + 1. In your Shopify admin, go to **Settings** → **Customer Privacy** → **Cookie Banner**. 2. Configure region visibility for the cookie banner. 3. Customize the banner's appearance and position as needed. @@ -83,16 +87,16 @@ Update the README file with GTM-specific documentation and setup instructions. #### File: /README.md -~~~diff +````diff @@ -1,6 +1,6 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Google Tag Manager (GTM) - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +This Hydrogen template demonstrates how to implement Google Tag Manager with analytics integration. Hydrogen supports both Shopify analytics and third-party services with built-in support for the [Customer Privacy API](https://shopify.dev/docs/api/customer-privacy). - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,18 +16,67 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -101,18 +105,18 @@ Update the README file with GTM-specific documentation and setup instructions. +- **Google Tag Manager integration** +- **Analytics.Provider setup** +- **Customer Privacy API support** - + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- Google Tag Manager account with container ID - + ```bash npm create @shopify/hydrogen@latest ``` - + +## Google Tag Manager Setup + +### 1. Enable Customer Privacy / Cookie Consent Banner @@ -160,16 +164,16 @@ Update the README file with GTM-specific documentation and setup instructions. +``` + ## Building for production - + ```bash @@ -42,4 +91,4 @@ npm run dev - + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +```` ### Step 2: Add GTM domains to Content Security Policy @@ -177,7 +181,7 @@ Configure CSP headers to allow Google Tag Manager and Analytics scripts. #### File: /app/entry.server.tsx -~~~diff +```diff @@ -15,6 +15,24 @@ export default async function handleRequest( context: HydrogenRouterContextProvider, ) { @@ -203,7 +207,7 @@ Configure CSP headers to allow Google Tag Manager and Analytics scripts. shop: { checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, storeDomain: context.env.PUBLIC_STORE_DOMAIN, -~~~ +``` ### Step 3: Create the analytics component @@ -211,7 +215,7 @@ Build a component that subscribes to Hydrogen analytics events and pushes them t #### File: [GoogleTagManager.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/gtm/ingredients/templates/skeleton/app/components/GoogleTagManager.tsx) -~~~tsx +```tsx import {useAnalytics} from '@shopify/hydrogen'; import {useEffect} from 'react'; @@ -228,7 +232,7 @@ export function GoogleTagManager() { useEffect(() => { subscribe('product_viewed', () => { // Triggering a custom event in GTM when a product is viewed - window.dataLayer.push({'event': 'viewed-product'}); + window.dataLayer.push({event: 'viewed-product'}); }); ready(); @@ -236,7 +240,7 @@ export function GoogleTagManager() { return null; } -~~~ +``` ### Step 4: Add GTM scripts to the app @@ -244,7 +248,7 @@ Insert Google Tag Manager tracking code in the head and body sections. #### File: /app/root.tsx -~~~diff +```diff @@ -1,4 +1,4 @@ -import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen'; +import {Analytics, getShopAnalytics, useNonce, Script} from '@shopify/hydrogen'; @@ -256,9 +260,9 @@ Insert Google Tag Manager tracking code in the head and body sections. import appStyles from '~/styles/app.css?url'; import {PageLayout} from './components/PageLayout'; +import {GoogleTagManager} from '~/components/GoogleTagManager'; - + export type RootLoader = typeof loader; - + @@ -153,8 +154,32 @@ export function Layout({children}: {children?: React.ReactNode}) { @@ -301,51 +305,6 @@ Insert Google Tag Manager tracking code in the head and body sections. ); } -~~~ - -### Step 4: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - - \ No newline at end of file +``` + + diff --git a/cookbook/llms/infinite-scroll.prompt.md b/cookbook/llms/infinite-scroll.prompt.md index 6454eb9e25..19c3086d16 100644 --- a/cookbook/llms/infinite-scroll.prompt.md +++ b/cookbook/llms/infinite-scroll.prompt.md @@ -37,6 +37,7 @@ Here's the infinite-scroll recipe for the base Hydrogen skeleton template: This recipe implements infinite scroll functionality on collection pages using the Intersection Observer API. Key features: + - Automatic loading when "Load more" button comes into view using Intersection Observer API - Preserves browser history and URL state (replace mode to avoid clutter) - Maintains scroll position during navigation @@ -50,8 +51,6 @@ Key features: ## New files added to the template by this recipe - - ## Steps ### Step 1: Document infinite scroll in the README @@ -60,18 +59,18 @@ Update the README file with infinite scroll documentation and implementation det #### File: /README.md -~~~diff +````diff @@ -1,6 +1,8 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Infinite Scroll - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates infinite scroll pagination for collection pages. Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates infinite scroll pagination for collection pages. Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to implement a seamless browsing experience where products automatically load as users scroll down, replacing traditional pagination with continuous content loading. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,7 +18,28 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -99,19 +98,19 @@ Update the README file with infinite scroll documentation and implementation det +- Maintains scroll position during navigation +- Clean URL updates using replace mode +- No history cluttering from pagination - + ## Getting started - + @@ -28,6 +51,25 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov npm create @shopify/hydrogen@latest ``` - + +## Implementation Details + +The infinite scroll implementation uses: +- React's `useEffect` hook for scroll detection +- Intersection Observer API for viewport detection -+- Remix's navigation for URL updates ++- React Router's navigation for URL updates +- Shopify's Pagination component as the base + +### Key Components @@ -126,12 +125,12 @@ Update the README file with infinite scroll documentation and implementation det +``` + ## Building for production - + ```bash @@ -40,6 +82,14 @@ npm run build npm run dev ``` - + +## Customization + +You can adjust the infinite scroll behavior by: @@ -141,11 +140,11 @@ Update the README file with infinite scroll documentation and implementation det +- Adding scroll-to-top functionality + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +```` ### Step 2: Add infinite scroll to collections @@ -153,7 +152,7 @@ Implement automatic loading with Intersection Observer when users scroll to the #### File: /app/routes/collections.$handle.tsx -~~~diff +```diff @@ -1,9 +1,14 @@ -import {redirect, useLoaderData} from 'react-router'; +import {redirect, useLoaderData, useNavigate} from 'react-router'; @@ -170,14 +169,14 @@ Implement automatic loading with Intersection Observer when users scroll to the +import {useEffect} from 'react'; +import {useInView} from 'react-intersection-observer'; import type {ProductItemFragment} from 'storefrontapi.generated'; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { @@ -67,23 +72,41 @@ function loadDeferredData({context}: Route.LoaderArgs) { - + export default function Collection() { const {collection} = useLoaderData(); + const {ref, inView} = useInView(); - + return (

{collection.title}

@@ -228,7 +227,7 @@ Implement automatic loading with Intersection Observer when users scroll to the @@ -96,6 +119,47 @@ export default function Collection() { ); } - + +// @description ProductsGrid component with infinite scroll functionality +function ProductsGrid({ + products, @@ -256,7 +255,7 @@ Implement automatic loading with Intersection Observer when users scroll to the + }, [inView, navigate, state, nextPageUrl, hasNextPage]); + + return ( -+
++
+ {products.map((product, index) => { + return ( + \ No newline at end of file + diff --git a/cookbook/llms/legacy-customer-account-flow.prompt.md b/cookbook/llms/legacy-customer-account-flow.prompt.md index b96d3ecb27..1b6f559d1c 100644 --- a/cookbook/llms/legacy-customer-account-flow.prompt.md +++ b/cookbook/llms/legacy-customer-account-flow.prompt.md @@ -17,11 +17,12 @@ Implement legacy customer account authentication using the Storefront API # User Intent Recognition + - How do I implement customer accounts without the Customer Account API? - How to use legacy customer authentication in Hydrogen? - How to migrate from Customer Account API to Storefront API? - How to implement form-based login in Hydrogen? - + # Troubleshooting @@ -52,6 +53,7 @@ that haven't migrated to the new Customer Account API yet or need to maintain co with existing customer authentication systems. Key features: + - Full customer registration and login flow with form-based authentication - Password recovery and reset functionality via email - Account activation via email tokens @@ -62,6 +64,7 @@ Key features: - Secure server-side rendering for all account routes Technical details: + - Customer access tokens are stored in session cookies for authentication. - The login/register/recover routes use the account\_ prefix to avoid layout nesting. - Account data routes use the `account.` prefix to inherit the account layout @@ -82,10 +85,10 @@ Technical details: ## New files added to the template by this recipe -- app/routes/account_.activate.$id.$activationToken.tsx -- app/routes/account_.recover.tsx -- app/routes/account_.register.tsx -- app/routes/account_.reset.$id.$resetToken.tsx +- app/routes/account\_.activate.$id.$activationToken.tsx +- app/routes/account\_.recover.tsx +- app/routes/account\_.register.tsx +- app/routes/account\_.reset.$id.$resetToken.tsx ## Steps @@ -95,24 +98,24 @@ Update the README file to document the legacy customer account flow. #### File: /README.md -~~~diff +```diff @@ -1,10 +1,26 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Legacy Customer Account Flow - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with legacy customer account authentication flow. - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with legacy customer account authentication flow. + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) - + [Get familiar with React Router](https://reactrouter.com/) + +## Legacy Customer Account Flow + +🚨 **Caution**: This legacy authentication strategy will not maintain authentication between your Hydrogen storefront and checkout; for better support, use either the New Customer Accounts strategy or Multipass. + +This template includes the legacy customer account flow made with [Storefront API](https://shopify.dev/docs/api/storefront) which provides: +- Full customer registration and login flow -+- Password recovery and reset functionality ++- Password recovery and reset functionality +- Account activation via email +- Customer profile management +- Order history and address management @@ -123,9 +126,9 @@ Update the README file to document the legacy customer account flow. +🗒️ Read about the Customer Account API: [https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores](https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores) + ## What's included - - - Remix -~~~ + + - React Router +``` ### Step 2: Add account link to header navigation @@ -133,7 +136,7 @@ Add an account link to the header navigation. #### File: /app/components/Header.tsx -~~~diff +```diff @@ -11,7 +11,8 @@ import {useAside} from '~/components/Aside'; interface HeaderProps { header: HeaderQuery; @@ -143,7 +146,7 @@ Add an account link to the header navigation. + isLoggedIn: boolean; publicStoreDomain: string; } - + @@ -103,11 +104,8 @@ function HeaderCtas({
@@ -602,15 +603,15 @@ Add customer access token validation to the root loader. + + return {isLoggedIn, headers}; +} -~~~ +```` ### Step 7: Create customer registration flow Add a customer registration form. -#### File: [account_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.register.tsx) +#### File: [account\_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.register.tsx) -~~~tsx +```tsx import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.register'; import type {CustomerCreateMutation} from 'storefrontapi.generated'; @@ -622,7 +623,8 @@ type ActionResponse = { | null; }; -export const headers: Route.HeadersFunction = ({actionHeaders}) => actionHeaders; +export const headers: Route.HeadersFunction = ({actionHeaders}) => + actionHeaders; export async function loader({context}: Route.LoaderArgs) { const customerAccessToken = await context.session.get('customerAccessToken'); @@ -814,8 +816,7 @@ const REGISTER_LOGIN_MUTATION = `#graphql } } ` as const; - -~~~ +``` ### Step 8: Handle unauthenticated account routes @@ -823,11 +824,11 @@ Convert the catch-all route to use Storefront API authentication. #### File: /app/routes/account.$.tsx -~~~diff +```diff @@ -1,9 +1,9 @@ import {redirect} from 'react-router'; import type {Route} from './+types/account.$'; - + -// fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); @@ -838,15 +839,15 @@ Convert the catch-all route to use Storefront API authentication. + } + return redirect('/account/login'); } -~~~ +``` ### Step 9: Build password reset flow Add a password reset form with token validation. -#### File: [account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) +#### File: [account\_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) -~~~tsx +```tsx import {Form, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.reset.$id.$resetToken'; @@ -979,8 +980,7 @@ const CUSTOMER_RESET_MUTATION = `#graphql } } ` as const; - -~~~ +``` ### Step 10: Add address management @@ -988,7 +988,7 @@ Convert address management to use Storefront API mutations. #### File: /app/routes/account.addresses.tsx -~~~diff +```diff @@ -1,22 +1,14 @@ -import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; -import type { @@ -1012,12 +1012,12 @@ Convert address management to use Storefront API mutations. - DELETE_ADDRESS_MUTATION, - CREATE_ADDRESS_MUTATION, -} from '~/graphql/customer-account/CustomerAddressMutations'; - + export type ActionResponse = { addressId?: string | null; @@ -32,13 +24,16 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -1028,17 +1028,17 @@ Convert address management to use Storefront API mutations. + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {storefront, session} = context; - + try { const form = await request.formData(); @@ -50,31 +45,26 @@ export async function action({request, context}: Route.ActionArgs) { 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) { @@ -1053,7 +1053,7 @@ Convert address management to use Storefront API mutations. + return data({error: {[addressId]: 'Unauthorized'}}, {status: 401}); } + const {accessToken} = customerAccessToken; - + const defaultAddress = form.has('defaultAddress') ? String(form.get('defaultAddress')) === 'on' - : false; @@ -1076,7 +1076,7 @@ Convert address management to use Storefront API mutations. + 'province', 'zip', ]; - + @@ -89,170 +79,134 @@ export async function action({request, context}: Route.ActionArgs) { case 'POST': { // handle new address creation @@ -1093,14 +1093,14 @@ Convert address management to use Storefront API mutations. + variables: {customerAccessToken: accessToken, address}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressCreate?.customerUserErrors?.length) { + const error = customerAddressCreate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressCreate?.userErrors?.length) { - throw new Error(data?.customerAddressCreate?.userErrors[0].message); - } @@ -1160,7 +1160,7 @@ Convert address management to use Storefront API mutations. + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'PUT': { // handle address updates try { @@ -1178,7 +1178,7 @@ Convert address management to use Storefront API mutations. }, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + const updatedAddress = customerAddressUpdate?.customerAddress; @@ -1187,7 +1187,7 @@ Convert address management to use Storefront API mutations. + const error = customerAddressUpdate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressUpdate?.userErrors?.length) { - throw new Error(data?.customerAddressUpdate?.userErrors[0].message); - } @@ -1237,7 +1237,7 @@ Convert address management to use Storefront API mutations. + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'DELETE': { // handles address deletion try { @@ -1252,7 +1252,7 @@ Convert address management to use Storefront API mutations. + variables: {customerAccessToken: accessToken, id: addressId}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressDelete?.customerUserErrors?.length) { @@ -1288,7 +1288,7 @@ Convert address management to use Storefront API mutations. + return data({error: {[addressId]: error}}, {status: 400}); } } - + default: { return data( {error: {[addressId]: 'Method not allowed'}}, @@ -1318,7 +1318,7 @@ Convert address management to use Storefront API mutations. + return data({error}, {status: 400}); } } - + @@ -291,21 +245,17 @@ function NewAddressForm() { address2: '', city: '', @@ -1335,7 +1335,7 @@ Convert address management to use Storefront API mutations. zip: '', - } as CustomerAddressInput; + } as AddressFragment; - + return ( - @@ -360,26 +309,26 @@ function ExistingAddresses({ } - + export function AddressForm({ - addressId, address, @@ -1545,7 +1545,7 @@ Convert address management to use Storefront API mutations. + } + } +` as const; -~~~ +``` ### Step 11: Show order details @@ -1553,7 +1553,7 @@ Convert the order details page to use Storefront API queries. #### File: /app/routes/account.orders.$id.tsx -~~~diff +```diff @@ -1,67 +1,50 @@ -import {redirect, useLoaderData} from 'react-router'; +import {Link, useLoaderData, redirect} from 'react-router'; @@ -1567,11 +1567,11 @@ Convert the order details page to use Storefront API queries. -import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery'; + DiscountApplicationFragment, +} from 'storefrontapi.generated'; - - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Order ${data?.order?.name}`}]; + + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; - + export async function loader({params, context}: Route.LoaderArgs) { - const {customerAccount} = context; + const {session, storefront} = context; @@ -1579,7 +1579,7 @@ Convert the order details page to use Storefront API queries. 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, { @@ -1589,29 +1589,29 @@ Convert the order details page to use Storefront API queries. - }, - }); + const customerAccessToken = await session.get('customerAccessToken'); - + - if (errors?.length || !data?.order) { - throw new Error('Order not found'); + if (!customerAccessToken) { + return redirect('/account/login'); } - + - const {order} = data; + const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, { + variables: {orderId}, + }); - + - // Extract line items directly from nodes array - const lineItems = order.lineItems.nodes; + if (!order || !('lineItems' in order)) { + throw new Response('Order not found', {status: 404}); + } - + - // Extract discount applications directly from nodes array - const discountApplications = order.discountApplications.nodes; + const lineItems = flattenConnection(order.lineItems) as OrderLineItemFullFragment[]; + const discountApplications = flattenConnection(order.discountApplications) as DiscountApplicationFragment[]; - + - // Get fulfillment status from first fulfillment node - const fulfillmentStatus = order.fulfillments.nodes[0]?.status ?? 'N/A'; - @@ -1620,17 +1620,17 @@ Convert the order details page to use Storefront API queries. - - // Type guard for MoneyV2 discount + const firstDiscount = discountApplications[0]; -+ ++ const discountValue = - firstDiscount?.__typename === 'MoneyV2' - ? (firstDiscount as Extract< - typeof firstDiscount, - {__typename: 'MoneyV2'} - >) -+ firstDiscount?.value?.__typename === 'MoneyV2' -+ ? firstDiscount.value ++ firstDiscount?.value?.__typename === 'MoneyV2' ++ ? firstDiscount.value : null; - + - // Type guard for percentage discount const discountPercentage = - firstDiscount?.__typename === 'PricingPercentageValue' @@ -1643,7 +1643,7 @@ Convert the order details page to use Storefront API queries. + firstDiscount?.value?.__typename === 'PricingPercentageValue' + ? firstDiscount.value.percentage : null; - + return { @@ -69,25 +52,16 @@ export async function loader({params, context}: Route.LoaderArgs) { lineItems, @@ -1652,7 +1652,7 @@ Convert the order details page to use Storefront API queries. - fulfillmentStatus, }; } - + export default function OrderRoute() { - const { - order, @@ -1744,7 +1744,7 @@ Convert the order details page to use Storefront API queries.

@@ -196,27 +171,145 @@ export default function OrderRoute() { - + function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { return ( - @@ -1898,15 +1898,15 @@ Convert the order details page to use Storefront API queries. + } + } +` as const; -~~~ +``` ### Step 12: Display order history Convert the orders list to use the Storefront API with pagination. -#### File: /app/routes/account.orders._index.tsx +#### File: /app/routes/account.orders.\_index.tsx -~~~diff +```diff @@ -1,222 +1,184 @@ -import { - Link, @@ -1935,7 +1935,7 @@ Convert the orders list to use the Storefront API with pagination. -} from 'customer-accountapi.generated'; +} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; - + -type OrdersLoaderData = { - customer: CustomerOrdersFragment; - filters: OrderFilterParams; @@ -1944,14 +1944,14 @@ Convert the orders list to use the Storefront API with pagination. 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 {session, storefront} = context; - + - const url = new URL(request.url); - const filters = parseOrderFilters(url.searchParams); - const query = buildOrderSearchQuery(filters); @@ -1970,7 +1970,7 @@ Convert the orders list to use the Storefront API with pagination. + if (!customerAccessToken?.accessToken) { + return redirect('/account/login'); } - + - return {customer: data.customer, filters}; + try { + const paginationVariables = getPaginationVariables(request, { @@ -1999,18 +1999,18 @@ Convert the orders list to use the Storefront API with pagination. + return data({error: String(error)}, {status: 400}); + } } - + export default function Orders() { - const {customer, filters} = useLoaderData(); + const data = useLoaderData(); -+ ++ + if ('error' in data) { + return
Error: {data.error}
; + } -+ ++ + const {customer} = data; const {orders} = customer; - + return (
- @@ -2019,7 +2019,7 @@ Convert the orders list to use the Storefront API with pagination.
); } - + -function OrdersTable({ - orders, - filters, @@ -2044,7 +2044,7 @@ Convert the orders list to use the Storefront API with pagination.
); } - + -function EmptyOrders({hasFilters = false}: {hasFilters?: boolean}) { +function EmptyOrders() { return ( @@ -2074,7 +2074,7 @@ Convert the orders list to use the Storefront API with pagination.
); } - + -function OrderSearchForm({ - currentFilters, -}: { @@ -2255,7 +2255,7 @@ Convert the orders list to use the Storefront API with pagination. + } +` as const; \ No newline at end of file -~~~ +``` ### Step 13: Build customer profile page @@ -2263,7 +2263,7 @@ Convert the customer profile page to use Storefront API queries. #### File: /app/routes/account.profile.tsx -~~~diff +```diff @@ -1,12 +1,12 @@ -import type {CustomerFragment} from 'customer-accountapi.generated'; -import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types'; @@ -2280,10 +2280,10 @@ Convert the customer profile page to use Storefront API queries. + redirect, } from 'react-router'; import type {Route} from './+types/account.profile'; - + @@ -20,23 +20,40 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -2293,21 +2293,21 @@ Convert the customer profile page to use Storefront API queries. + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {session, storefront} = context; - + if (request.method !== 'PUT') { return data({error: 'Method not allowed'}, {status: 405}); } - + const form = await request.formData(); + const customerAccessToken = await session.get('customerAccessToken'); + if (!customerAccessToken) { + return data({error: 'Unauthorized'}, {status: 401}); + } - + try { + const password = getPassword(form); const customer: CustomerUpdateInput = {}; @@ -2329,7 +2329,7 @@ Convert the customer profile page to use Storefront API queries. @@ -46,36 +63,37 @@ export async function action({request, context}: Route.ActionArgs) { } } - + + if (password) { + customer.password = password; + } @@ -2349,7 +2349,7 @@ Convert the customer profile page to use Storefront API queries. }, - ); + }); - + - if (errors?.length) { - throw new Error(errors[0].message); + // check for mutation errors @@ -2359,7 +2359,7 @@ Convert the customer profile page to use Storefront API queries. + {status: 400}, + ); } - + - if (!data?.customerUpdate?.customer) { - throw new Error('Customer profile update failed.'); + // update session with the updated access token @@ -2369,7 +2369,7 @@ Convert the customer profile page to use Storefront API queries. + updated.customerUpdate?.customerAccessToken, + ); } - + - return { - error: null, - customer: data?.customerUpdate?.customer, @@ -2385,7 +2385,7 @@ Convert the customer profile page to use Storefront API queries. + return data({error: error.message, customer: null}, {status: 400}); } } - + @@ -114,6 +132,64 @@ export default function AccountProfile() { defaultValue={customer.lastName ?? ''} minLength={2} @@ -2500,52 +2500,7 @@ Convert the customer profile page to use Storefront API queries. + } + } +` as const; -~~~ - -### Step 13: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ### Step 14: Update account layout for session auth @@ -2553,7 +2508,7 @@ Convert the account layout to use session-based authentication. #### File: /app/routes/account.tsx -~~~diff +```diff @@ -1,42 +1,109 @@ import { - data as remixData, @@ -2567,11 +2522,11 @@ Convert the account layout to use session-based authentication. import type {Route} from './+types/account'; -import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import type {CustomerFragment} from 'storefrontapi.generated'; - + export function shouldRevalidate() { return true; } - + -export async function loader({context}: Route.LoaderArgs) { - const {customerAccount} = context; - const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { @@ -2581,7 +2536,7 @@ Convert the account layout to use session-based authentication. - }); +export const headers: Route.HeadersFunction = ({loaderHeaders}) => + loaderHeaders; - + - if (errors?.length || !data?.customer) { - throw new Error('Customer not found'); +export async function loader({request, context}: Route.LoaderArgs) { @@ -2599,7 +2554,7 @@ Convert the account layout to use session-based authentication. + session.unset('customerAccessToken'); + return redirect('/account/login'); } - + - return remixData( - {customer: data.customer}, - { @@ -2670,7 +2625,7 @@ Convert the account layout to use session-based authentication. + ); } - + -export default function AccountLayout() { - const {customer} = useLoaderData(); - @@ -2746,19 +2701,19 @@ Convert the account layout to use session-based authentication. + } + ${CUSTOMER_FRAGMENT} +` as const; -~~~ +``` ### Step 15: Create login page Replace the Customer Account API login with the Storefront API form. -#### File: /app/routes/account_.login.tsx +#### File: /app/routes/account\_.login.tsx -~~~diff +```diff @@ -1,17 +1,139 @@ +import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.login'; - + -export async function loader({request, context}: Route.LoaderArgs) { - const url = new URL(request.url); - const acrValues = url.searchParams.get('acr_values') || undefined; @@ -2768,7 +2723,7 @@ Replace the Customer Account API login with the Storefront API form. +type ActionResponse = { + error: string | null; +}; - + - return context.customerAccount.login({ - countryCode: context.storefront.i18n.country, - acrValues, @@ -2908,20 +2863,20 @@ Replace the Customer Account API login with the Storefront API form. + } + } +` as const; -~~~ +``` ### Step 16: Handle logout and session cleanup Replace the Customer Account API logout with a session cleanup. -#### File: /app/routes/account_.logout.tsx +#### File: /app/routes/account\_.logout.tsx -~~~diff +```diff @@ -1,11 +1,25 @@ -import {redirect} from 'react-router'; +import {data, redirect} from 'react-router'; import type {Route} from './+types/account_.logout'; - + -// if we don't implement this, /account/logout will get caught by account.$.tsx to do login +export const meta: Route.MetaFunction = () => { + return [{title: 'Logout'}]; @@ -2941,7 +2896,7 @@ Replace the Customer Account API logout with a session cleanup. + return redirect('/'); } - + -export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); -} @@ -2949,6 +2904,6 @@ Replace the Customer Account API logout with a session cleanup. + return null; +} \ No newline at end of file -~~~ +``` - \ No newline at end of file + diff --git a/cookbook/llms/markets.prompt.md b/cookbook/llms/markets.prompt.md index d3aa684c84..3059f71418 100644 --- a/cookbook/llms/markets.prompt.md +++ b/cookbook/llms/markets.prompt.md @@ -17,11 +17,12 @@ This recipe adds basic localization support to your Hydrogen app using Shopify M # User Intent Recognition + - How do I add localization support to my Hydrogen app? - How do I add a country selector to my Hydrogen app? - Set up Shopify Markets in Hydrogen - Help me segment my customers based on their location - + # Troubleshooting @@ -61,16 +62,16 @@ market information visible in the URL. This provides two key benefits: 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 + - **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 + - **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 @@ -87,38 +88,38 @@ In this recipe, we'll implement **path-based localization**. ## Requirements - Set up your store's regions and languages using [Shopify -Markets](https://help.shopify.com/en/manual/markets). + Markets](https://help.shopify.com/en/manual/markets). - Configure your products appropriately for each market. - Make sure your Hydrogen app is configured to use a default `language` and -`country code`. They will be used as the fallback when no market is explicitly -selected. + `country code`. They will be used as the fallback when no market is explicitly + selected. ## New files added to the template by this recipe - app/components/CountrySelector.tsx - app/components/Link.tsx - app/lib/i18n.ts -- app/routes/($locale)._index.tsx +- app/routes/($locale).\_index.tsx - app/routes/($locale).account.$.tsx -- app/routes/($locale).account._index.tsx +- app/routes/($locale).account.\_index.tsx - app/routes/($locale).account.addresses.tsx - app/routes/($locale).account.orders.$id.tsx -- app/routes/($locale).account.orders._index.tsx +- app/routes/($locale).account.orders.\_index.tsx - app/routes/($locale).account.profile.tsx - app/routes/($locale).account.tsx -- app/routes/($locale).account_.authorize.tsx -- app/routes/($locale).account_.login.tsx -- app/routes/($locale).account_.logout.tsx +- app/routes/($locale).account\_.authorize.tsx +- app/routes/($locale).account\_.login.tsx +- app/routes/($locale).account\_.logout.tsx - app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx -- app/routes/($locale).blogs.$blogHandle._index.tsx -- app/routes/($locale).blogs._index.tsx +- app/routes/($locale).blogs.$blogHandle.\_index.tsx +- app/routes/($locale).blogs.\_index.tsx - app/routes/($locale).cart.tsx - app/routes/($locale).collections.$handle.tsx -- app/routes/($locale).collections._index.tsx +- app/routes/($locale).collections.\_index.tsx - app/routes/($locale).collections.all.tsx - app/routes/($locale).pages.$handle.tsx - app/routes/($locale).policies.$handle.tsx -- app/routes/($locale).policies._index.tsx +- app/routes/($locale).policies.\_index.tsx - app/routes/($locale).products.$handle.tsx - app/routes/($locale).search.tsx - app/routes/($locale).tsx @@ -135,7 +136,7 @@ Update cart line items to use the unified Link component for product links. ##### File: /app/components/CartLineItem.tsx -~~~diff +```diff @@ -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'; @@ -145,7 +146,7 @@ Update cart line items to use the unified Link component for product links. import {ProductPrice} from './ProductPrice'; import {useAside} from './Aside'; import type { -~~~ +``` #### Step 1.2: Update AddToCartButton to use locale-aware cart route @@ -153,12 +154,12 @@ Update the AddToCartButton component to post to the localized cart route (e.g., ##### File: /app/components/AddToCartButton.tsx -~~~diff +```diff @@ -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({ @@ -173,7 +174,7 @@ Update the AddToCartButton component to post to the localized cart route (e.g., {(fetcher: FetcherWithComponents) => ( <> ); } - -~~~ +``` #### Step 1.4: Create a unified locale-aware Link component Create a single Link component that handles both regular links and navigation links. This component automatically: + - Prepends the current locale to paths - Supports variant="nav" for navigation links with active states - Cleans invalid locale prefixes from menu URLs @@ -279,7 +280,7 @@ This component automatically: ##### File: [Link.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx) -~~~tsx +```tsx import type {LinkProps, NavLinkProps} from 'react-router'; import {Link as ReactLink, NavLink as ReactNavLink} from 'react-router'; import {useLocalizedPath, cleanPath} from '../lib/i18n'; @@ -290,31 +291,33 @@ type BaseProps = { preservePath?: boolean; }; -type LinkVariantProps = BaseProps & LinkProps & { - variant?: never; -}; +type LinkVariantProps = BaseProps & + LinkProps & { + variant?: never; + }; -type NavLinkVariantProps = BaseProps & NavLinkProps & { - variant: 'nav'; -}; +type NavLinkVariantProps = BaseProps & + NavLinkProps & { + variant: 'nav'; + }; export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; /** * Locale-aware Link component that handles both regular and navigation links - * + * * @example * // Regular link (auto-adds current locale) * Products - * + * * @example * // Navigation link with active styles * About - * + * * @example * // Switch locale while preserving current path * Français - * + * * @example * // Link to specific locale * Canadian Products @@ -322,7 +325,7 @@ export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; 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('://')) { @@ -334,19 +337,20 @@ export function Link(props: ExtendedLinkProps) { } to = cleanPath(to); } - + to = useLocalizedPath(to, locale, preservePath); - + if (variant === 'nav') { return ; } return ; } -~~~ +``` #### Step 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 @@ -357,7 +361,7 @@ Create a centralized i18n module that includes: ##### File: [i18n.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/lib/i18n.ts) -~~~ts +```ts import {useMatches, useLocation} from 'react-router'; import type { CountryCode as CustomerCountryCode, @@ -568,8 +572,7 @@ export function useLocalizedPath( // Add current locale to path return normalizePrefix(currentLocale?.pathPrefix || '') + to; } - -~~~ +``` #### Step 1.6: Update ProductItem to use locale-aware Link @@ -578,7 +581,7 @@ This ensures all product links automatically include the correct locale prefix. ##### File: /app/components/ProductItem.tsx -~~~diff +```diff @@ -1,4 +1,3 @@ -import {Link} from 'react-router'; import {Image, Money} from '@shopify/hydrogen'; @@ -589,10 +592,10 @@ This ensures all product links automatically include the correct locale prefix. } from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {Link} from './Link'; - + export function ProductItem({ product, -~~~ +``` #### Step 1.7: Add the selected locale to the context @@ -600,25 +603,25 @@ Detect the locale from the URL path, and add it to the HydrogenContext. ##### File: /app/lib/context.ts -~~~diff -@@ -1,6 +1,7 @@ - import {createHydrogenContext} from '@shopify/hydrogen'; +```diff +@@ -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 = { -@@ -40,6 +41,8 @@ export async function createHydrogenRouterContext( + // Additional context for custom properties, CMS clients, 3P SDKs, etc. +@@ -42,6 +43,8 @@ export async function createHydrogenRouterContext( AppSession.init(request, [env.SESSION_SECRET]), ]); - + + const i18n = getLocaleFromRequest(request); + const hydrogenContext = createHydrogenContext( { env, -@@ -47,8 +50,7 @@ export async function createHydrogenRouterContext( +@@ -49,8 +52,7 @@ export async function createHydrogenRouterContext( cache, waitUntil, session, @@ -628,7 +631,7 @@ Detect the locale from the URL path, and add it to the HydrogenContext. cart: { queryFragment: CART_QUERY_FRAGMENT, }, -~~~ +``` #### Step 1.8: Update Header with CountrySelector and locale-aware Links @@ -639,7 +642,7 @@ Menu URLs are automatically cleaned of invalid locale prefixes. ##### File: /app/components/Header.tsx -~~~diff +```diff @@ -1,5 +1,6 @@ import {Suspense} from 'react'; -import {Await, NavLink, useAsyncValue} from 'react-router'; @@ -653,7 +656,7 @@ Menu URLs are automatically cleaned of invalid locale prefixes. 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({ @@ -723,27 +726,27 @@ Menu URLs are automatically cleaned of invalid locale prefixes. -~~~ +``` #### Step 1.9: 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. + when the locale changes. ##### File: /app/root.tsx -~~~diff -@@ -77,6 +77,7 @@ export async function loader(args: Route.LoaderArgs) { +```diff +@@ -85,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) { return { ...deferredData, ...criticalData, -+ selectedLocale: args.context.storefront.i18n, ++ selectedLocale: args.context.get(hydrogenContext.storefront).i18n, publicStoreDomain: env.PUBLIC_STORE_DOMAIN, shop: getShopAnalytics({ storefront, -@@ -176,7 +177,10 @@ export default function App() { +@@ -183,7 +184,10 @@ export default function App() { shop={data.shop} consent={data.consent} > @@ -755,7 +758,7 @@ when the locale changes. -~~~ +``` ### Step 2: Localizing the individual routes @@ -767,7 +770,7 @@ Replace all Link imports to use the unified locale-aware `Link` component for co ##### File: /app/components/CartMain.tsx -~~~diff +```diff @@ -1,5 +1,5 @@ import {useOptimisticCart} from '@shopify/hydrogen'; -import {Link} from 'react-router'; @@ -775,7 +778,7 @@ Replace all Link imports to use the unified locale-aware `Link` component for co import type {CartApiQueryFragment} from 'storefrontapi.generated'; import {useAside} from '~/components/Aside'; import {CartLineItem, type CartLine} from '~/components/CartLineItem'; -~~~ +``` #### Step 2.2: Add language dynamic segment to the desired routes @@ -793,13 +796,13 @@ For brevity, we'll focus on the home page, the cart page, and the product page i > [!NOTE] > Rename `app/routes/_index.tsx` to `app/routes/($locale)._index.tsx`. -##### File: [($locale)._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx) +##### File: [($locale).\_index.tsx]() -~~~tsx +```tsx import {Await, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale)._index'; import {Suspense} from 'react'; -import {Image} from '@shopify/hydrogen'; +import {Image, hydrogenContext} from '@shopify/hydrogen'; import type { FeaturedCollectionFragment, RecommendedProductsQuery, @@ -827,7 +830,7 @@ export async function loader(args: Route.LoaderArgs) { */ async function loadCriticalData({context}: Route.LoaderArgs) { const [{collections}] = await Promise.all([ - context.storefront.query(FEATURED_COLLECTION_QUERY), + context.get(hydrogenContext.storefront).query(FEATURED_COLLECTION_QUERY), // Add other queries here, so that they are loaded in parallel ]); @@ -842,7 +845,8 @@ async function loadCriticalData({context}: Route.LoaderArgs) { * 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 + const recommendedProducts = context + .get(hydrogenContext.storefront) .query(RECOMMENDED_PRODUCTS_QUERY) .catch((error: Error) => { // Log query errors, but don't throw them so the page can still render @@ -964,8 +968,7 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.4: Add localization to the cart page @@ -974,14 +977,10 @@ Add the dynamic segment to the cart page route. > [!NOTE] > Rename `app/routes/cart.tsx` to `app/routes/($locale).cart.tsx`. -##### File: [($locale).cart.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx) +##### File: [($locale).cart.tsx]() -~~~tsx -import { - useLoaderData, - data, - type HeadersFunction, -} from 'react-router'; +```tsx +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'; @@ -1094,21 +1093,20 @@ export default function Cart() {
); } - -~~~ +``` #### Step 2.5: Add localization to the product page 1. Add the dynamic segment to the product page route. 2. Update the `meta` function to also update the canonical URL to use the -localized prefix. + localized prefix. > [!NOTE] > Rename `app/routes/products.$handle.tsx` to `app/routes/($locale).products.$handle.tsx`. -##### File: [($locale).products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx) +##### File: [($locale).products.$handle.tsx]() -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).products.$handle'; import { @@ -1163,6 +1161,7 @@ async function loadCriticalData({ context, params, request, + url, }: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; @@ -1188,7 +1187,7 @@ async function loadCriticalData({ } // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); + redirectIfHandleIsLocalized(url, {handle, data: product}); return { product, @@ -1361,8 +1360,7 @@ const PRODUCT_QUERY = `#graphql } ${PRODUCT_FRAGMENT} ` as const; - -~~~ +``` #### Step 2.6: Add a utility route to validate the locale. @@ -1370,9 +1368,9 @@ Add a utility route in `$(locale).tsx` that will use `localeMatchesPrefix` to validate the locale from the URL params. If the locale is invalid, the route will throw a 404 error. -##### File: [($locale).tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).tsx) +##### File: [($locale).tsx]() -~~~tsx +```tsx import type {Route} from './+types/($locale)'; import {localeMatchesPrefix} from '~/lib/i18n'; @@ -1383,50 +1381,48 @@ export async function loader({params}: Route.LoaderArgs) { return null; } - -~~~ +``` #### Step 2.7: Handle unauthenticated account pages Add a fallback route for unauthenticated account pages with locale support. -##### File: [($locale).account.$.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx) +##### File: [($locale).account.$.tsx]() -~~~tsx +```tsx import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account.$'; +import {hydrogenContext} from '@shopify/hydrogen'; // fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return redirect('/account'); } - -~~~ +``` #### Step 2.8: Redirect to account dashboard Add a localized account dashboard redirect route. -##### File: [($locale).account._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account._index.tsx) +##### File: [($locale).account.\_index.tsx]() -~~~tsx +```tsx import {redirect} from 'react-router'; export async function loader() { return redirect('/account/orders'); } - -~~~ +``` #### Step 2.9: Add address management Add a customer address management page with locale-aware forms and links. -##### File: [($locale).account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx) +##### File: [($locale).account.addresses.tsx]() -~~~tsx +```tsx import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; import type { AddressFragment, @@ -1446,6 +1442,7 @@ import { DELETE_ADDRESS_MUTATION, CREATE_ADDRESS_MUTATION, } from '~/graphql/customer-account/CustomerAddressMutations'; +import {hydrogenContext} from '@shopify/hydrogen'; export type ActionResponse = { addressId?: string | null; @@ -1461,7 +1458,7 @@ export const meta: Route.MetaFunction = () => { }; export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return {}; } @@ -1943,16 +1940,15 @@ export function AddressForm({ ); } - -~~~ +``` #### Step 2.10: Show order details Add an individual order details page with localized currency and date formatting. -##### File: [($locale).account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx) +##### File: [($locale).account.orders.$id.tsx]() -~~~tsx +```tsx import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).account.orders.$id'; import {Money, Image} from '@shopify/hydrogen'; @@ -1962,8 +1958,8 @@ import type { } 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { @@ -1987,19 +1983,10 @@ export async function loader({params, context}: Route.LoaderArgs) { 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< @@ -2175,21 +2162,16 @@ function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { ); } - -~~~ +``` #### Step 2.11: Display order history Implement customer order history listing with locale-specific pagination. -##### File: [($locale).account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx) +##### File: [($locale).account.orders.\_index.tsx]() -~~~tsx -import { - useLoaderData, - useNavigation, - useSearchParams, -} from 'react-router'; +```tsx +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'; @@ -2407,16 +2389,15 @@ function OrderItem({order}: {order: OrderItemFragment}) { ); } - -~~~ +``` #### Step 2.12: Build customer profile page Add a customer profile editing form with localized field labels. -##### File: [($locale).account.profile.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx) +##### File: [($locale).account.profile.tsx]() -~~~tsx +```tsx import type {CustomerFragment} from 'customer-accountapi.generated'; import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types'; import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation'; @@ -2428,6 +2409,7 @@ import { useOutletContext, } from 'react-router'; import type {Route} from './+types/($locale).account.profile'; +import {hydrogenContext} from '@shopify/hydrogen'; export type ActionResponse = { error: string | null; @@ -2439,7 +2421,7 @@ export const meta: Route.MetaFunction = () => { }; export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return {}; } @@ -2550,25 +2532,20 @@ export default function AccountProfile() { ); } - -~~~ +``` #### Step 2.13: Create account layout Add an account layout wrapper with locale-aware navigation tabs. -##### File: [($locale).account.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx) +##### File: [($locale).account.tsx]() -~~~tsx -import { - data as remixData, - Form, - Outlet, - useLoaderData, -} from 'react-router'; +```tsx +import {data as remixData, Form, Outlet, useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).account'; import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import {hydrogenContext} from '@shopify/hydrogen'; export function shouldRevalidate() { return true; @@ -2576,14 +2553,11 @@ export function shouldRevalidate() { 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, - }, + const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { + variables: { + language: context.get(hydrogenContext.customerAccount).i18n.language, }, - ); + }); if (errors?.length || !data?.customer) { throw new Error('Customer not found'); @@ -2660,32 +2634,32 @@ function Logout() { ); } - -~~~ +``` #### Step 2.14: Handle OAuth authorization Add an OAuth authorization callback route with locale preservation. -##### File: [($locale).account_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx) +##### File: [($locale).account\_.authorize.tsx]() -~~~tsx +```tsx import type {Route} from './+types/($locale).account_.authorize'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({context}: Route.LoaderArgs) { - return context.customerAccount.authorize(); + return context.get(hydrogenContext.customerAccount).authorize(); } - -~~~ +``` #### Step 2.15: Create login redirect Add a customer login redirect with a locale-specific return URL. -##### File: [($locale).account_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx) +##### File: [($locale).account\_.login.tsx]() -~~~tsx +```tsx import type {Route} from './+types/($locale).account_.login'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({request, context}: Route.LoaderArgs) { const url = new URL(request.url); @@ -2694,26 +2668,26 @@ export async function loader({request, context}: Route.LoaderArgs) { 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, + return context.get(hydrogenContext.customerAccount).login({ + countryCode: context.get(hydrogenContext.storefront).i18n.country, acrValues, loginHint, loginHintMode, locale, }); } - -~~~ +``` #### Step 2.16: Handle logout Add a logout handler that maintains locale after the user signs out. -##### File: [($locale).account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx) +##### File: [($locale).account\_.logout.tsx]() -~~~tsx +```tsx import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account_.logout'; +import {hydrogenContext} from '@shopify/hydrogen'; // if we don't implement this, /account/logout will get caught by account.$.tsx to do login export async function loader() { @@ -2721,25 +2695,24 @@ export async function loader() { } export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); + return context.get(hydrogenContext.customerAccount).logout(); } - -~~~ +``` #### Step 2.17: Show blog articles Add a blog article page with locale-specific content and SEO metadata. -##### File: [($locale).blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx) +##### File: [($locale).blogs.$blogHandle.$articleHandle.tsx]() -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).blogs.$blogHandle.$articleHandle'; -import {Image} from '@shopify/hydrogen'; +import {Image, hydrogenContext} from '@shopify/hydrogen'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.article.title ?? ''} article`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -2756,7 +2729,7 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({context, url, params}: Route.LoaderArgs) { const {blogHandle, articleHandle} = params; if (!articleHandle || !blogHandle) { @@ -2764,7 +2737,7 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { } const [{blog}] = await Promise.all([ - context.storefront.query(ARTICLE_QUERY, { + context.get(hydrogenContext.storefront).query(ARTICLE_QUERY, { variables: {blogHandle, articleHandle}, }), // Add other queries here, so that they are loaded in parallel @@ -2775,7 +2748,7 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { } redirectIfHandleIsLocalized( - request, + url, { handle: articleHandle, data: blog.articleByHandle, @@ -2862,28 +2835,29 @@ const ARTICLE_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.18: List blog posts Add a blog listing page with localized article previews and pagination. -##### File: [($locale).blogs.$blogHandle._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx) +##### File: [($locale).blogs.$blogHandle.\_index.tsx]() -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs.$blogHandle._index'; -import {Image, getPaginationVariables} from '@shopify/hydrogen'; +import { + Image, + getPaginationVariables, + hydrogenContext, +} 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.blog.title ?? ''} blog`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -2903,6 +2877,7 @@ export async function loader(args: Route.LoaderArgs) { async function loadCriticalData({ context, request, + url, params, }: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { @@ -2914,7 +2889,7 @@ async function loadCriticalData({ } const [{blog}] = await Promise.all([ - context.storefront.query(BLOGS_QUERY, { + context.get(hydrogenContext.storefront).query(BLOGS_QUERY, { variables: { blogHandle: params.blogHandle, ...paginationVariables, @@ -2927,7 +2902,7 @@ async function loadCriticalData({ throw new Response('Not found', {status: 404}); } - redirectIfHandleIsLocalized(request, {handle: params.blogHandle, data: blog}); + redirectIfHandleIsLocalized(url, {handle: params.blogHandle, data: blog}); return {blog}; } @@ -3054,20 +3029,19 @@ const BLOGS_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.19: Display all blogs Add an overview page for all blogs with locale-aware navigation links. -##### File: [($locale).blogs._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx) +##### File: [($locale).blogs.\_index.tsx]() -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs._index'; -import {getPaginationVariables} from '@shopify/hydrogen'; +import {getPaginationVariables, hydrogenContext} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import type {BlogsQuery} from 'storefrontapi.generated'; @@ -3097,7 +3071,7 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { }); const [{blogs}] = await Promise.all([ - context.storefront.query(BLOGS_QUERY, { + context.get(hydrogenContext.storefront).query(BLOGS_QUERY, { variables: { ...paginationVariables, }, @@ -3174,16 +3148,15 @@ const BLOGS_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.20: Show collection pages Add a collection page displaying products with locale-specific pricing and availability. -##### File: [($locale).collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx) +##### File: [($locale).collections.$handle.tsx]() -~~~tsx +```tsx import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).collections.$handle'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; @@ -3192,8 +3165,10 @@ 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [ + {title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}, + ]; }; export async function loader(args: Route.LoaderArgs) { @@ -3210,7 +3185,12 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({ + context, + params, + request, + url, +}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; const paginationVariables = getPaginationVariables(request, { @@ -3235,7 +3215,7 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { } // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: collection}); + redirectIfHandleIsLocalized(url, {handle, data: collection}); return { collection, @@ -3345,20 +3325,23 @@ const COLLECTION_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.21: List all collections Add a collections listing page with localized collection names and images. -##### File: [($locale).collections._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx) +##### File: [($locale).collections.\_index.tsx]() -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).collections._index'; -import {getPaginationVariables, Image} from '@shopify/hydrogen'; +import { + getPaginationVariables, + Image, + hydrogenContext, +} from '@shopify/hydrogen'; import type {CollectionFragment} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; @@ -3382,7 +3365,7 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { }); const [{collections}] = await Promise.all([ - context.storefront.query(COLLECTIONS_QUERY, { + context.get(hydrogenContext.storefront).query(COLLECTIONS_QUERY, { variables: paginationVariables, }), // Add other queries here, so that they are loaded in parallel @@ -3489,20 +3472,17 @@ const COLLECTIONS_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.22: Show all products Add an "All products" page with locale-based filtering and sorting. -##### File: [($locale).collections.all.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx) +##### File: [($locale).collections.all.tsx]() -~~~tsx +```tsx import type {Route} from './+types/($locale).collections.all'; -import { - useLoaderData, -} from 'react-router'; +import {useLoaderData} from 'react-router'; import {getPaginationVariables, Image, Money} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {ProductItem} from '~/components/ProductItem'; @@ -3623,24 +3603,22 @@ const CATALOG_QUERY = `#graphql } ${COLLECTION_ITEM_FRAGMENT} ` as const; - -~~~ +``` #### Step 2.23: Display content pages Add a dynamic page route for locale-specific content pages. -##### File: [($locale).pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx) +##### File: [($locale).pages.$handle.tsx]() -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).pages.$handle'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; +import {hydrogenContext} from '@shopify/hydrogen'; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.page.title ?? ''}`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.page.title ?? ''}`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -3657,17 +3635,13 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({context, url, params}: Route.LoaderArgs) { if (!params.handle) { throw new Error('Missing page handle'); } const [{page}] = await Promise.all([ - context.storefront.query(PAGE_QUERY, { + context.get(hydrogenContext.storefront).query(PAGE_QUERY, { variables: { handle: params.handle, }, @@ -3679,7 +3653,7 @@ async function loadCriticalData({ throw new Response('Not Found', {status: 404}); } - redirectIfHandleIsLocalized(request, {handle: params.handle, data: page}); + redirectIfHandleIsLocalized(url, {handle: params.handle, data: page}); return { page, @@ -3727,30 +3701,28 @@ const PAGE_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.24: Show policy pages Add a policy page (privacy, terms, etc.) with locale-specific legal content. -##### File: [($locale).policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx) +##### File: [($locale).policies.$handle.tsx]() -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).policies.$handle'; import {type Shop} from '@shopify/hydrogen/storefront-api-types'; +import {hydrogenContext} from '@shopify/hydrogen'; type SelectedPolicies = keyof Pick< Shop, 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy' >; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.policy.title ?? ''}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { @@ -3763,16 +3735,18 @@ export async function loader({params, context}: Route.LoaderArgs) { (_: 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 data = await context + .get(hydrogenContext.storefront) + .query(POLICY_CONTENT_QUERY, { + variables: { + privacyPolicy: false, + shippingPolicy: false, + termsOfService: false, + refundPolicy: false, + [policyName]: true, + language: context.get(hydrogenContext.storefront).i18n?.language, + }, + }); const policy = data.shop?.[policyName]; @@ -3833,24 +3807,26 @@ const POLICY_CONTENT_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.25: List all policies Add a policies index page that lists all available store policies. -##### File: [($locale).policies._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx) +##### File: [($locale).policies.\_index.tsx]() -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).policies._index'; import type {PoliciesQuery, PolicyItemFragment} from 'storefrontapi.generated'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({context}: Route.LoaderArgs) { - const data: PoliciesQuery = await context.storefront.query(POLICIES_QUERY); - + const data: PoliciesQuery = await context + .get(hydrogenContext.storefront) + .query(POLICIES_QUERY); + const shopPolicies = data.shop; const policies: PolicyItemFragment[] = [ shopPolicies?.privacyPolicy, @@ -3913,19 +3889,16 @@ const POLICIES_QUERY = `#graphql } } ` as const; - -~~~ +``` #### Step 2.26: Build search functionality Add a search results page with locale-aware product matching and predictive search. -##### File: [($locale).search.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx) +##### File: [($locale).search.tsx]() -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).search'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; import {SearchForm} from '~/components/SearchForm'; @@ -3935,7 +3908,10 @@ import { type PredictiveSearchReturn, getEmptyPredictiveSearchResult, } from '~/lib/search'; -import type {RegularSearchQuery, PredictiveSearchQuery} from 'storefrontapi.generated'; +import type { + RegularSearchQuery, + PredictiveSearchQuery, +} from 'storefrontapi.generated'; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Search`}]; @@ -4150,9 +4126,13 @@ async function regularSearch({ 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}, - }); + 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'); @@ -4314,17 +4294,18 @@ async function predictiveSearch({ 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, - { + 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( @@ -4343,53 +4324,7 @@ async function predictiveSearch({ return {type, term, result: {items, total}}; } - -~~~ - -### Step 8: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ## Deleted Files @@ -4417,4 +4352,4 @@ async function predictiveSearch({ - [`templates/skeleton/app/routes/products.$handle.tsx`](templates/skeleton/app/routes/products.$handle.tsx) - [`templates/skeleton/app/routes/search.tsx`](templates/skeleton/app/routes/search.tsx) - \ No newline at end of file + diff --git a/cookbook/llms/metaobjects.prompt.md b/cookbook/llms/metaobjects.prompt.md index c5cf35325a..790c6f8212 100644 --- a/cookbook/llms/metaobjects.prompt.md +++ b/cookbook/llms/metaobjects.prompt.md @@ -39,6 +39,7 @@ It lets you create and manage dynamic content sections through your Shopify admi providing a flexible way to build pages with reusable components. Key features: + - Dynamic route-based content rendering - Modular section components (Hero, Featured Products, Featured Collections, Stores) - Content editing capabilities with direct links to Shopify admin @@ -64,7 +65,7 @@ to match your specific content needs. See the included `guides/metaobjects/READM - app/components/EditRoute.tsx - app/routes/stores.$name.tsx -- app/routes/stores._index.tsx +- app/routes/stores.\_index.tsx - app/sections/RouteContent.tsx - app/sections/SectionFeaturedCollections.tsx - app/sections/SectionFeaturedProducts.tsx @@ -93,18 +94,18 @@ Update the README file with metaobjects CMS documentation and an architecture ov #### File: /README.md -~~~diff +````diff @@ -1,6 +1,8 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Metaobjects as CMS - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates how to use Shopify Metaobjects as a content management system (CMS). Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates how to use Shopify Metaobjects as a content management system (CMS). Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to create a flexible, section-based content architecture using Shopify's native Metaobjects, allowing merchants to manage content directly from the Shopify admin without external CMS dependencies. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,18 +18,60 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -135,19 +136,19 @@ Update the README file with metaobjects CMS documentation and an architecture ov +- **Type-safe**: Full TypeScript support with generated types +- **Merchant-friendly**: Content managed directly in Shopify admin +- **Extensible**: Easy to add new section types - + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- Shopify store with Metaobjects enabled +- Metaobject definitions created in Shopify admin - + ```bash npm create @shopify/hydrogen@latest ``` - + +## Setting up Metaobjects + +1. **Create Metaobject definitions** in your Shopify admin: @@ -165,12 +166,12 @@ Update the README file with metaobjects CMS documentation and an architecture ov + - Sections component handles dynamic rendering based on type + ## Building for production - + ```bash @@ -40,6 +84,21 @@ npm run build npm run dev ``` - + +## Creating New Sections + +1. Define the Metaobject in Shopify admin @@ -187,11 +188,11 @@ Update the README file with metaobjects CMS documentation and an architecture ov +``` + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +```` ### Step 2: Add product fragment for sections @@ -199,7 +200,7 @@ Add RECOMMENDED_PRODUCT_FRAGMENT for displaying product collections in metaobjec #### File: /app/lib/fragments.ts -~~~diff +```diff @@ -240,3 +240,25 @@ export const FOOTER_QUERY = `#graphql } ${MENU_FRAGMENT} @@ -226,15 +227,15 @@ Add RECOMMENDED_PRODUCT_FRAGMENT for displaying product collections in metaobjec + } + } +` as const; -~~~ +``` ### Step 3: Create edit route component Add the edit route component for managing metaobject-based content in development. -#### File: [EditRoute.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/components/EditRoute.tsx) +#### File: [EditRoute.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/components/EditRoute.tsx) -~~~tsx +```tsx import {useState, useEffect} from 'react'; import {Link, useMatches} from 'react-router'; @@ -283,8 +284,7 @@ export function EditRoute({routeId}: {routeId: string}) { ); } - -~~~ +``` ### Step 4: Expose store subdomain @@ -292,24 +292,24 @@ Expose the public store subdomain for metaobject queries and content management. #### File: /app/root.tsx -~~~diff +```diff @@ -90,6 +90,8 @@ export async function loader(args: Route.LoaderArgs) { country: args.context.storefront.i18n.country, language: args.context.storefront.i18n.language, }, + // @description Add public store subdomain for metaobjects -+ publictoreSubdomain: args.context.env.PUBLIC_STORE_DOMAIN, ++ publicStoreSubdomain: args.context.env.PUBLIC_STORE_DOMAIN, }; } -~~~ +``` ### Step 5: Build store profile route Add a dynamic store profile route for displaying store-specific metaobject content. -#### File: [stores.$name.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores.$name.tsx) +#### File: [stores.$name.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores.$name.tsx) -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/stores.$name'; @@ -337,7 +337,7 @@ export async function loader(args: Route.LoaderArgs) { async function loadCriticalData({context, params}: Route.LoaderArgs) { const {storefront} = context; const {name} = params; - + // 2. Query for the route's content metaobject const [{route}] = await Promise.all([ storefront.query(ROUTE_CONTENT_QUERY, { @@ -368,22 +368,17 @@ export default function Store() { ); } - -~~~ +``` ### Step 6: Add metaobjects to homepage Integrate the `RouteContent` component to render metaobject sections on the homepage. -#### File: /app/routes/_index.tsx +#### File: /app/routes/\_index.tsx -~~~diff -@@ -1,19 +1,11 @@ --import { -- Await, -- useLoaderData, -- Link, --} from 'react-router'; +```diff +@@ -1,16 +1,11 @@ +-import {Await, useLoaderData, Link} from 'react-router'; +import {useLoaderData} from 'react-router'; import type {Route} from './+types/_index'; -import {Suspense} from 'react'; @@ -393,17 +388,18 @@ Integrate the `RouteContent` component to render metaobject sections on the home - RecommendedProductsQuery, -} from 'storefrontapi.generated'; -import {ProductItem} from '~/components/ProductItem'; +-import {MockShopNotice} from '~/components/MockShopNotice'; + +// @description Add metaobject content imports +import {ROUTE_CONTENT_QUERY, RouteContent} from '~/sections/RouteContent'; - + export const meta: Route.MetaFunction = () => { - return [{title: 'Hydrogen | Home'}]; + return [{title: 'Hydrogen Metaobject | Home'}]; }; - + export async function loader(args: Route.LoaderArgs) { -@@ -31,14 +23,18 @@ export async function loader(args: Route.LoaderArgs) { +@@ -28,150 +23,37 @@ export async function loader(args: Route.LoaderArgs) { * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context}: Route.LoaderArgs) { @@ -419,15 +415,18 @@ Integrate the `RouteContent` component to render metaobject sections on the home + }), // Add other queries here, so that they are loaded in parallel ]); - + - return { +- isShopLinked: Boolean(context.env.PUBLIC_STORE_DOMAIN), - featuredCollection: collections.nodes[0], - }; + return {route}; } - + /** -@@ -47,125 +43,17 @@ async function loadCriticalData({context}: Route.LoaderArgs) { + * 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. ++ * fetched after the initial page load. If it's unavailable, the page should still render * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { @@ -445,13 +444,14 @@ Integrate the `RouteContent` component to render metaobject sections on the home + // No deferred data for this route + return {}; } - + export default function Homepage() { - const data = useLoaderData(); + const {route} = useLoaderData(); + return (
+- {data.isShopLinked ? null : } - - + {/* @description Render the route's content sections */} @@ -474,7 +474,11 @@ Integrate the `RouteContent` component to render metaobject sections on the home - > - {image && ( -
-- +- {image.altText -
- )} -

{collection.title}

@@ -488,8 +492,11 @@ Integrate the `RouteContent` component to render metaobject sections on the home - products: Promise; -}) { - return ( --
--

Recommended Products

+-
+- - Loading...
}> - - {(response) => ( @@ -504,7 +511,7 @@ Integrate the `RouteContent` component to render metaobject sections on the home - - -
--
+- - ); -} - @@ -559,15 +566,15 @@ Integrate the `RouteContent` component to render metaobject sections on the home - } - } -` as const; -~~~ +``` ### Step 7: Display all stores Add a store listing page that shows all stores from metaobjects with a grid layout. -#### File: [stores._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores._index.tsx) +#### File: [stores.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores._index.tsx) -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/stores._index'; @@ -627,8 +634,7 @@ export default function Stores() { ); } - -~~~ +``` ### Step 8: Install rich text dependencies @@ -636,27 +642,23 @@ Add Slate dependencies for rich text editing in metaobject sections. #### File: /package.json -~~~diff -@@ -21,7 +21,9 @@ - "react": "18.3.1", - "react-dom": "18.3.1", - "react-router": "7.12.0", -- "react-router-dom": "7.12.0" -+ "react-router-dom": "7.12.0", +```diff +@@ -21,3 +21,5 @@ + "react": "catalog:", + "react-dom": "catalog:", +- "react-router": "8.0.1" ++ "react-router": "8.0.1", + "slate": "^0.101.4", + "slate-react": "^0.101.3" - }, - "devDependencies": { - "@eslint/compat": "^1.2.5", -~~~ +``` ### Step 9: Create route content component Add the main component for fetching and rendering metaobject-based route content. -#### File: [RouteContent.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/RouteContent.tsx) +#### File: [RouteContent.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/RouteContent.tsx) -~~~tsx +```tsx import {SECTIONS_FRAGMENT, Sections} from '~/sections/Sections'; import {EditRoute} from '~/components/EditRoute'; @@ -691,16 +693,15 @@ export const ROUTE_CONTENT_QUERY = `#graphql } ${SECTIONS_FRAGMENT} `; - -~~~ +``` ### Step 10: Build featured collections section Add a section component for displaying featured product collections from metaobjects. -#### File: [SectionFeaturedCollections.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedCollections.tsx) +#### File: [SectionFeaturedCollections.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedCollections.tsx) -~~~tsx +```tsx import type { SectionFeaturedCollectionsFragment, FeaturedCollectionImageFragment, @@ -788,16 +789,15 @@ export const SECTION_FEATURED_COLLECTIONS_FRAGMENT = `#graphql } ${FEATURED_COLLECTION_FRAGMENT} `; - -~~~ +``` ### Step 11: Build featured products section Add a section component for showcasing featured products with a customizable layout. -#### File: [SectionFeaturedProducts.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedProducts.tsx) +#### File: [SectionFeaturedProducts.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedProducts.tsx) -~~~tsx +```tsx import {Money, Image} from '@shopify/hydrogen'; import {Link} from 'react-router'; import type {SectionFeaturedProductsFragment} from 'storefrontapi.generated'; @@ -901,16 +901,15 @@ export const SECTION_FEATURED_PRODUCTS_FRAGMENT = `#graphql } ${FEATURED_PRODUCT_FRAGMENT} `; - -~~~ +``` ### Step 12: Build hero banner section Add a hero banner section with an image, heading, and call-to-action from metaobjects. -#### File: [SectionHero.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionHero.tsx) +#### File: [SectionHero.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionHero.tsx) -~~~tsx +```tsx import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseSection} from '~/utils/parseSection'; import {Link} from 'react-router'; @@ -934,6 +933,7 @@ export function SectionHero(props: SectionHeroFragment) { return (
+
Back to Stores

@@ -1155,16 +1154,15 @@ export const SECTION_STORE_PROFILE_FRAGMENT = `#graphql } ${STORE_PROFILE_FRAGMENT} `; - -~~~ +``` ### Step 14: Build stores grid section Add a grid layout section for displaying multiple store locations from metaobjects. -#### File: [SectionStores.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStores.tsx) +#### File: [SectionStores.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStores.tsx) -~~~tsx +```tsx import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseSection} from '~/utils/parseSection'; import {Link} from 'react-router'; @@ -1182,7 +1180,7 @@ export function SectionStores(props: SectionStoresFragment) { const {heading, stores} = section; return ( -
+
{heading?.value &&

{heading.value}

}
+
{sections?.references?.nodes.map((section) => { switch (section.type) { case 'section_hero': @@ -1351,16 +1348,15 @@ export const SECTIONS_FRAGMENT = `#graphql ${SECTION_STORES_FRAGMENT} ${SECTION_STORE_PROFILE_FRAGMENT} `; - -~~~ +``` ### Step 16: Add section parsing utility Add a utility function for parsing and transforming metaobject field data. -#### File: [parseSection.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/utils/parseSection.ts) +#### File: [parseSection.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/utils/parseSection.ts) -~~~ts +```ts import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseMetafield} from '@shopify/hydrogen'; @@ -1493,16 +1489,15 @@ function liftEach>( return lift(result, keyToLift); }, obj) as LiftEach; } - -~~~ +``` ### Step 17: Add setup guide A comprehensive guide for setting up metaobject definitions in the Shopify admin. -#### File: [README.md](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/README.md) +#### File: [README.md](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/README.md) -~~~md +````md # Metaobjects Overview This document describes the high-level content architecture and metaobject definitions @@ -1772,14 +1767,13 @@ export const SECTIONS_FRAGMENT = `#graphql ${SECTION_EXAMPLE_FRAGMENT} `; ``` - -~~~ +```` ### Step 18: Link field screenshot A screenshot showing the Link metaobject field configuration. -#### File: [definition_link.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) +#### File: [definition_link.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) ![templates/skeleton/guides/metaobjects/images/definition_link.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) @@ -1787,7 +1781,7 @@ A screenshot showing the Link metaobject field configuration. A screenshot of a "Featured Collections" section metaobject definition. -#### File: [definition_section_featured_collections.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) +#### File: [definition_section_featured_collections.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) ![templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) @@ -1795,7 +1789,7 @@ A screenshot of a "Featured Collections" section metaobject definition. A screenshot of a "Featured Products" section metaobject definition. -#### File: [definition_section_featured_products.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) +#### File: [definition_section_featured_products.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) ![templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) @@ -1803,7 +1797,7 @@ A screenshot of a "Featured Products" section metaobject definition. A screenshot of a Hero section metaobject definition with image and text fields. -#### File: [definition_section_hero.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) +#### File: [definition_section_hero.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) ![templates/skeleton/guides/metaobjects/images/definition_section_hero.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) @@ -1811,7 +1805,7 @@ A screenshot of a Hero section metaobject definition with image and text fields. A screenshot of a "Richtext" section metaobject definition. -#### File: [definition_section_rich_text.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) +#### File: [definition_section_rich_text.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) ![templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) @@ -1819,7 +1813,7 @@ A screenshot of a "Richtext" section metaobject definition. A screenshot of a "Store Profile" section metaobject definition. -#### File: [definition_section_store_profile.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) +#### File: [definition_section_store_profile.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) ![templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) @@ -1827,7 +1821,7 @@ A screenshot of a "Store Profile" section metaobject definition. A screenshot of a "Stores Grid" section metaobject definition. -#### File: [definition_section_stores_grid.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) +#### File: [definition_section_stores_grid.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) ![templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) @@ -1835,7 +1829,7 @@ A screenshot of a "Stores Grid" section metaobject definition. A screenshot of a "Store" metaobject definition with location and contact fields. -#### File: [definition_store.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) +#### File: [definition_store.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) ![templates/skeleton/guides/metaobjects/images/definition_store.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) @@ -1843,7 +1837,7 @@ A screenshot of a "Store" metaobject definition with location and contact fields A screenshot showing a list of all metaobject definitions in the Shopify admin. -#### File: [definitions_list.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) +#### File: [definitions_list.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) ![templates/skeleton/guides/metaobjects/images/definitions_list.png](ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) @@ -1851,8 +1845,8 @@ A screenshot showing a list of all metaobject definitions in the Shopify admin. A screenshot of a "Route" metaobject definition with a "Sections" reference field. -#### File: [definiton_route.png](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) +#### File: [definiton_route.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) ![templates/skeleton/guides/metaobjects/images/definiton_route.png](ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) - \ No newline at end of file + diff --git a/cookbook/llms/multipass.prompt.md b/cookbook/llms/multipass.prompt.md index 4dfaa24378..6214a5c363 100644 --- a/cookbook/llms/multipass.prompt.md +++ b/cookbook/llms/multipass.prompt.md @@ -17,12 +17,13 @@ Enable Shopify Plus Multipass authentication using Storefront API for seamless c # User Intent Recognition + - How do I set up Multipass authentication in my Hydrogen store? - How can I use Storefront API for customer authentication instead of Customer Account API? - How do I implement session-based authentication in Hydrogen? - How can I maintain customer login state across checkout? - How do I integrate external authentication with Shopify Plus? - + # Troubleshooting @@ -53,6 +54,7 @@ state across the storefront and checkout process. This is particularly useful fo integrate with external authentication systems or maintain customer sessions across different platforms. Key features: + - Converts all customer account routes from Customer Account API to Storefront API - Implements session-based authentication with customer access tokens - Adds Multipass checkout button for seamless checkout experience @@ -77,7 +79,7 @@ Key features: - Shopify Plus subscription for Multipass functionality - PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET environment variable must be set -- React Router 7.9.x or higher +- React Router framework mode ## New files added to the template by this recipe @@ -85,11 +87,11 @@ Key features: - app/lib/multipass/multipass.ts - app/lib/multipass/multipassify.server.ts - app/lib/multipass/types.ts -- app/routes/account_.activate.$id.$activationToken.tsx -- app/routes/account_.login.multipass.tsx -- app/routes/account_.recover.tsx -- app/routes/account_.register.tsx -- app/routes/account_.reset.$id.$resetToken.tsx +- app/routes/account\_.activate.$id.$activationToken.tsx +- app/routes/account\_.login.multipass.tsx +- app/routes/account\_.recover.tsx +- app/routes/account\_.register.tsx +- app/routes/account\_.reset.$id.$resetToken.tsx ## Steps @@ -99,21 +101,21 @@ Update README with multipass authentication documentation #### File: /README.md -~~~diff +````diff @@ -1,13 +1,15 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Multipass - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) +[Learn about Multipass](https://shopify.dev/docs/api/multipass) - + ## What's included - --- Remix + +-- React Router +### Core Hydrogen Stack +- React Router - Hydrogen @@ -122,7 +124,7 @@ Update README with multipass authentication documentation @@ -18,11 +20,18 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - TypeScript and JavaScript flavors - Minimal setup of components and routes - + +### Multipass Authentication (Shopify Plus) +- Customer session persistence through checkout +- Storefront API-based authentication (not Customer Account API) @@ -130,21 +132,21 @@ Update README with multipass authentication documentation +- Automatic fallback for non-Plus stores + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- Shopify store (Shopify Plus for Multipass features) - + ```bash npm create @shopify/hydrogen@latest @@ -40,6 +49,88 @@ npm run build npm run dev ``` - + -## Setup for using Customer Account API (`/account` section) +## Multipass Setup (Shopify Plus only) - + -Follow step 1 and 2 of +### Requirements + @@ -185,7 +187,7 @@ Update README with multipass authentication documentation + +### Account Routes +- `/account/login` - Customer login -+- `/account/register` - New customer registration ++- `/account/register` - New customer registration +- `/account/logout` - Logout +- `/account/recover` - Password recovery +- `/account/reset/:id/:token` - Password reset @@ -228,8 +230,8 @@ Update README with multipass authentication documentation +- [Hydrogen Documentation](https://shopify.dev/custom-storefronts/hydrogen) +- [Multipass Documentation](https://shopify.dev/docs/api/multipass) +- [Storefront API Authentication](https://shopify.dev/docs/api/storefront/authentication) -+- [Remix Documentation](https://remix.run/docs) -~~~ ++- [React Router Documentation](https://reactrouter.com/) +```` ### Step 2: app/components/MultipassCheckoutButton.tsx @@ -237,7 +239,7 @@ Add checkout button component that generates multipass tokens #### File: [MultipassCheckoutButton.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/components/MultipassCheckoutButton.tsx) -~~~tsx +```tsx import React, {useCallback} from 'react'; import {multipass} from '~/lib/multipass/multipass'; @@ -276,7 +278,7 @@ export function MultipassCheckoutButton(props: MultipassCheckoutButtonProps) { return ; } -~~~ +``` ### Step 3: app/components/CartSummary.tsx @@ -284,11 +286,11 @@ Add multipass checkout button to cart summary #### File: /app/components/CartSummary.tsx -~~~diff +```diff @@ -4,6 +4,9 @@ import {CartForm, Money, type OptimisticCart} from '@shopify/hydrogen'; import {useEffect, useId, useRef, useState} from 'react'; import {useFetcher} from 'react-router'; - + +// @description Import MultipassCheckoutButton for Shopify Plus multipass checkout +import {MultipassCheckoutButton} from '~/components/MultipassCheckoutButton'; + @@ -296,7 +298,7 @@ Add multipass checkout button to cart summary cart: OptimisticCart; layout: CartLayout; @@ -51,9 +54,10 @@ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) { - + return ( ); -~~~ +``` ### Step 4: app/lib/multipass/multipass.ts @@ -316,7 +318,7 @@ Core multipass encryption and token generation utilities #### File: [multipass.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts) -~~~ts +```ts import type { MultipassResponse, MultipassOptions, @@ -391,7 +393,7 @@ export async function multipass( return {url: null, token: null, error: message}; } } -~~~ +``` ### Step 5: app/root.tsx @@ -399,7 +401,7 @@ Add session validation and token refresh logic #### File: /app/root.tsx -~~~diff +```diff @@ -1,4 +1,15 @@ -import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen'; +import { @@ -420,7 +422,7 @@ Add session validation and token refresh logic @@ -110,7 +121,14 @@ async function loadCriticalData({context}: Route.LoaderArgs) { // Add other queries here, so that they are loaded in parallel ]); - + - return {header}; + // @description Validate customer authentication for multipass + const customerAccessToken = await context.session.get('customerAccessToken'); @@ -431,7 +433,7 @@ Add session validation and token refresh logic + + return {header, isLoggedIn: Promise.resolve(isLoggedIn)}; } - + /** @@ -207,3 +225,24 @@ export function ErrorBoundary() {
@@ -458,7 +460,7 @@ Add session validation and token refresh logic + + return true; +} -~~~ +``` ### Step 6: app/lib/multipass/multipassify.server.ts @@ -466,24 +468,27 @@ Server-side multipass token generation with ESM-compatible snake_case #### File: [multipassify.server.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts) -~~~ts +```ts import CryptoJS from 'crypto-js'; import type {MultipassCustomer} from './types'; // Simple snake_case converter for ESM/Worker runtime function toSnakeCase(str: string): string { - return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''); + return str + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); } function snakecaseKeys(obj: any): any { if (typeof obj !== 'object' || obj === null) { return obj; } - + if (Array.isArray(obj)) { return obj.map(snakecaseKeys); } - + const result: any = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { @@ -642,7 +647,7 @@ export class Multipassify { return customer; } } -~~~ +``` ### Step 7: app/routes/account.$.tsx @@ -650,11 +655,11 @@ Convert catch-all account route to use Storefront API #### File: /app/routes/account.$.tsx -~~~diff +```diff @@ -1,9 +1,9 @@ import {redirect} from 'react-router'; import type {Route} from './+types/account.$'; - + -// fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); @@ -665,7 +670,7 @@ Convert catch-all account route to use Storefront API + } + return redirect('/account/login'); } -~~~ +``` ### Step 8: app/lib/multipass/types.ts @@ -673,7 +678,7 @@ TypeScript types for multipass functionality #### File: [types.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/types.ts) -~~~ts +```ts /* multipass and multipassify types */ @@ -743,15 +748,15 @@ export interface MultipassTokenResponseType { }; error: string | null; } -~~~ +``` -### Step 9: app/routes/account_.activate.$id.$activationToken.tsx +### Step 9: app/routes/account\_.activate.$id.$activationToken.tsx Add customer account activation route -#### File: [account_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) +#### File: [account\_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) -~~~tsx +```tsx import {Form, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.activate.$id.$activationToken'; @@ -904,7 +909,7 @@ const CUSTOMER_ACTIVATE_MUTATION = `#graphql } } ` as const; -~~~ +``` ### Step 10: app/routes/account.addresses.tsx @@ -912,7 +917,7 @@ Convert addresses management to use Storefront API #### File: /app/routes/account.addresses.tsx -~~~diff +```diff @@ -1,22 +1,14 @@ -import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; -import type { @@ -937,12 +942,12 @@ Convert addresses management to use Storefront API - DELETE_ADDRESS_MUTATION, - CREATE_ADDRESS_MUTATION, -} from '~/graphql/customer-account/CustomerAddressMutations'; - + export type ActionResponse = { addressId?: string | null; @@ -32,13 +24,16 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -953,17 +958,17 @@ Convert addresses management to use Storefront API + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {storefront, session} = context; - + try { const form = await request.formData(); @@ -50,31 +45,26 @@ export async function action({request, context}: Route.ActionArgs) { 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) { @@ -978,7 +983,7 @@ Convert addresses management to use Storefront API + return data({error: {[addressId]: 'Unauthorized'}}, {status: 401}); } + const {accessToken} = customerAccessToken; - + const defaultAddress = form.has('defaultAddress') ? String(form.get('defaultAddress')) === 'on' - : false; @@ -1001,7 +1006,7 @@ Convert addresses management to use Storefront API + 'province', 'zip', ]; - + @@ -89,143 +79,119 @@ export async function action({request, context}: Route.ActionArgs) { case 'POST': { // handle new address creation @@ -1018,14 +1023,14 @@ Convert addresses management to use Storefront API + variables: {customerAccessToken: accessToken, address}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressCreate?.customerUserErrors?.length) { + const error = customerAddressCreate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressCreate?.userErrors?.length) { - throw new Error(data?.customerAddressCreate?.userErrors[0].message); - } @@ -1085,7 +1090,7 @@ Convert addresses management to use Storefront API + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'PUT': { // handle address updates try { @@ -1103,7 +1108,7 @@ Convert addresses management to use Storefront API }, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + const updatedAddress = customerAddressUpdate?.customerAddress; @@ -1112,7 +1117,7 @@ Convert addresses management to use Storefront API + const error = customerAddressUpdate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressUpdate?.userErrors?.length) { - throw new Error(data?.customerAddressUpdate?.userErrors[0].message); - } @@ -1162,7 +1167,7 @@ Convert addresses management to use Storefront API + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'DELETE': { // handles address deletion try { @@ -1177,7 +1182,7 @@ Convert addresses management to use Storefront API + variables: {customerAccessToken: accessToken, id: addressId}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressDelete?.customerUserErrors?.length) { @@ -1213,7 +1218,7 @@ Convert addresses management to use Storefront API + return data({error: {[addressId]: error}}, {status: 400}); } } - + @@ -291,21 +257,17 @@ function NewAddressForm() { address2: '', city: '', @@ -1230,7 +1235,7 @@ Convert addresses management to use Storefront API zip: '', - } as CustomerAddressInput; + } as AddressFragment; - + return ( - @@ -360,26 +321,26 @@ function ExistingAddresses({ } - + export function AddressForm({ - addressId, address, @@ -1440,17 +1445,21 @@ Convert addresses management to use Storefront API + } + } +` as const; -~~~ +``` -### Step 11: app/routes/account_.login.multipass.tsx +### Step 11: app/routes/account\_.login.multipass.tsx Add multipass login handler route -#### File: [account_.login.multipass.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx) +#### File: [account\_.login.multipass.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx) -~~~tsx +```tsx import {data as remixData, redirect} from 'react-router'; -import type {LoaderFunctionArgs, ActionFunctionArgs, HeadersFunction} from 'react-router'; +import type { + LoaderFunctionArgs, + ActionFunctionArgs, + HeadersFunction, +} from 'react-router'; import {Multipassify} from '~/lib/multipass/multipassify.server'; import type { CustomerInfoType, @@ -1458,8 +1467,7 @@ import type { NotLoggedInResponseType, } from '~/lib/multipass/types'; -export const headers: HeadersFunction = ({actionHeaders}) => - actionHeaders; +export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; /* Redirect document GET requests to the login page (housekeeping) @@ -1741,8 +1749,7 @@ const CUSTOMER_INFO_QUERY = `#graphql } } `; - -~~~ +``` ### Step 12: app/routes/account.orders.$id.tsx @@ -1750,7 +1757,7 @@ Convert order details to use Storefront API #### File: /app/routes/account.orders.$id.tsx -~~~diff +```diff @@ -1,67 +1,50 @@ -import {redirect, useLoaderData} from 'react-router'; +import {Link, useLoaderData, redirect} from 'react-router'; @@ -1764,11 +1771,11 @@ Convert order details to use Storefront API -import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery'; + DiscountApplicationFragment, +} from 'storefrontapi.generated'; - - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Order ${data?.order?.name}`}]; + + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; - + export async function loader({params, context}: Route.LoaderArgs) { - const {customerAccount} = context; + const {session, storefront} = context; @@ -1776,7 +1783,7 @@ Convert order details to use Storefront API 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, { @@ -1786,29 +1793,29 @@ Convert order details to use Storefront API - }, - }); + const customerAccessToken = await session.get('customerAccessToken'); - + - if (errors?.length || !data?.order) { - throw new Error('Order not found'); + if (!customerAccessToken) { + return redirect('/account/login'); } - + - const {order} = data; + const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, { + variables: {orderId}, + }); - + - // Extract line items directly from nodes array - const lineItems = order.lineItems.nodes; + if (!order || !('lineItems' in order)) { + throw new Response('Order not found', {status: 404}); + } - + - // Extract discount applications directly from nodes array - const discountApplications = order.discountApplications.nodes; + const lineItems = flattenConnection(order.lineItems) as OrderLineItemFullFragment[]; + const discountApplications = flattenConnection(order.discountApplications) as DiscountApplicationFragment[]; - + - // Get fulfillment status from first fulfillment node - const fulfillmentStatus = order.fulfillments.nodes[0]?.status ?? 'N/A'; - @@ -1817,17 +1824,17 @@ Convert order details to use Storefront API - - // Type guard for MoneyV2 discount + const firstDiscount = discountApplications[0]; -+ ++ const discountValue = - firstDiscount?.__typename === 'MoneyV2' - ? (firstDiscount as Extract< - typeof firstDiscount, - {__typename: 'MoneyV2'} - >) -+ firstDiscount?.value?.__typename === 'MoneyV2' -+ ? firstDiscount.value ++ firstDiscount?.value?.__typename === 'MoneyV2' ++ ? firstDiscount.value : null; - + - // Type guard for percentage discount const discountPercentage = - firstDiscount?.__typename === 'PricingPercentageValue' @@ -1840,7 +1847,7 @@ Convert order details to use Storefront API + firstDiscount?.value?.__typename === 'PricingPercentageValue' + ? firstDiscount.value.percentage : null; - + return { @@ -69,25 +52,16 @@ export async function loader({params, context}: Route.LoaderArgs) { lineItems, @@ -1849,7 +1856,7 @@ Convert order details to use Storefront API - fulfillmentStatus, }; } - + export default function OrderRoute() { - const { - order, @@ -1957,7 +1964,7 @@ Convert order details to use Storefront API

@@ -196,27 +170,144 @@ export default function OrderRoute() { - + function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { return ( - @@ -2111,15 +2118,15 @@ Convert order details to use Storefront API + } +` as const; \ No newline at end of file -~~~ +``` -### Step 13: app/routes/account_.recover.tsx +### Step 13: app/routes/account\_.recover.tsx Add password recovery route -#### File: [account_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx) +#### File: [account\_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx) -~~~tsx +```tsx import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.recover'; @@ -2244,15 +2251,15 @@ const CUSTOMER_RECOVER_MUTATION = `#graphql } } ` as const; -~~~ +``` -### Step 14: app/routes/account.orders._index.tsx +### Step 14: app/routes/account.orders.\_index.tsx Convert orders list to use Storefront API -#### File: /app/routes/account.orders._index.tsx +#### File: /app/routes/account.orders.\_index.tsx -~~~diff +```diff @@ -1,222 +1,183 @@ -import { - Link, @@ -2281,7 +2288,7 @@ Convert orders list to use Storefront API -} from 'customer-accountapi.generated'; +} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; - + -type OrdersLoaderData = { - customer: CustomerOrdersFragment; - filters: OrderFilterParams; @@ -2290,14 +2297,14 @@ Convert orders list to use Storefront API 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 {session, storefront} = context; - + - const url = new URL(request.url); - const filters = parseOrderFilters(url.searchParams); - const query = buildOrderSearchQuery(filters); @@ -2316,7 +2323,7 @@ Convert orders list to use Storefront API + if (!customerAccessToken?.accessToken) { + return redirect('/account/login'); } - + - return {customer: data.customer, filters}; + try { + const paginationVariables = getPaginationVariables(request, { @@ -2345,18 +2352,18 @@ Convert orders list to use Storefront API + return data({error}, {status: 400}); + } } - + export default function Orders() { - const {customer, filters} = useLoaderData(); + const data = useLoaderData(); -+ ++ + if ('error' in data) { + return
Error: {String(data.error)}
; + } -+ ++ + const {customer} = data; const {orders} = customer; - + return (
- @@ -2365,7 +2372,7 @@ Convert orders list to use Storefront API
); } - + -function OrdersTable({ - orders, - filters, @@ -2390,7 +2397,7 @@ Convert orders list to use Storefront API
); } - + -function EmptyOrders({hasFilters = false}: {hasFilters?: boolean}) { +function EmptyOrders() { return ( @@ -2420,7 +2427,7 @@ Convert orders list to use Storefront API
); } - + -function OrderSearchForm({ - currentFilters, -}: { @@ -2600,15 +2607,15 @@ Convert orders list to use Storefront API + } +` as const; \ No newline at end of file -~~~ +``` -### Step 15: app/routes/account_.register.tsx +### Step 15: app/routes/account\_.register.tsx Add customer registration route -#### File: [account_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx) +#### File: [account\_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx) -~~~tsx +```tsx import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.register'; import type {CustomerCreateMutation} from 'storefrontapi.generated'; @@ -2620,7 +2627,8 @@ type ActionResponse = { | null; }; -export const headers: Route.HeadersFunction = ({actionHeaders}) => actionHeaders; +export const headers: Route.HeadersFunction = ({actionHeaders}) => + actionHeaders; export async function loader({context}: Route.LoaderArgs) { const customerAccessToken = await context.session.get('customerAccessToken'); @@ -2812,7 +2820,7 @@ const REGISTER_LOGIN_MUTATION = `#graphql } } ` as const; -~~~ +``` ### Step 16: app/routes/account.profile.tsx @@ -2820,7 +2828,7 @@ Convert customer profile management from Customer Account API to Storefront API #### File: /app/routes/account.profile.tsx -~~~diff +```diff @@ -1,12 +1,12 @@ -import type {CustomerFragment} from 'customer-accountapi.generated'; -import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types'; @@ -2837,10 +2845,10 @@ Convert customer profile management from Customer Account API to Storefront API + redirect, } from 'react-router'; import type {Route} from './+types/account.profile'; - + @@ -20,62 +20,79 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -2850,21 +2858,21 @@ Convert customer profile management from Customer Account API to Storefront API + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {session, storefront} = context; - + if (request.method !== 'PUT') { return data({error: 'Method not allowed'}, {status: 405}); } - + const form = await request.formData(); + const customerAccessToken = await session.get('customerAccessToken'); + if (!customerAccessToken) { + return data({error: 'Unauthorized'}, {status: 401}); + } - + try { + const password = getPassword(form); const customer: CustomerUpdateInput = {}; @@ -2887,7 +2895,7 @@ Convert customer profile management from Customer Account API to Storefront API customer[key as (typeof validInputKeys)[number]] = value; } } - + + if (password) { + customer.password = password; + } @@ -2907,7 +2915,7 @@ Convert customer profile management from Customer Account API to Storefront API }, - ); + }); - + - if (errors?.length) { - throw new Error(errors[0].message); + // check for mutation errors @@ -2917,7 +2925,7 @@ Convert customer profile management from Customer Account API to Storefront API + {status: 400}, + ); } - + - if (!data?.customerUpdate?.customer) { - throw new Error('Customer profile update failed.'); + // update session with the updated access token @@ -2927,7 +2935,7 @@ Convert customer profile management from Customer Account API to Storefront API + updated.customerUpdate?.customerAccessToken, + ); } - + - return { - error: null, - customer: data?.customerUpdate?.customer, @@ -2943,7 +2951,7 @@ Convert customer profile management from Customer Account API to Storefront API + return data({error: error.message, customer: null}, {status: 400}); } } - + @@ -114,6 +131,64 @@ export default function AccountProfile() { defaultValue={customer.lastName ?? ''} minLength={2} @@ -3065,15 +3073,15 @@ Convert customer profile management from Customer Account API to Storefront API + } + } +` as const; -~~~ +``` -### Step 17: app/routes/account_.reset.$id.$resetToken.tsx +### Step 17: app/routes/account\_.reset.$id.$resetToken.tsx Add password reset confirmation route -#### File: [account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) +#### File: [account\_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) -~~~tsx +```tsx import {data, Form, redirect, useActionData} from 'react-router'; import type {Route} from './+types/account_.reset.$id.$resetToken'; @@ -3206,7 +3214,7 @@ const CUSTOMER_RESET_MUTATION = `#graphql } } ` as const; -~~~ +``` ### Step 18: app/routes/account.tsx @@ -3214,7 +3222,7 @@ Convert account layout to use Storefront API customer data #### File: /app/routes/account.tsx -~~~diff +```diff @@ -1,42 +1,106 @@ import { - data as remixData, @@ -3228,11 +3236,11 @@ Convert account layout to use Storefront API customer data import type {Route} from './+types/account'; -import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import type {CustomerFragment} from 'storefrontapi.generated'; - + export function shouldRevalidate() { return true; } - + -export async function loader({context}: Route.LoaderArgs) { - const {customerAccount} = context; - const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { @@ -3242,7 +3250,7 @@ Convert account layout to use Storefront API customer data - }); +export const headers: Route.HeadersFunction = ({loaderHeaders}) => + loaderHeaders; - + - if (errors?.length || !data?.customer) { - throw new Error('Customer not found'); +export async function loader({request, context}: Route.LoaderArgs) { @@ -3275,7 +3283,7 @@ Convert account layout to use Storefront API customer data + return redirect('/account/orders'); + } } - + - return remixData( - {customer: data.customer}, - { @@ -3328,7 +3336,7 @@ Convert account layout to use Storefront API customer data + ); } - + -export default function AccountLayout() { - const {customer} = useLoaderData(); - @@ -3404,19 +3412,19 @@ Convert account layout to use Storefront API customer data + } + ${CUSTOMER_FRAGMENT} +` as const; -~~~ +``` -### Step 19: app/routes/account_.login.tsx +### Step 19: app/routes/account\_.login.tsx Replace Customer Account API login with form-based Storefront API login -#### File: /app/routes/account_.login.tsx +#### File: /app/routes/account\_.login.tsx -~~~diff +```diff @@ -1,17 +1,133 @@ +import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.login'; - + -export async function loader({request, context}: Route.LoaderArgs) { - const url = new URL(request.url); - const acrValues = url.searchParams.get('acr_values') || undefined; @@ -3426,7 +3434,7 @@ Replace Customer Account API login with form-based Storefront API login +type ActionResponse = { + error: string | null; +}; - + - return context.customerAccount.login({ - countryCode: context.storefront.i18n.country, - acrValues, @@ -3560,20 +3568,20 @@ Replace Customer Account API login with form-based Storefront API login + } + } +` as const; -~~~ +``` -### Step 20: app/routes/account_.logout.tsx +### Step 20: app/routes/account\_.logout.tsx Implement session-based logout -#### File: /app/routes/account_.logout.tsx +#### File: /app/routes/account\_.logout.tsx -~~~diff +```diff @@ -1,11 +1,25 @@ -import {redirect} from 'react-router'; +import {data, redirect} from 'react-router'; import type {Route} from './+types/account_.logout'; - + -// if we don't implement this, /account/logout will get caught by account.$.tsx to do login +export const meta: Route.MetaFunction = () => { + return [{title: 'Logout'}]; @@ -3593,7 +3601,7 @@ Implement session-based logout + return redirect('/'); } - + -export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); -} @@ -3601,7 +3609,7 @@ Implement session-based logout + return null; +} \ No newline at end of file -~~~ +``` ### Step 21: env.d.ts @@ -3609,9 +3617,9 @@ Add multipass secret environment variable type #### File: /env.d.ts -~~~diff +```diff @@ -5,3 +5,9 @@ - + // Enhance TypeScript's built-in typings. import '@total-typescript/ts-reset'; + @@ -3620,7 +3628,7 @@ Add multipass secret environment variable type + PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET?: string; + } +} -~~~ +``` ### Step 22: app/routes/cart.tsx @@ -3628,23 +3636,23 @@ Add multipass URL generation for checkout #### File: /app/routes/cart.tsx -~~~diff +```diff @@ -11,9 +11,13 @@ export const meta: Route.MetaFunction = () => { export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; - + export async function action({request, context}: Route.ActionArgs) { - const {cart} = context; + // @description Get session for multipass customer token persistence + const {session, cart} = context; - + - const formData = await request.formData(); + const [formData, customerAccessToken] = await Promise.all([ + request.formData(), + session.get('customerAccessToken'), + ]); - + const {action, inputs} = CartForm.getFormInput(formData); - + @@ -66,6 +70,8 @@ export async function action({request, context}: Route.ActionArgs) { case CartForm.ACTIONS.BuyerIdentityUpdate: { result = await cart.updateBuyerIdentity({ @@ -3654,7 +3662,7 @@ Add multipass URL generation for checkout }); break; } -~~~ +``` ### Step 23: package.json @@ -3662,7 +3670,7 @@ Add crypto dependencies for multipass token generation #### File: /package.json -~~~diff +```diff @@ -15,6 +15,7 @@ "prettier": "@shopify/prettier-config", "dependencies": { @@ -3679,7 +3687,7 @@ Add crypto dependencies for multipass token generation "@types/eslint": "^9.6.1", "@types/react": "catalog:", "@types/react-dom": "catalog:", -~~~ +``` ### Step 24: vite.config.ts @@ -3687,7 +3695,7 @@ Configure Vite for crypto polyfills #### File: /vite.config.ts -~~~diff +```diff @@ -29,6 +29,7 @@ export default defineConfig({ 'react-router > set-cookie-parser', 'react-router > cookie', @@ -3696,10 +3704,10 @@ Configure Vite for crypto polyfills ], }, }, -~~~ +``` ## Deleted Files - [`templates/skeleton/app/routes/account_.authorize.tsx`](templates/skeleton/app/routes/account_.authorize.tsx) - \ No newline at end of file + diff --git a/cookbook/llms/partytown.prompt.md b/cookbook/llms/partytown.prompt.md index 11de328013..67f9b18a73 100644 --- a/cookbook/llms/partytown.prompt.md +++ b/cookbook/llms/partytown.prompt.md @@ -34,11 +34,12 @@ Here's the partytown recipe for the base Hydrogen skeleton template: ## Description -This recipe integrates Partytown with your Hydrogen storefront to run Google Tag Manager -and other third-party scripts in a web worker, keeping the main thread free for critical +This recipe integrates Partytown with your Hydrogen storefront to run Google Tag Manager +and other third-party scripts in a web worker, keeping the main thread free for critical rendering tasks. Key features: + - Moves GTM and analytics scripts off the main thread - Improves Core Web Vitals scores - Maintains full GTM functionality @@ -54,7 +55,7 @@ Key features: - Google Tag Manager container ID (remember to set your `GTM_CONTAINER_ID` or `GTM_ID` environment variable) - Basic understanding of web workers and CSP -- Node.js 18.0.0 or higher +- Node.js 22.22.0 or higher ## New files added to the template by this recipe @@ -71,7 +72,7 @@ Add `public/~partytown` to ignore Partytown library files. #### File: /.gitignore -~~~diff +```diff @@ -4,6 +4,7 @@ node_modules /build /dist @@ -80,7 +81,7 @@ Add `public/~partytown` to ignore Partytown library files. /.mf .env .shopify -~~~ +``` ### Step 2: Create GTM web worker component @@ -88,7 +89,7 @@ Add a GTM component that loads scripts in a web worker. #### File: [PartytownGoogleTagManager.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/components/PartytownGoogleTagManager.tsx) -~~~tsx +```tsx import {useEffect, useRef} from 'react'; /** @@ -158,7 +159,7 @@ export function PartytownGoogleTagManager(props: { ); } -~~~ +``` ### Step 3: Document Partytown setup @@ -166,36 +167,36 @@ Document Partytown setup and configuration instructions. #### File: /README.md -~~~diff +````diff @@ -1,6 +1,6 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton + Partytown + Google Tag Manager - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with [Partytown](https://partytown.builder.io/) for performance-oriented lazy-loading of [Google Tag Manager](https://support.google.com/tagmanager). - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with [Partytown](https://partytown.builder.io/) for performance-oriented lazy-loading of [Google Tag Manager](https://support.google.com/tagmanager). + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -17,12 +17,15 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - GraphQL generator - TypeScript and JavaScript flavors - Minimal setup of components and routes +- **Partytown** - Relocates resource intensive scripts off the main thread into a web worker +- **Google Tag Manager** - Integration with CSP support - + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- [Google Tag Manager ID](https://support.google.com/tagmanager/answer/6103696?hl=en) (optional) - + ```bash npm create @shopify/hydrogen@latest @@ -40,6 +43,62 @@ npm run build npm run dev ``` - + +## Partytown + Google Tag Manager Setup + +### Key files @@ -246,7 +247,7 @@ Document Partytown setup and configuration instructions. +- Improved user experience + ## Setup for using Customer Account API (`/account` section) - + Follow step 1 and 2 of + +## Resources @@ -256,7 +257,7 @@ Document Partytown setup and configuration instructions. +- [Google Tag Manager setup](https://support.google.com/tagmanager/answer/6103696) +- [Introducing Partytown](https://dev.to/adamdbradley/introducing-partytown-run-third-party-scripts-from-a-web-worker-2cnp) \ No newline at end of file -~~~ +```` ### Step 4: Add CORS reverse proxy @@ -264,7 +265,7 @@ Reverse the proxy route for third-party scripts requiring CORS headers. #### File: [reverse-proxy.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts) -~~~ts +```ts // Reverse proxies partytown libs that require CORS. Used by Partytown resolveUrl //@see: https://developers.cloudflare.com/workers/examples/cors-header-proxy/ @@ -469,7 +470,7 @@ async function handleRequest(request: Route.LoaderArgs['request']) { } } } -~~~ +``` ### Step 5: Configure CSP headers @@ -477,7 +478,7 @@ Configure the CSP headers for GTM and Google Analytics domains. #### File: /app/entry.server.tsx -~~~diff +```diff @@ -19,6 +19,19 @@ export default async function handleRequest( checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, storeDomain: context.env.PUBLIC_STORE_DOMAIN, @@ -496,9 +497,9 @@ Configure the CSP headers for GTM and Google Analytics domains. + 'www.google-analytics.com', + ], }); - + const body = await renderToReadableStream( -~~~ +``` ### Step 6: Add URL resolver for proxying @@ -506,7 +507,7 @@ Add a URL resolver to control which scripts should be reverse-proxied. #### File: [maybeProxyRequest.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/utils/partytown/maybeProxyRequest.ts) -~~~ts +```ts /** * Partytown will call this function to resolve any URLs * Many third-party scripts already provide the correct CORS headers, but not all do. For services that do not add the correct headers, then a reverse proxy to another domain must be used in order to provide the CORS headers. @@ -541,7 +542,7 @@ export function maybeProxyRequest(url: URL, location: Location, type: string) { return proxyUrl; } -~~~ +``` ### Step 7: Initialize Partytown and GTM @@ -549,7 +550,7 @@ Initialize Partytown and GTM in the root layout. #### File: /app/root.tsx -~~~diff +```diff @@ -1,4 +1,4 @@ -import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen'; +import {Analytics, getShopAnalytics, useNonce, Script} from '@shopify/hydrogen'; @@ -564,9 +565,9 @@ Initialize Partytown and GTM in the root layout. +import {PartytownGoogleTagManager} from '~/components/PartytownGoogleTagManager'; +import {Partytown} from '@qwik.dev/partytown/react'; +import {maybeProxyRequest} from '~/utils/partytown/maybeProxyRequest'; - + export type RootLoader = typeof loader; - + @@ -90,6 +94,10 @@ export async function loader(args: Route.LoaderArgs) { country: args.context.storefront.i18n.country, language: args.context.storefront.i18n.language, @@ -577,11 +578,11 @@ Initialize Partytown and GTM in the root layout. + args.context.env.GTM_ID || args.context.env.GTM_CONTAINER_ID, }; } - + @@ -163,6 +171,38 @@ export function Layout({children}: {children?: React.ReactNode}) { ); } - + +function PartyTownScripts({gtmContainerId}: {gtmContainerId: string}) { + const nonce = useNonce(); + return ( @@ -616,7 +617,7 @@ Initialize Partytown and GTM in the root layout. + export default function App() { const data = useRouteLoaderData('root'); - + @@ -177,6 +217,7 @@ export default function App() { consent={data.consent} > @@ -625,7 +626,7 @@ Initialize Partytown and GTM in the root layout. -~~~ +``` ### Step 8: Enable atomic mode @@ -633,7 +634,7 @@ Add a helper utility to enable Partytown atomic mode for better performance. #### File: [partytownAtomicHeaders.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/utils/partytown/partytownAtomicHeaders.ts) -~~~ts +```ts /* * Helper utility to enable PartyTown atomic mode * @see: https://partytown.builder.io/atomics @@ -644,7 +645,7 @@ export function partytownAtomicHeaders() { 'Cross-Origin-Opener-Policy': 'same-origin', }; } -~~~ +``` ### Step 9: Install Partytown @@ -652,7 +653,7 @@ Add a Partytown dependency and npm script for copying library files. #### File: /package.json -~~~diff +```diff @@ -8,12 +8,14 @@ "build": "shopify hydrogen build --codegen", "dev": "shopify hydrogen dev --codegen", @@ -670,7 +671,7 @@ Add a Partytown dependency and npm script for copying library files. "@shopify/hydrogen": "workspace:*", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", -~~~ +``` ### Step 10: Configure Vite for Partytown @@ -678,7 +679,7 @@ Configure Vite to exclude Partytown library from build optimization. #### File: /vite.config.ts -~~~diff +```diff @@ -29,6 +29,7 @@ export default defineConfig({ 'react-router > set-cookie-parser', 'react-router > cookie', @@ -687,6 +688,6 @@ Configure Vite to exclude Partytown library from build optimization. ], }, }, -~~~ +``` - \ No newline at end of file + diff --git a/cookbook/llms/subscriptions.prompt.md b/cookbook/llms/subscriptions.prompt.md index 13eb34921f..78500cae6b 100644 --- a/cookbook/llms/subscriptions.prompt.md +++ b/cookbook/llms/subscriptions.prompt.md @@ -17,10 +17,11 @@ Add subscription-based products to your Hydrogen storefront. # User Intent Recognition + - How do I add subscriptions to my Hydrogen storefront? - How do I add selling plans to my Hydrogen storefront? - How do I display subscription details on applicable line items in the cart? - + # Troubleshooting @@ -71,7 +72,7 @@ To implement subscriptions in your own store, you need to install a subscription 1. Install the [Shopify Subscriptions app](https://apps.shopify.com/shopify-subscriptions). 2. In your Shopify admin, [use the Subscriptions app](https://admin.shopify.com/apps/subscriptions-remix/app) to create one or more subscription plans. 3. On the [Products](https://admin.shopify.com/products) page, open any products that will be sold as subscriptions and add the relevant subscription plans in the **Purchase options** section. -The Hydrogen demo storefront comes pre-configured with an example subscription product with the handle `shopify-wax`. + The Hydrogen demo storefront comes pre-configured with an example subscription product with the handle `shopify-wax`. ### Step 2: Update CartLineItem to display subscription details @@ -81,7 +82,7 @@ The Hydrogen demo storefront comes pre-configured with an example subscription p #### File: /app/components/CartLineItem.tsx -~~~diff +```diff @@ -27,7 +27,8 @@ export function CartLineItem({ line: CartLine; childrenMap: LineItemChildrenMap; @@ -105,11 +106,11 @@ The Hydrogen demo storefront comes pre-configured with an example subscription p {selectedOptions.map((option) => (
  • -~~~ +``` ### Step 3: Show subscription options on product pages -In this step we'll implement the ability to display subscription options on product pages, alongside the existing one-off purchase options. +In this step we'll implement the ability to display subscription options on product pages, alongside the existing one-off purchase options. #### Step 3.1: Create a SellingPlanSelector component @@ -117,7 +118,7 @@ Create a new `SellingPlanSelector` component that displays the available subscri ##### File: [SellingPlanSelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/components/SellingPlanSelector.tsx) -~~~tsx +````tsx import type { ProductFragment, SellingPlanGroupFragment, @@ -223,8 +224,7 @@ export function SellingPlanSelector({ ], ); } - -~~~ +```` #### Step 3.2: Add styles for the SellingPlanSelector component @@ -232,7 +232,7 @@ Add styles for the `SellingPlanSelector` component. ##### File: [selling-plan.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/styles/selling-plan.css) -~~~css +```css .selling-plan-group { margin-bottom: 1rem; } @@ -266,8 +266,7 @@ Add styles for the `SellingPlanSelector` component. .selling-plan.unselected { border-color: #fafafa; /* Equivalent to 'border-neutral-50' */ } - -~~~ +``` #### Step 3.3: Update ProductForm to support subscriptions @@ -277,7 +276,7 @@ Add styles for the `SellingPlanSelector` component. ##### File: /app/components/ProductForm.tsx -~~~diff +```diff @@ -6,14 +6,25 @@ import type { } from '@shopify/hydrogen/storefront-api-types'; import {AddToCartButton} from './AddToCartButton'; @@ -291,7 +290,7 @@ Add styles for the `SellingPlanSelector` component. + SellingPlanSelector, + type SellingPlanGroup, +} from '~/components/SellingPlanSelector'; - + export function ProductForm({ productOptions, selectedVariant, @@ -392,7 +391,7 @@ Add styles for the `SellingPlanSelector` component. + + ); +} -~~~ +``` #### Step 3.4: Update ProductPrice to display subscription pricing @@ -401,7 +400,7 @@ Add styles for the `SellingPlanSelector` component. ##### File: /app/components/ProductPrice.tsx -~~~diff +```diff @@ -1,13 +1,31 @@ +import type {CurrencyCode} from '@shopify/hydrogen/customer-account-api-types'; +import type { @@ -410,7 +409,7 @@ Add styles for the `SellingPlanSelector` component. +} from 'storefrontapi.generated'; import {Money} from '@shopify/hydrogen'; import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; - + export function ProductPrice({ price, compareAtPrice, @@ -509,7 +508,7 @@ Add styles for the `SellingPlanSelector` component. + + ); +} -~~~ +``` #### Step 3.5: Update the product page to display subscription options @@ -519,7 +518,7 @@ Add styles for the `SellingPlanSelector` component. ##### File: /app/routes/products.$handle.tsx -~~~diff +```diff @@ -1,5 +1,6 @@ import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/products.$handle'; @@ -530,20 +529,20 @@ Add styles for the `SellingPlanSelector` component. @@ -13,6 +14,12 @@ import {ProductImage} from '~/components/ProductImage'; import {ProductForm} from '~/components/ProductForm'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; - + +import sellingPlanStyle from '~/styles/selling-plan.css?url'; + +export const links: Route.LinksFunction = () => [ + {rel: 'stylesheet', href: sellingPlanStyle}, +]; + - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { return [ - {title: `Hydrogen | ${data?.product.title ?? ''}`}, -@@ -59,8 +66,34 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { + {title: `Hydrogen | ${loaderData?.product.title ?? ''}`}, +@@ -59,8 +66,34 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); - + redirectIfHandleIsLocalized(url, {handle, data: product}); + + // Initialize the selectedSellingPlan to null + let selectedSellingPlan = null; + @@ -574,23 +573,23 @@ Add styles for the `SellingPlanSelector` component. + selectedSellingPlan, }; } - + @@ -77,7 +110,7 @@ function loadDeferredData({context, params}: Route.LoaderArgs) { } - + export default function Product() { - const {product} = useLoaderData(); + const {product, selectedSellingPlan} = useLoaderData(); - + // Optimistically selects a variant with given available variant information const selectedVariant = useOptimisticVariant( @@ -95,7 +128,7 @@ export default function Product() { selectedOrFirstAvailableVariant: selectedVariant, }); - + - const {title, descriptionHtml} = product; + const {title, descriptionHtml, sellingPlanGroups} = product; - + return (
    @@ -105,11 +138,15 @@ export default function Product() { @@ -622,7 +621,7 @@ Add styles for the `SellingPlanSelector` component. + } } ` as const; - + +const SELLING_PLAN_FRAGMENT = `#graphql + fragment SellingPlanMoney on MoneyV2 { + amount @@ -712,7 +711,7 @@ Add styles for the `SellingPlanSelector` component. + ${SELLING_PLAN_GROUP_FRAGMENT} ${PRODUCT_VARIANT_FRAGMENT} ` as const; -~~~ +``` ### Step 4: Show subscription details in the cart @@ -724,7 +723,7 @@ Add `sellingPlanAllocation` field with the plan name to both the standard cart l ##### File: /app/lib/fragments.ts -~~~diff +```diff @@ -59,6 +59,11 @@ export const CART_QUERY_FRAGMENT = `#graphql id } @@ -749,7 +748,7 @@ Add `sellingPlanAllocation` field with the plan name to both the standard cart l } fragment CartApiQuery on Cart { updatedAt -~~~ +``` ### Step 5: Add subscription management to the account page @@ -761,7 +760,7 @@ Create GraphQL queries that retrieve the subscription info from the customer acc ##### File: [CustomerSubscriptionsQuery.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsQuery.ts) -~~~ts +```ts // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer const SUBSCRIPTION_CONTRACT_FRAGMENT = `#graphql @@ -821,8 +820,7 @@ export const SUBSCRIPTIONS_CONTRACTS_QUERY = `#graphql } ${SUBSCRIPTION_CONTRACT_FRAGMENT} ` as const; - -~~~ +``` #### Step 5.2: Add mutations to cancel customer subscriptions @@ -830,7 +828,7 @@ Create a GraqhQL mutation to cancel an existing subscription. ##### File: [CustomerSubscriptionsMutations.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsMutations.ts) -~~~ts +```ts // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer export const SUBSCRIPTION_CANCEL_MUTATION = `#graphql @@ -846,16 +844,15 @@ export const SUBSCRIPTION_CANCEL_MUTATION = `#graphql } } ` as const; - -~~~ +``` #### Step 5.3: Add an account subscriptions page -Create a new account subpage that lets customers manage their existing subscriptions based on the new GraphQL queries and mutations. +Create a new account subpage that lets customers manage their existing subscriptions based on the new GraphQL queries and mutations. ##### File: [account.subscriptions.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/routes/account.subscriptions.tsx) -~~~tsx +```tsx import type { SubscriptionBillingPolicyFragment, SubscriptionDiscountFragmentFragment, @@ -1052,8 +1049,7 @@ function formatDiscountValue( return 'Discount applied'; } - -~~~ +``` #### Step 5.4: Add a link to the Subscriptions page in the account menu @@ -1061,7 +1057,7 @@ Add a `Subscriptions` link to the account menu so customers can easily access th ##### File: /app/routes/account.tsx -~~~diff +```diff @@ -83,6 +83,9 @@ function AccountMenu() {   Addresses   @@ -1072,7 +1068,7 @@ Add a `Subscriptions` link to the account menu so customers can easily access th ); -~~~ +``` #### Step 5.5: Add styles for the Subscriptions page @@ -1080,7 +1076,7 @@ Add styles for the Subscriptions page. ##### File: [account-subscriptions.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/styles/account-subscriptions.css) -~~~css +```css .account-subscriptions { display: flex; flex-direction: column; @@ -1131,52 +1127,6 @@ Add styles for the Subscriptions page. font-size: 0.875rem; margin-right: 0.5rem; } +``` -~~~ - -### Step 7: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - - \ No newline at end of file + diff --git a/cookbook/llms/third-party-api.prompt.md b/cookbook/llms/third-party-api.prompt.md index bdfc82c56a..a8750bc06b 100644 --- a/cookbook/llms/third-party-api.prompt.md +++ b/cookbook/llms/third-party-api.prompt.md @@ -34,22 +34,23 @@ Here's the third-party-api recipe for the base Hydrogen skeleton template: ## Description -This recipe integrates third-party GraphQL APIs into your Hydrogen storefront -with Oxygen's powerful sub-request caching system. Using the Rick & Morty API as an example, +This recipe integrates third-party GraphQL APIs into your Hydrogen storefront +with Oxygen's powerful sub-request caching system. Using the Rick & Morty API as an example, you'll learn how to: -1. **Create a cached GraphQL client** - Build a reusable client factory that minifies queries, +1. **Create a cached GraphQL client** - Build a reusable client factory that minifies queries, handles error handling, and integrates with Oxygen's caching infrastructure. -2. **Integrate with Hydrogen's context** - Add the third-party client to the global context +2. **Integrate with Hydrogen's context** - Add the third-party client to the global context system, making it available in all routes and actions throughout your application. -3. **Query external APIs efficiently** - Fetch data from third-party sources in parallel +3. **Query external APIs efficiently** - Fetch data from third-party sources in parallel with Shopify API calls, leveraging Oxygen's caching to minimize latency and API calls. ## Use cases This pattern is perfect for integrating: + - **CMS platforms** (Contentful, Sanity, Strapi) - **Review systems** (Yotpo, Judge.me, Reviews.io) - **Analytics services** (custom dashboards, reporting APIs) @@ -93,26 +94,26 @@ Add documentation explaining how to integrate external GraphQL APIs with Oxygen #### File: /README.md -~~~diff +````diff @@ -1,6 +1,6 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Third-party API Integration - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, plus an example of integrating third-party GraphQL APIs with Oxygen caching. - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, plus an example of integrating third-party GraphQL APIs with Oxygen caching. + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -40,6 +40,46 @@ npm run build npm run dev ``` - + +## Third-party API Integration + +This example demonstrates how to query third-party GraphQL APIs with Oxygen's sub-request caching. The example uses the public [Rick & Morty API](https://rickandmortyapi.com/documentation/#graphql) to show how to: + +1. Create a cached GraphQL client for third-party APIs -+2. Integrate the client into Hydrogen's context ++2. Integrate the client into Hydrogen's context +3. Query and display data from external APIs alongside Shopify data + +### Key files for third-party API integration @@ -148,9 +149,9 @@ Add documentation explaining how to integrate external GraphQL APIs with Oxygen +This pattern can be adapted for any third-party API integration including CMS systems, review platforms, analytics services, or custom backend APIs. + ## Setup for using Customer Account API (`/account` section) - + Follow step 1 and 2 of -~~~ +```` ### Step 2: Create the third-party API client @@ -159,13 +160,15 @@ This client handles query minification, error handling, and cache key generation #### File: [createRickAndMortyClient.server.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/third-party-api/ingredients/templates/skeleton/app/lib/createRickAndMortyClient.server.ts) -~~~ts +```ts import { createWithCache, CacheLong, type CachingStrategy, } from '@shopify/hydrogen'; +export const OPERATION_NAME_PATTERN = /^(query|mutation)\s\w+/; + export function createRickAndMortyClient({ cache, waitUntil, @@ -200,7 +203,7 @@ export function createRickAndMortyClient({ shouldCacheResponse: (body) => !body?.error, cacheKey: ['r&m', body], displayName: - 'Rick & Morty - ' + query.match(/^(query|mutation)\s\w+/)?.[0], + 'Rick & Morty - ' + query.match(OPERATION_NAME_PATTERN)?.[0], }, ); @@ -216,13 +219,13 @@ export function createRickAndMortyClient({ }; } -function minifyQuery(string: T) { +export function minifyQuery(string: T) { return string .replace(/\s*#.*$/gm, '') // Remove GQL comments .replace(/\s+/gm, ' ') // Minify spaces .trim() as T; } -~~~ +``` ### Step 3: Add the client to Hydrogen context @@ -231,12 +234,13 @@ in all routes. Also update TypeScript declarations for proper type support. #### File: /app/lib/context.ts -~~~diff -@@ -1,25 +1,10 @@ - import {createHydrogenContext} from '@shopify/hydrogen'; +```diff +@@ -2,29 +2,10 @@ import {AppSession} from '~/lib/session'; import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; -- + import type {CartApiQueryFragment} from 'storefrontapi.generated'; ++import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server'; + -// Define the additional context object -const additionalContext = { - // Additional context for custom properties, CMS clients, 3P SDKs, etc. @@ -251,19 +255,22 @@ in all routes. Also update TypeScript declarations for proper type support. - -declare global { - interface HydrogenAdditionalContext extends AdditionalContextType {} +- +- // Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so +- // that context.cart.get() and all cart mutations return the extended cart type. +- interface HydrogenCustomCartFragment extends CartApiQueryFragment {} -} -+import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server'; - +- /** -- * Creates Hydrogen context for React Router 7.9.x -+ * Creates Hydrogen context for React Router 7.9.x with third-party API support +- * Creates the Hydrogen context used by React Router loaders and actions. ++ * Creates the Hydrogen context used by React Router loaders and actions with third-party API support. * Returns HydrogenRouterContextProvider with hybrid access patterns * */ export async function createHydrogenRouterContext( -@@ -40,6 +25,19 @@ export async function createHydrogenRouterContext( +@@ -45,6 +26,19 @@ AppSession.init(request, [env.SESSION_SECRET]), ]); - + + // @description Create a Rick and Morty client for third-party GraphQL queries with Oxygen caching + const rickAndMorty = createRickAndMortyClient({ + cache, @@ -280,8 +287,8 @@ in all routes. Also update TypeScript declarations for proper type support. const hydrogenContext = createHydrogenContext( { env, -@@ -58,3 +56,12 @@ export async function createHydrogenRouterContext( - +@@ -63,3 +57,13 @@ + return hydrogenContext; } + @@ -292,18 +299,18 @@ in all routes. Also update TypeScript declarations for proper type support. + +declare global { + interface HydrogenAdditionalContext extends AdditionalContextType {} ++ interface HydrogenCustomCartFragment extends CartApiQueryFragment {} +} -\ No newline at end of file -~~~ +``` ### Step 4: Query and display third-party data Update the homepage to fetch data from the third-party API and display it alongside Shopify data. This demonstrates parallel data fetching and proper caching strategies. -#### File: /app/routes/_index.tsx +#### File: /app/routes/\_index.tsx -~~~diff +```diff @@ -1,7 +1,7 @@ import {Await, useLoaderData, Link} from 'react-router'; import type {Route} from './+types/_index'; @@ -327,7 +334,7 @@ Shopify data. This demonstrates parallel data fetching and proper caching strate + cache: CacheShort(), + }), ]); - + return { isShopLinked: Boolean(context.env.PUBLIC_STORE_DOMAIN), featuredCollection: collections.nodes[0], @@ -335,7 +342,7 @@ Shopify data. This demonstrates parallel data fetching and proper caching strate + characters, }; } - + @@ -63,12 +69,50 @@ export default function Homepage() { return (
    @@ -347,7 +354,7 @@ Shopify data. This demonstrates parallel data fetching and proper caching strate
    ); } - + +// @description Component to display Rick & Morty characters fetched from third-party API +function ThirdPartyApiExample({characters}: {characters: any}) { + return ( @@ -390,7 +397,7 @@ Shopify data. This demonstrates parallel data fetching and proper caching strate @@ -147,6 +191,18 @@ const FEATURED_COLLECTION_QUERY = `#graphql } ` as const; - + +// @description GraphQL query for Rick & Morty characters from third-party API +const CHARACTERS_QUERY = `#graphql:rickAndMorty + query Characters { @@ -406,51 +413,6 @@ Shopify data. This demonstrates parallel data fetching and proper caching strate const RECOMMENDED_PRODUCTS_QUERY = `#graphql fragment RecommendedProduct on Product { id -~~~ - -### Step 4: package.json - - - -#### File: /package.json - -~~~diff -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - - \ No newline at end of file +``` + + diff --git a/cookbook/recipes/b2b/README.md b/cookbook/recipes/b2b/README.md index c3a4c15721..e96124e7f7 100644 --- a/cookbook/recipes/b2b/README.md +++ b/cookbook/recipes/b2b/README.md @@ -7,8 +7,8 @@ This recipe adds comprehensive B2B functionality to your Hydrogen storefront, en - See volume-based price breaks for bulk purchases - Access B2B-specific pricing through contextualized GraphQL queries -The implementation includes a location selector modal, quantity rule displays on product pages, -and cart functionality that respects B2B quantity rules. All product queries are contextualized +The implementation includes a location selector modal, quantity rule displays on product pages, +and cart functionality that respects B2B quantity rules. All product queries are contextualized with buyer information (company location + customer token) to ensure accurate B2B pricing. > [!NOTE] @@ -25,14 +25,14 @@ with buyer information (company location + customer token) to ensure accurate B2 _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/B2BLocationProvider.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/B2BLocationProvider.tsx) | React context provider that manages company location state across the application | -| [app/components/B2BLocationSelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/B2BLocationSelector.tsx) | Modal component that allows customers to choose which company location to use for pricing | -| [app/components/PriceBreaks.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/PriceBreaks.tsx) | Component that displays volume pricing tiers showing bulk purchase discounts | -| [app/components/QuantityRules.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/QuantityRules.tsx) | Component that displays minimum, maximum, and increment quantity requirements for B2B products | -| [app/graphql/customer-account/CustomerLocationsQuery.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/graphql/customer-account/CustomerLocationsQuery.ts) | GraphQL query to fetch company locations from the Customer Account API for B2B customers | -| [app/routes/b2blocations.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/routes/b2blocations.tsx) | Route handler for location selection that automatically sets location if customer has only one | +| File | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| [app/components/B2BLocationProvider.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/B2BLocationProvider.tsx) | React context provider that manages company location state across the application | +| [app/components/B2BLocationSelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/B2BLocationSelector.tsx) | Modal component that allows customers to choose which company location to use for pricing | +| [app/components/PriceBreaks.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/PriceBreaks.tsx) | Component that displays volume pricing tiers showing bulk purchase discounts | +| [app/components/QuantityRules.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/components/QuantityRules.tsx) | Component that displays minimum, maximum, and increment quantity requirements for B2B products | +| [app/graphql/customer-account/CustomerLocationsQuery.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/graphql/customer-account/CustomerLocationsQuery.ts) | GraphQL query to fetch company locations from the Customer Account API for B2B customers | +| [app/routes/b2blocations.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/b2b/ingredients/templates/skeleton/app/routes/b2blocations.tsx) | Route handler for location selection that automatically sets location if customer has only one | ## Steps @@ -42,14 +42,14 @@ Update the README file with comprehensive B2B commerce documentation, explaining #### File: [README.md](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/README.md) -~~~diff +```diff index c584e5370..e3231cba4 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -18,6 +18,45 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - TypeScript and JavaScript flavors - Minimal setup of components and routes - + +## B2B Features + +This template includes B2B (Business-to-Business) functionality for stores on Shopify Plus plans: @@ -90,9 +90,9 @@ index c584e5370..e3231cba4 100644 +> In this implementation, only the product display page (`app/routes/products.$handle.tsx`) uses contextualized queries. For production, all product queries should be contextualized with buyer information. + ## Getting started - + **Requirements:** -~~~ +``` ### Step 2: Create a React context provider to manage company location state across the app @@ -102,7 +102,7 @@ Create a React context provider that manages the selected company location state
    -~~~tsx +```tsx import {createContext, useContext, useEffect, useState, useMemo} from 'react'; import {useFetcher} from 'react-router'; import {type CustomerCompany} from '~/root'; @@ -154,7 +154,7 @@ export function B2BLocationProvider({children}: {children: React.ReactNode}) { export function useB2BLocation(): B2BLocationContextValue { return useContext(B2BLocationContext); } -~~~ +```
    @@ -164,7 +164,7 @@ Update cart line item quantity controls to honor B2B quantity rules including mi #### File: [app/components/CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/CartLineItem.tsx) -~~~diff +```diff index 63a0ecd9d..ad9c43133 100644 --- a/templates/skeleton/app/components/CartLineItem.tsx +++ b/templates/skeleton/app/components/CartLineItem.tsx @@ -181,7 +181,7 @@ index 63a0ecd9d..ad9c43133 100644 + quantity % increment === 0 ? increment : quantity % increment; + const prevQuantity = Number(Math.max(0, quantity - prevIncrement).toFixed(0)); + const nextQuantity = Number((quantity + nextIncrement).toFixed(0)); - + return (
    @@ -114,7 +119,7 @@ function CartLineQuantity({line}: {line: CartLine}) { @@ -204,7 +204,7 @@ index 63a0ecd9d..ad9c43133 100644 > + -~~~ +``` ### Step 4: Build a modal that lets customers choose which company location to use for pricing @@ -214,7 +214,7 @@ Create a modal component that displays available company locations and allows B2
    -~~~tsx +```tsx import React from 'react'; import {CartForm} from '@shopify/hydrogen'; import type { @@ -286,7 +286,7 @@ export function B2BLocationSelector() {
    ); } -~~~ +``` @@ -298,7 +298,7 @@ Add a location selector button to the site header that allows B2B customers to e
    -~~~diff +```diff index 045a75925..7e9a74c57 100644 --- a/templates/skeleton/app/components/Header.tsx +++ b/templates/skeleton/app/components/Header.tsx @@ -309,7 +309,7 @@ index 045a75925..7e9a74c57 100644 +// @description Import B2B types and hooks for company location management +import {type CustomerCompanyLocationConnection} from '~/root'; +import {useB2BLocation} from './B2BLocationProvider'; - + interface HeaderProps { header: HeaderQuery; @@ -91,6 +94,8 @@ export function HeaderMenu({ @@ -324,7 +324,7 @@ index 045a75925..7e9a74c57 100644 @@ -175,6 +180,29 @@ function CartBanner() { return ; } - + +// @description Add B2B location change button for company location selection +function ChangeLocation() { + const {company, companyLocationId, setModalOpen} = useB2BLocation(); @@ -351,7 +351,7 @@ index 045a75925..7e9a74c57 100644 const FALLBACK_HEADER_MENU = { id: 'gid://shopify/Menu/199655587896', items: [ -~~~ +```
    @@ -363,7 +363,7 @@ Create a component that displays volume pricing breaks, showing customers the di
    -~~~tsx +```tsx import {Money} from '@shopify/hydrogen'; import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; @@ -403,7 +403,7 @@ export function PriceBreaks({priceBreaks}: PriceBreaksProps) { ); } -~~~ +```
    @@ -413,14 +413,14 @@ Update the ProductForm component to accept and handle quantity parameters that e #### File: [app/components/ProductForm.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/ProductForm.tsx) -~~~diff +```diff index 47c8f3056..5e3ec2c17 100644 --- a/templates/skeleton/app/components/ProductForm.tsx +++ b/templates/skeleton/app/components/ProductForm.tsx @@ -8,12 +8,15 @@ import {AddToCartButton} from './AddToCartButton'; import {useAside} from './Aside'; import type {ProductFragment} from 'storefrontapi.generated'; - + +// @description Add quantity parameter for B2B quantity support export function ProductForm({ productOptions, @@ -443,7 +443,7 @@ index 47c8f3056..5e3ec2c17 100644 selectedVariant, }, ] -~~~ +``` ### Step 8: Show minimum, maximum, and increment quantity requirements for B2B products @@ -453,7 +453,7 @@ Create a component that displays B2B quantity rules to customers, showing minimu
    -~~~tsx +```tsx import type {Maybe} from '@shopify/hydrogen/customer-account-api-types'; export type QuantityRulesProps = { @@ -498,7 +498,7 @@ export function QuantityRules({ ); } -~~~ +```
    @@ -508,7 +508,7 @@ Update GraphQL fragments to include quantity rules and price break data needed f #### File: [app/lib/fragments.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/lib/fragments.ts) -~~~diff +```diff index f6b6b2a36..ac961801e 100644 --- a/templates/skeleton/app/lib/fragments.ts +++ b/templates/skeleton/app/lib/fragments.ts @@ -556,55 +556,7 @@ index f6b6b2a36..ac961801e 100644 } } lineComponents { -~~~ - -### Step 10: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ### Step 10: Query company locations from the Customer Account API for B2B customers @@ -614,7 +566,7 @@ Create a GraphQL query that fetches all available company locations for B2B cust
    -~~~ts +```ts // NOTE: https://shopify.dev/docs/api/customer/latest/objects/Customer export const CUSTOMER_LOCATIONS_QUERY = `#graphql query CustomerLocations { @@ -648,7 +600,7 @@ export const CUSTOMER_LOCATIONS_QUERY = `#graphql } } ` as const; -~~~ +```
    @@ -660,7 +612,7 @@ Wrap the application with the B2B location provider context and add TypeScript t
    -~~~diff +```diff index df87425c5..5a0fef093 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -677,9 +629,9 @@ index df87425c5..5a0fef093 100644 + CompanyLocation, + Maybe, +} from '@shopify/hydrogen/customer-account-api-types'; - + export type RootLoader = typeof loader; - + +// @description Define B2B customer company types +export type CustomerCompanyLocation = Pick & { + shippingAddress?: @@ -721,7 +673,7 @@ index df87425c5..5a0fef093 100644 ); } -~~~ +```
    @@ -733,7 +685,7 @@ Create a route handler that processes location selection requests and automatica
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/b2blocations'; import {B2BLocationSelector} from '../components/B2BLocationSelector'; @@ -772,7 +724,7 @@ export async function loader({context}: Route.LoaderArgs) { export default function CartRoute() { return ; } -~~~ +```
    @@ -782,20 +734,20 @@ Update the cart route to call customerAccount.setBuyer() when processing BuyerId #### File: [app/routes/cart.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/cart.tsx) -~~~diff +```diff index 983f6505d..0fdab3fb8 100644 --- a/templates/skeleton/app/routes/cart.tsx +++ b/templates/skeleton/app/routes/cart.tsx @@ -11,7 +11,8 @@ export const meta: Route.MetaFunction = () => { export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; - + export async function action({request, context}: Route.ActionArgs) { - const {cart} = context; + // @description Destructure customerAccount for B2B location session persistence + const {cart, customerAccount} = context; - + const formData = await request.formData(); - + @@ -64,6 +65,12 @@ export async function action({request, context}: Route.ActionArgs) { break; } @@ -809,21 +761,21 @@ index 983f6505d..0fdab3fb8 100644 result = await cart.updateBuyerIdentity({ ...inputs.buyerIdentity, }); -~~~ +``` ### Step 14: Clear company location and customer data from cart when logging out Update the logout process to clear B2B-specific data including selected company location and customer context from the cart session -#### File: [app/routes/account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.logout.tsx) +#### File: [app/routes/account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.logout.tsx) -~~~diff +```diff index 5e67cc857..6d331155e 100644 --- a/templates/skeleton/app/routes/account_.logout.tsx +++ b/templates/skeleton/app/routes/account_.logout.tsx @@ -7,5 +7,10 @@ export async function loader() { } - + export async function action({context}: Route.ActionArgs) { + // @description Clear B2B company location on logout + await context.cart.updateBuyerIdentity({ @@ -832,7 +784,7 @@ index 5e67cc857..6d331155e 100644 + }); return context.customerAccount.logout(); } -~~~ +``` ### Step 15: Contextualize product queries with buyer information and display B2B pricing details @@ -842,7 +794,7 @@ Update product queries to include buyer context (company location and customer t
    -~~~diff +```diff index 99730d418..fe1d09ab6 100644 --- a/templates/skeleton/app/routes/products.$handle.tsx +++ b/templates/skeleton/app/routes/products.$handle.tsx @@ -863,12 +815,12 @@ index 99730d418..fe1d09ab6 100644 + }; + } + | {}; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { return [ -@@ -24,11 +37,24 @@ export const meta: Route.MetaFunction = ({data}) => { +@@ -24,11 +37,24 @@ export const meta: Route.MetaFunction = ({loaderData}) => { }; - + export async function loader(args: Route.LoaderArgs) { + // @description Get B2B buyer context for contextualized product queries + const buyer = await args.context.customerAccount.getBuyer(); @@ -886,29 +838,30 @@ index 99730d418..fe1d09ab6 100644 // Start fetching non-critical data without blocking time to first byte - const deferredData = loadDeferredData(args); + const deferredData = loadDeferredData({...args, buyerVariables}); - + // Await the critical data required to render initial state of the page - const criticalData = await loadCriticalData(args); + const criticalData = await loadCriticalData({...args, buyerVariables}); - + return {...deferredData, ...criticalData}; } @@ -37,7 +63,12 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +-async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { +async function loadCriticalData({ + context, + params, + request, ++ url, + buyerVariables, +}: Route.LoaderArgs & {buyerVariables: BuyerVariables}) { const {handle} = params; const {storefront} = context; - -@@ -47,7 +78,11 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { - + +@@ -47,7 +78,11 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { + const [{product}] = await Promise.all([ storefront.query(PRODUCT_QUERY, { - variables: {handle, selectedOptions: getSelectedProductOptions(request)}, @@ -920,7 +873,7 @@ index 99730d418..fe1d09ab6 100644 }), // Add other queries here, so that they are loaded in parallel ]); -@@ -69,7 +104,11 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -69,7 +104,11 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { * 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. */ @@ -932,7 +885,7 @@ index 99730d418..fe1d09ab6 100644 +}: Route.LoaderArgs & {buyerVariables: BuyerVariables}) { // Put any API calls that is not critical to be available on first page render // For example: product reviews, product recommendations, social feeds. - + @@ -110,8 +149,26 @@ export default function Product() { \ No newline at end of file +
    diff --git a/cookbook/recipes/b2b/patches/package.json.8e0ff5.patch b/cookbook/recipes/b2b/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/b2b/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/b2b/patches/products.$handle.tsx.30ba8d.patch b/cookbook/recipes/b2b/patches/products.$handle.tsx.30ba8d.patch index 7a1debafda..eb2c34dde0 100644 --- a/cookbook/recipes/b2b/patches/products.$handle.tsx.30ba8d.patch +++ b/cookbook/recipes/b2b/patches/products.$handle.tsx.30ba8d.patch @@ -19,9 +19,9 @@ index 99730d418..fe1d09ab6 100644 + } + | {}; - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { return [ -@@ -24,11 +37,24 @@ export const meta: Route.MetaFunction = ({data}) => { +@@ -24,11 +37,24 @@ export const meta: Route.MetaFunction = ({loaderData}) => { }; export async function loader(args: Route.LoaderArgs) { @@ -52,17 +52,18 @@ index 99730d418..fe1d09ab6 100644 * 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) { +-async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { +async function loadCriticalData({ + context, + params, + request, ++ url, + buyerVariables, +}: Route.LoaderArgs & {buyerVariables: BuyerVariables}) { const {handle} = params; const {storefront} = context; -@@ -47,7 +78,11 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -47,7 +78,11 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { const [{product}] = await Promise.all([ storefront.query(PRODUCT_QUERY, { @@ -75,7 +76,7 @@ index 99730d418..fe1d09ab6 100644 }), // Add other queries here, so that they are loaded in parallel ]); -@@ -69,7 +104,11 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -69,7 +104,11 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { * 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. */ diff --git a/cookbook/recipes/b2b/recipe.yaml b/cookbook/recipes/b2b/recipe.yaml index 712fc16558..9059dc243b 100644 --- a/cookbook/recipes/b2b/recipe.yaml +++ b/cookbook/recipes/b2b/recipe.yaml @@ -25,19 +25,23 @@ requirements: | - Products with quantity rules or volume pricing configured (optional but recommended for testing) ingredients: - path: templates/skeleton/app/components/B2BLocationProvider.tsx - description: React context provider that manages company location state across + description: + React context provider that manages company location state across the application - path: templates/skeleton/app/components/B2BLocationSelector.tsx description: Modal component that allows customers to choose which company location to use for pricing - path: templates/skeleton/app/components/PriceBreaks.tsx - description: Component that displays volume pricing tiers showing bulk purchase + description: + Component that displays volume pricing tiers showing bulk purchase discounts - path: templates/skeleton/app/components/QuantityRules.tsx - description: Component that displays minimum, maximum, and increment quantity + description: + Component that displays minimum, maximum, and increment quantity requirements for B2B products - path: templates/skeleton/app/graphql/customer-account/CustomerLocationsQuery.ts - description: GraphQL query to fetch company locations from the Customer Account + description: + GraphQL query to fetch company locations from the Customer Account API for B2B customers - path: templates/skeleton/app/routes/b2blocations.tsx description: Route handler for location selection that automatically sets @@ -45,7 +49,7 @@ ingredients: deletedFiles: [] steps: - type: PATCH - step: "1" + step: '1' name: Document B2B features, requirements, and implementation details in the project README description: Update the README file with comprehensive B2B commerce @@ -55,44 +59,52 @@ steps: - file: README.md patchFile: README.md.1764cd.patch - type: NEW_FILE - step: "2" - name: Create a React context provider to manage company location state across + step: '2' + name: + Create a React context provider to manage company location state across the app - description: Create a React context provider that manages the selected company + description: + Create a React context provider that manages the selected company location state and provides location switching functionality throughout the B2B application ingredients: - path: templates/skeleton/app/components/B2BLocationProvider.tsx - type: PATCH - step: "3" - name: Update quantity controls to respect B2B minimum, maximum, and increment + step: '3' + name: + Update quantity controls to respect B2B minimum, maximum, and increment rules - description: Update cart line item quantity controls to honor B2B quantity rules + description: + Update cart line item quantity controls to honor B2B quantity rules including minimum order quantities, maximum limits, and increment requirements diffs: - file: app/components/CartLineItem.tsx patchFile: CartLineItem.tsx.c12ba7.patch - type: NEW_FILE - step: "4" - name: Build a modal that lets customers choose which company location to use for + step: '4' + name: + Build a modal that lets customers choose which company location to use for pricing - description: Create a modal component that displays available company locations + description: + Create a modal component that displays available company locations and allows B2B customers to select the appropriate location for contextualized pricing ingredients: - path: templates/skeleton/app/components/B2BLocationSelector.tsx - type: PATCH - step: "5" - name: Add a location selector button to the header for switching between company + step: '5' + name: + Add a location selector button to the header for switching between company locations - description: Add a location selector button to the site header that allows B2B + description: + Add a location selector button to the site header that allows B2B customers to easily switch between their company's different locations diffs: - file: app/components/Header.tsx patchFile: Header.tsx.00776f.patch - type: NEW_FILE - step: "6" + step: '6' name: Display volume pricing tiers that show bulk purchase discounts description: Create a component that displays volume pricing breaks, showing customers the discounted prices they'll receive for bulk purchases at @@ -100,8 +112,9 @@ steps: ingredients: - path: templates/skeleton/app/components/PriceBreaks.tsx - type: PATCH - step: "7" - name: Accept quantity parameter to support B2B minimum quantity and increment + step: '7' + name: + Accept quantity parameter to support B2B minimum quantity and increment rules description: Update the ProductForm component to accept and handle quantity parameters that enforce B2B minimum quantities and increment rules when @@ -110,54 +123,53 @@ steps: - file: app/components/ProductForm.tsx patchFile: ProductForm.tsx.aa8a24.patch - type: NEW_FILE - step: "8" + step: '8' name: Show minimum, maximum, and increment quantity requirements for B2B products - description: Create a component that displays B2B quantity rules to customers, + description: + Create a component that displays B2B quantity rules to customers, showing minimum order quantities, maximum limits, and required increments for business products ingredients: - path: templates/skeleton/app/components/QuantityRules.tsx - type: PATCH - step: "9" + step: '9' name: Add quantity rules and price breaks to cart GraphQL fragments - description: Update GraphQL fragments to include quantity rules and price break + description: + Update GraphQL fragments to include quantity rules and price break data needed for B2B functionality in cart queries diffs: - file: app/lib/fragments.ts patchFile: fragments.ts.e564c1.patch - - type: PATCH - step: "10" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch - type: NEW_FILE - step: "10" + step: '10' name: Query company locations from the Customer Account API for B2B customers - description: Create a GraphQL query that fetches all available company locations + description: + Create a GraphQL query that fetches all available company locations for B2B customers from the Customer Account API ingredients: - path: templates/skeleton/app/graphql/customer-account/CustomerLocationsQuery.ts - type: PATCH - step: "11" + step: '11' name: Wrap the app with B2B location provider and add company location types - description: Wrap the application with the B2B location provider context and add + description: + Wrap the application with the B2B location provider context and add TypeScript type definitions for company location data diffs: - file: app/root.tsx patchFile: root.tsx.8f1e92.patch - type: NEW_FILE - step: "12" - name: Handle location selection and automatically set location if customer has + step: '12' + name: + Handle location selection and automatically set location if customer has only one - description: Create a route handler that processes location selection requests + description: + Create a route handler that processes location selection requests and automatically sets the location if a B2B customer only has access to one company location ingredients: - path: templates/skeleton/app/routes/b2blocations.tsx - type: PATCH - step: "13" + step: '13' name: Persist B2B company location in session when updating buyer identity description: Update the cart route to call customerAccount.setBuyer() when processing BuyerIdentityUpdate, so the selected company location persists @@ -166,7 +178,7 @@ steps: - file: app/routes/cart.tsx patchFile: cart.tsx.825f67.patch - type: PATCH - step: "14" + step: '14' name: Clear company location and customer data from cart when logging out description: Update the logout process to clear B2B-specific data including selected company location and customer context from the cart session @@ -174,10 +186,11 @@ steps: - file: app/routes/account_.logout.tsx patchFile: account_.logout.tsx.2930de.patch - type: PATCH - step: "15" + step: '15' name: Contextualize product queries with buyer information and display B2B pricing details - description: Update product queries to include buyer context (company location + description: + Update product queries to include buyer context (company location and customer token) and display B2B-specific pricing, quantity rules, and volume discounts on product pages diffs: diff --git a/cookbook/recipes/bundles/README.md b/cookbook/recipes/bundles/README.md index 3fbe2718db..753913a843 100644 --- a/cookbook/recipes/bundles/README.md +++ b/cookbook/recipes/bundles/README.md @@ -7,12 +7,12 @@ when they're viewing product and collection pages. In this recipe you'll make the following changes: 1. Set up the Shopify Bundles app in your Shopify admin and create a new -product bundle. + product bundle. 2. Update the GraphQL fragments to query for bundles to identify bundled -products. + products. 3. Update the product and collection templates to display badges on product -listings, update the copy for the cart buttons, and display bundle-specific -information on product and collection pages. + listings, update the copy for the cart buttons, and display bundle-specific + information on product and collection pages. 4. Update the cart line item template to display the bundle badge as needed. ## Requirements @@ -24,9 +24,9 @@ In this recipe, we'll use the [Shopify Bundles app](https://apps.shopify.com/sho _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/BundleBadge.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundleBadge.tsx) | A badge displayed on bundle product listings. | +| File | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| [app/components/BundleBadge.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundleBadge.tsx) | A badge displayed on bundle product listings. | | [app/components/BundledVariants.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/bundles/ingredients/templates/skeleton/app/components/BundledVariants.tsx) | A component that wraps the variants of a bundle product in a single product listing. | ## Steps @@ -47,7 +47,7 @@ Create a new BundleBadge component to be displayed on bundle product listings.
    -~~~tsx +```tsx export function BundleBadge() { return (
    ); } - -~~~ +```
    @@ -78,7 +77,7 @@ Create a new `BundledVariants` component that wraps the variants of a bundle pro
    -~~~tsx +```tsx import {Link} from 'react-router'; import {Image} from '@shopify/hydrogen'; import type { @@ -142,8 +141,7 @@ export function BundledVariants({
    ); } - -~~~ +``` @@ -151,9 +149,9 @@ export function BundledVariants({ Add `maxVariantPrice` to the `RecommendedProducts` query's product fields. -#### File: [app/routes/_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/_index.tsx) +#### File: [app/routes/\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/_index.tsx) -~~~diff +```diff index 237edc911..ebb71b7e2 100644 --- a/templates/skeleton/app/routes/_index.tsx +++ b/templates/skeleton/app/routes/_index.tsx @@ -168,19 +166,19 @@ index 237edc911..ebb71b7e2 100644 } featuredImage { id -~~~ +``` ### Step 5: Show bundled products on the product page 1. Add the `requiresComponents` field to the `Product` fragment, which is -used to identify bundled products. + used to identify bundled products. 2. Pass the `isBundle` flag to the `ProductImage` component. #### File: [app/routes/products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/products.$handle.tsx)
    -~~~diff +```diff index 99730d418..66298e8b9 100644 --- a/templates/skeleton/app/routes/products.$handle.tsx +++ b/templates/skeleton/app/routes/products.$handle.tsx @@ -190,13 +188,13 @@ index 99730d418..66298e8b9 100644 import {redirectIfHandleIsLocalized} from '~/lib/redirect'; +import type {ProductVariantComponent} from '@shopify/hydrogen/storefront-api-types'; +import {BundledVariants} from '~/components/BundledVariants'; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { return [ @@ -97,9 +99,12 @@ export default function Product() { - + const {title, descriptionHtml} = product; - + + const isBundle = Boolean(product.isBundle?.requiresComponents); + const bundledVariants = isBundle ? product.isBundle?.components.nodes : null; + @@ -258,7 +256,7 @@ index 99730d418..66298e8b9 100644 + } } ` as const; - + @@ -209,6 +245,25 @@ const PRODUCT_FRAGMENT = `#graphql adjacentVariants (selectedOptions: $selectedOptions) { ...ProductVariant @@ -285,7 +283,7 @@ index 99730d418..66298e8b9 100644 seo { description title -~~~ +```
    @@ -295,7 +293,7 @@ Like the previous step, use the `requiresComponents` field to detect if the prod #### File: [app/routes/collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/collections.$handle.tsx) -~~~diff +```diff index c416c2b3d..773ba8b6c 100644 --- a/templates/skeleton/app/routes/collections.$handle.tsx +++ b/templates/skeleton/app/routes/collections.$handle.tsx @@ -311,13 +309,13 @@ index c416c2b3d..773ba8b6c 100644 + } } ` as const; - + -// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection +// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/collection const COLLECTION_QUERY = `#graphql ${PRODUCT_ITEM_FRAGMENT} query Collection( -~~~ +``` ### Step 7: Identify bundles in the cart @@ -327,7 +325,7 @@ Use the `requiresComponents` field to determine if a cart line item is a bundle.
    -~~~diff +```diff index f6b6b2a36..42534be7e 100644 --- a/templates/skeleton/app/lib/fragments.ts +++ b/templates/skeleton/app/lib/fragments.ts @@ -380,7 +378,7 @@ index f6b6b2a36..42534be7e 100644 } } lineComponents { -~~~ +```
    @@ -390,7 +388,7 @@ If a product is a bundle, show the `BundleBadge` component in the cart line item #### File: [app/components/CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/CartLineItem.tsx) -~~~diff +```diff index 63a0ecd9d..22b2d90fe 100644 --- a/templates/skeleton/app/components/CartLineItem.tsx +++ b/templates/skeleton/app/components/CartLineItem.tsx @@ -399,9 +397,9 @@ index 63a0ecd9d..22b2d90fe 100644 CartLineFragment, } from 'storefrontapi.generated'; +import {BundleBadge} from '~/components/BundleBadge'; - + export type CartLine = OptimisticCartLine; - + @@ -31,6 +32,7 @@ export function CartLineItem({ const {product, title, image, selectedOptions} = merchandise; const lineItemUrl = useVariantUrl(product.handle, selectedOptions); @@ -409,11 +407,11 @@ index 63a0ecd9d..22b2d90fe 100644 + const isBundle = Boolean(line.merchandise.requiresComponents); const lineItemChildren = childrenMap[id]; const childrenLabelId = `cart-line-children-${id}`; - + @@ -48,8 +50,9 @@ export function CartLineItem({ /> )} - + -
    +
      -~~~ +``` ### Step 9: Update the cart button text for bundles @@ -441,7 +439,7 @@ If a product is a bundle, update the text of the product button. #### File: [app/components/ProductForm.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/ProductForm.tsx) -~~~diff +```diff index 47c8f3056..b9406e814 100644 --- a/templates/skeleton/app/components/ProductForm.tsx +++ b/templates/skeleton/app/components/ProductForm.tsx @@ -470,7 +468,7 @@ index 47c8f3056..b9406e814 100644
    ); -~~~ +``` ### Step 10: Show bundle badges on product images @@ -478,7 +476,7 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductImage` #### File: [app/components/ProductImage.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/ProductImage.tsx) -~~~diff +```diff index 5f3ac1cce..c16b947b0 100644 --- a/templates/skeleton/app/components/ProductImage.tsx +++ b/templates/skeleton/app/components/ProductImage.tsx @@ -486,7 +484,7 @@ index 5f3ac1cce..c16b947b0 100644 import type {ProductVariantFragment} from 'storefrontapi.generated'; import {Image} from '@shopify/hydrogen'; +import {BundleBadge} from './BundleBadge'; - + export function ProductImage({ image, + isBundle = false, @@ -504,55 +502,7 @@ index 5f3ac1cce..c16b947b0 100644
    ); } -~~~ - -### Step 10: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ +``` ### Step 11: Show bundle badges on product cards @@ -562,7 +512,7 @@ If a product is a bundle, show the `BundleBadge` component in the `ProductItem`
    -~~~diff +```diff index 3b0f69133..1b6cb130a 100644 --- a/templates/skeleton/app/components/ProductItem.tsx +++ b/templates/skeleton/app/components/ProductItem.tsx @@ -577,7 +527,7 @@ index 3b0f69133..1b6cb130a 100644 +import type {ProductItemFragment} from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {BundleBadge} from '~/components/BundleBadge'; - + export function ProductItem({ product, loading, @@ -632,7 +582,7 @@ index 3b0f69133..1b6cb130a 100644 ); } -~~~ +```
    @@ -642,14 +592,14 @@ Make sure the bundle badge is positioned relative to the product image. #### File: [app/styles/app.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/styles/app.css) -~~~diff +```diff index 3be08d316..51b749115 100644 --- a/templates/skeleton/app/styles/app.css +++ b/templates/skeleton/app/styles/app.css @@ -506,6 +506,10 @@ button.reset:hover:not(:has(> *)) { margin-top: 0; } - + +.product-image { + position: relative; +} @@ -657,9 +607,9 @@ index 3be08d316..51b749115 100644 .product-image img { height: auto; width: 100%; -~~~ +``` ## Next steps - Test your implementation by going to your store and adding a bundle to the cart. Make sure that the bundle's badge appears on the product page and in the cart. -- (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for bundles appear in your Shopify admin. \ No newline at end of file +- (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for bundles appear in your Shopify admin. diff --git a/cookbook/recipes/bundles/patches/package.json.8e0ff5.patch b/cookbook/recipes/bundles/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/bundles/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/bundles/patches/products.$handle.tsx.30ba8d.patch b/cookbook/recipes/bundles/patches/products.$handle.tsx.30ba8d.patch index 82a58b4a0a..a713338853 100644 --- a/cookbook/recipes/bundles/patches/products.$handle.tsx.30ba8d.patch +++ b/cookbook/recipes/bundles/patches/products.$handle.tsx.30ba8d.patch @@ -8,7 +8,7 @@ index 99730d418..66298e8b9 100644 +import type {ProductVariantComponent} from '@shopify/hydrogen/storefront-api-types'; +import {BundledVariants} from '~/components/BundledVariants'; - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { return [ @@ -97,9 +99,12 @@ export default function Product() { diff --git a/cookbook/recipes/bundles/recipe.yaml b/cookbook/recipes/bundles/recipe.yaml index b2dda47a23..96e64dad89 100644 --- a/cookbook/recipes/bundles/recipe.yaml +++ b/cookbook/recipes/bundles/recipe.yaml @@ -26,12 +26,13 @@ ingredients: - path: templates/skeleton/app/components/BundleBadge.tsx description: A badge displayed on bundle product listings. - path: templates/skeleton/app/components/BundledVariants.tsx - description: A component that wraps the variants of a bundle product in a single + description: + A component that wraps the variants of a bundle product in a single product listing. deletedFiles: [] steps: - type: INFO - step: "1" + step: '1' name: Set up the Shopify Bundles app description: | 1. Install the [Shopify Bundles app](https://apps.shopify.com/shopify-bundles) in your Shopify admin. @@ -40,21 +41,22 @@ steps: 3. From the [**Bundles**](https://admin.shopify.com/apps/shopify-bundles/app) page, [create a new bundle](https://help.shopify.com/en/manual/products/bundles/shopify-bundles). - type: NEW_FILE - step: "2" + step: '2' name: Create the BundleBadge component description: | Create a new BundleBadge component to be displayed on bundle product listings. ingredients: - path: templates/skeleton/app/components/BundleBadge.tsx - type: NEW_FILE - step: "3" + step: '3' name: Create a new BundledVariants component - description: Create a new `BundledVariants` component that wraps the variants of + description: + Create a new `BundledVariants` component that wraps the variants of a bundle product in a single product listing. ingredients: - path: templates/skeleton/app/components/BundledVariants.tsx - type: PATCH - step: "4" + step: '4' name: Query bundle pricing for recommended products description: | Add `maxVariantPrice` to the `RecommendedProducts` query's product fields. @@ -62,7 +64,7 @@ steps: - file: app/routes/_index.tsx patchFile: _index.tsx.8ba83c.patch - type: PATCH - step: "5" + step: '5' name: Show bundled products on the product page description: | 1. Add the `requiresComponents` field to the `Product` fragment, which is @@ -72,7 +74,7 @@ steps: - file: app/routes/products.$handle.tsx patchFile: products.$handle.tsx.30ba8d.patch - type: PATCH - step: "6" + step: '6' name: Detect bundles in collection listings description: | Like the previous step, use the `requiresComponents` field to detect if the product item is a bundle. @@ -80,7 +82,7 @@ steps: - file: app/routes/collections.$handle.tsx patchFile: collections.$handle.tsx.7abca5.patch - type: PATCH - step: "7" + step: '7' name: Identify bundles in the cart description: | Use the `requiresComponents` field to determine if a cart line item is a bundle. @@ -88,7 +90,7 @@ steps: - file: app/lib/fragments.ts patchFile: fragments.ts.e564c1.patch - type: PATCH - step: "8" + step: '8' name: Show bundle badges in the cart description: | If a product is a bundle, show the `BundleBadge` component in the cart line item. @@ -96,7 +98,7 @@ steps: - file: app/components/CartLineItem.tsx patchFile: CartLineItem.tsx.c12ba7.patch - type: PATCH - step: "9" + step: '9' name: Update the cart button text for bundles description: | If a product is a bundle, update the text of the product button. @@ -104,7 +106,7 @@ steps: - file: app/components/ProductForm.tsx patchFile: ProductForm.tsx.aa8a24.patch - type: PATCH - step: "10" + step: '10' name: Show bundle badges on product images description: | If a product is a bundle, show the `BundleBadge` component in the `ProductImage` component. @@ -112,14 +114,7 @@ steps: - file: app/components/ProductImage.tsx patchFile: ProductImage.tsx.a62ff5.patch - type: PATCH - step: "10" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch - - type: PATCH - step: "11" + step: '11' name: Show bundle badges on product cards description: | If a product is a bundle, show the `BundleBadge` component in the `ProductItem` component. @@ -127,7 +122,7 @@ steps: - file: app/components/ProductItem.tsx patchFile: ProductItem.tsx.c6506f.patch - type: PATCH - step: "12" + step: '12' name: Position bundle badges on images description: | Make sure the bundle badge is positioned relative to the product image. @@ -145,14 +140,17 @@ llms: - How do I detect if a product is a bundle? troubleshooting: - issue: I'm not seeing product bundles on my storefront. - solution: Make sure you've installed the Shopify Bundles app and set up product + solution: + Make sure you've installed the Shopify Bundles app and set up product bundles in your Shopify admin. - issue: I'm not seeing product bundle badges on product pages. - solution: Make sure you've installed the Shopify Bundles app and set up product + solution: + Make sure you've installed the Shopify Bundles app and set up product bundles in your Shopify admin. Then make sure you've updated the product fragment to query for bundles and display BundledVariants. - issue: I'm not seeing the product bundle badges on my cart line items. - solution: Make sure you've installed the Shopify Bundles app and set up product + solution: + Make sure you've installed the Shopify Bundles app and set up product bundles in your Shopify admin. Then make sure you've updated the cart fragment to query for bundles. commit: 1040066d20b52667756fd1ebffd8607602a735b4 diff --git a/cookbook/recipes/combined-listings/README.md b/cookbook/recipes/combined-listings/README.md index 799b0ca50f..f9b4e46705 100644 --- a/cookbook/recipes/combined-listings/README.md +++ b/cookbook/recipes/combined-listings/README.md @@ -11,7 +11,7 @@ In this recipe, you'll make the following changes: 5. Show a range of prices for combined listings in `ProductItem`. > [!NOTE] -> This recipe is compatible with React Router 7.9.x and uses consolidated imports from 'react-router' instead of separate '@shopify/remix-oxygen' and '@remix-run/react' packages. +> This recipe is compatible with React Router framework mode and uses consolidated imports from 'react-router' instead of separate '@shopify/remix-oxygen' and '@remix-run/react' packages. ## Requirements @@ -22,8 +22,8 @@ In this recipe, you'll make the following changes: _New files added to the template by this recipe._ -| File | Description | -| --- | --- | +| File | Description | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | [app/lib/combined-listings.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/combined-listings/ingredients/templates/skeleton/app/lib/combined-listings.ts) | The `combined-listings.ts` file contains utilities and settings for handling combined listings. | ## Steps @@ -62,7 +62,7 @@ Create a new `combined-listings.ts` file that contains utilities and settings fo
    -~~~ts +```ts // Edit these values to customize combined listings' behavior export const combinedListingsSettings = { // If true, loading the product page will redirect to the first variant @@ -98,8 +98,7 @@ export function isCombinedListing(product: unknown) { product.tags.includes(combinedListingsSettings.combinedListingTag) ); } - -~~~ +```
    @@ -112,7 +111,7 @@ export function isCombinedListing(product: unknown) {
    -~~~diff +```diff index 47c8f3056..670d0804f 100644 --- a/templates/skeleton/app/components/ProductForm.tsx +++ b/templates/skeleton/app/components/ProductForm.tsx @@ -207,7 +206,7 @@ index 47c8f3056..670d0804f 100644 ); } -~~~ +```
    @@ -217,7 +216,7 @@ Update the `ProductImage` component to support images from both product variants #### File: [app/components/ProductImage.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/ProductImage.tsx) -~~~diff +```diff index 5f3ac1cce..f1c9f2cdd 100644 --- a/templates/skeleton/app/components/ProductImage.tsx +++ b/templates/skeleton/app/components/ProductImage.tsx @@ -228,7 +227,7 @@ index 5f3ac1cce..f1c9f2cdd 100644 + ProductFragment, +} from 'storefrontapi.generated'; import {Image} from '@shopify/hydrogen'; - + export function ProductImage({ image, }: { @@ -237,7 +236,7 @@ index 5f3ac1cce..f1c9f2cdd 100644 }) { if (!image) { return
    ; -~~~ +``` ### Step 6: Show a range of prices for combined listings in ProductItem @@ -245,7 +244,7 @@ Update `ProductItem.tsx` to show a range of prices for the combined listing pare #### File: [app/components/ProductItem.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/ProductItem.tsx) -~~~diff +```diff index 3b0f69133..07fc73cd2 100644 --- a/templates/skeleton/app/components/ProductItem.tsx +++ b/templates/skeleton/app/components/ProductItem.tsx @@ -254,7 +253,7 @@ index 3b0f69133..07fc73cd2 100644 } from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {isCombinedListing} from '../lib/combined-listings'; - + export function ProductItem({ product, @@ -36,9 +37,17 @@ export function ProductItem({ @@ -278,7 +277,7 @@ index 3b0f69133..07fc73cd2 100644 ); } -~~~ +``` ### Step 7: (Optional) Redirect to the first variant @@ -286,7 +285,7 @@ If you want to redirect automatically to the first variant of a combined listing #### File: [app/lib/redirect.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/lib/redirect.ts) -~~~diff +```diff index f18c1d0d6..cbffc8a44 100644 --- a/templates/skeleton/app/lib/redirect.ts +++ b/templates/skeleton/app/lib/redirect.ts @@ -294,7 +293,7 @@ index f18c1d0d6..cbffc8a44 100644 import {redirect} from 'react-router'; +import type {ProductFragment} from 'storefrontapi.generated'; +import {isCombinedListing} from './combined-listings'; - + export function redirectIfHandleIsLocalized( request: Request, @@ -21,3 +23,23 @@ export function redirectIfHandleIsLocalized( @@ -321,7 +320,7 @@ index f18c1d0d6..cbffc8a44 100644 + throw redirect(url.toString()); + } +} -~~~ +``` ### Step 8: Filter combined listings from the all products page @@ -329,7 +328,7 @@ Update the "all products" collection page to filter out combined listing parent #### File: [app/routes/collections.all.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/collections.all.tsx) -~~~diff +```diff index 5a122262f..a0fd66d73 100644 --- a/templates/skeleton/app/routes/collections.all.tsx +++ b/templates/skeleton/app/routes/collections.all.tsx @@ -341,11 +340,11 @@ index 5a122262f..a0fd66d73 100644 + combinedListingsSettings, + maybeFilterOutCombinedListingsQuery, +} from '../lib/combined-listings'; - + export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Products`}]; @@ -31,7 +35,12 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { - + const [{products}] = await Promise.all([ storefront.query(CATALOG_QUERY, { - variables: {...paginationVariables}, @@ -377,18 +376,18 @@ index 5a122262f..a0fd66d73 100644 nodes { ...CollectionItem } -~~~ +``` ### Step 9: Filter recommended products 1. Add the `tags` property to the items returned by the product query. 2. (Optional) Add the filtering query to the product query to exclude combined listings. -#### File: [app/routes/_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/_index.tsx) +#### File: [app/routes/\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/_index.tsx)
    -~~~diff +```diff index 237edc911..bfde9915e 100644 --- a/templates/skeleton/app/routes/_index.tsx +++ b/templates/skeleton/app/routes/_index.tsx @@ -397,7 +396,7 @@ index 237edc911..bfde9915e 100644 import {ProductItem} from '~/components/ProductItem'; import {MockShopNotice} from '~/components/MockShopNotice'; +import {maybeFilterOutCombinedListingsQuery} from '~/lib/combined-listings'; - + export const meta: Route.MetaFunction = () => { return [{title: 'Hydrogen | Home'}]; @@ -46,7 +47,11 @@ async function loadCriticalData({context}: Route.LoaderArgs) { @@ -453,7 +452,7 @@ index 237edc911..bfde9915e 100644 nodes { ...RecommendedProduct } -~~~ +```
    @@ -465,7 +464,7 @@ Since it's not possible to directly apply query filters when retrieving collecti
    -~~~diff +```diff index c416c2b3d..b627a950d 100644 --- a/templates/skeleton/app/routes/collections.$handle.tsx +++ b/templates/skeleton/app/routes/collections.$handle.tsx @@ -477,13 +476,13 @@ index c416c2b3d..b627a950d 100644 + combinedListingsSettings, + isCombinedListing, +} from '~/lib/combined-listings'; - - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}]; + + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}]; @@ -68,12 +72,25 @@ function loadDeferredData({context}: Route.LoaderArgs) { export default function Collection() { const {collection} = useLoaderData(); - + + // Manually filter out combined listings from the collection products, because filtering + // would not work here. + const filteredCollectionProducts = { @@ -524,58 +523,10 @@ index c416c2b3d..b627a950d 100644 ) { nodes { ...ProductItem -~~~ +```
    -### Step 10: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - ### Step 11: Show price ranges on product pages 1. Display a range of prices for combined listings instead of the variant price. @@ -586,7 +537,7 @@ index 0bb332639..651bbfffa 100644
    -~~~diff +```diff index 99730d418..bd97c83cb 100644 --- a/templates/skeleton/app/routes/products.$handle.tsx +++ b/templates/skeleton/app/routes/products.$handle.tsx @@ -603,13 +554,13 @@ index 99730d418..bd97c83cb 100644 + isCombinedListing, + combinedListingsSettings, +} from '../lib/combined-listings'; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { return [ -@@ -59,6 +66,10 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -59,6 +66,10 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); - + redirectIfHandleIsLocalized(url, {handle, data: product}); + + if (combinedListingsSettings.redirectToFirstVariant) { + redirectIfCombinedListing(request, product); + } @@ -618,35 +569,35 @@ index 99730d418..bd97c83cb 100644 product, }; @@ -78,6 +89,7 @@ function loadDeferredData({context, params}: Route.LoaderArgs) { - + export default function Product() { const {product} = useLoaderData(); + const combinedListing = isCombinedListing(product); - + // Optimistically selects a variant with given available variant information const selectedVariant = useOptimisticVariant( @@ -87,7 +99,9 @@ export default function Product() { - + // Sets the search param to the selected variant without navigation // only when no search params are set in the url - useSelectedOptionInUrlParam(selectedVariant.selectedOptions); + useSelectedOptionInUrlParam( + combinedListing ? [] : selectedVariant.selectedOptions, + ); - + // Get the product options array const productOptions = getProductOptions({ @@ -95,21 +109,41 @@ export default function Product() { selectedOrFirstAvailableVariant: selectedVariant, }); - + - const {title, descriptionHtml} = product; + const {descriptionHtml, title} = product; + + const productImage = combinedListing + ? (product.featuredImage ?? selectedVariant?.image) + : selectedVariant?.image; - + return (
    - @@ -708,7 +659,7 @@ index 99730d418..bd97c83cb 100644 options { name optionValues { -~~~ +```
    @@ -718,14 +669,14 @@ Add a class to the product item to show a range of prices for combined listings. #### File: [app/styles/app.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/styles/app.css) -~~~diff +```diff index 3be08d316..38e9afed1 100644 --- a/templates/skeleton/app/styles/app.css +++ b/templates/skeleton/app/styles/app.css @@ -489,6 +489,11 @@ button.reset:hover:not(:has(> *)) { width: 100%; } - + +.product-item .combined-listing-price { + display: flex; + grid-gap: 0.5rem; @@ -734,9 +685,9 @@ index 3be08d316..38e9afed1 100644 /* * -------------------------------------------------- * routes/products.$handle.tsx -~~~ +``` ## Next steps - Test your implementation by going to your store and searching for a combined listing. Make sure that the combined listing's details appear in the search results and on the product page. -- (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for combined listings appear in your Shopify admin. \ No newline at end of file +- (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for combined listings appear in your Shopify admin. diff --git a/cookbook/recipes/combined-listings/patches/collections.$handle.tsx.7abca5.patch b/cookbook/recipes/combined-listings/patches/collections.$handle.tsx.7abca5.patch index 9b306b88f8..9ddd27ac9b 100644 --- a/cookbook/recipes/combined-listings/patches/collections.$handle.tsx.7abca5.patch +++ b/cookbook/recipes/combined-listings/patches/collections.$handle.tsx.7abca5.patch @@ -10,8 +10,8 @@ index c416c2b3d..b627a950d 100644 + isCombinedListing, +} from '~/lib/combined-listings'; - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}]; + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}]; @@ -68,12 +72,25 @@ function loadDeferredData({context}: Route.LoaderArgs) { export default function Collection() { const {collection} = useLoaderData(); diff --git a/cookbook/recipes/combined-listings/patches/package.json.8e0ff5.patch b/cookbook/recipes/combined-listings/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/combined-listings/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/combined-listings/patches/products.$handle.tsx.30ba8d.patch b/cookbook/recipes/combined-listings/patches/products.$handle.tsx.30ba8d.patch index 3faa1c6459..46bc5b78e4 100644 --- a/cookbook/recipes/combined-listings/patches/products.$handle.tsx.30ba8d.patch +++ b/cookbook/recipes/combined-listings/patches/products.$handle.tsx.30ba8d.patch @@ -15,11 +15,11 @@ index 99730d418..bd97c83cb 100644 + combinedListingsSettings, +} from '../lib/combined-listings'; - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { return [ -@@ -59,6 +66,10 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -59,6 +66,10 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); + redirectIfHandleIsLocalized(url, {handle, data: product}); + if (combinedListingsSettings.redirectToFirstVariant) { + redirectIfCombinedListing(request, product); diff --git a/cookbook/recipes/combined-listings/recipe.yaml b/cookbook/recipes/combined-listings/recipe.yaml index 36ff8f6a65..e95b793a0b 100644 --- a/cookbook/recipes/combined-listings/recipe.yaml +++ b/cookbook/recipes/combined-listings/recipe.yaml @@ -14,7 +14,7 @@ description: | 4. Update the `ProductImage` component to support images from product variants and the product itself. 5. Show a range of prices for combined listings in `ProductItem`. notes: - - This recipe is compatible with React Router 7.9.x and uses consolidated + - This recipe is compatible with React Router framework mode and uses consolidated imports from 'react-router' instead of separate '@shopify/remix-oxygen' and '@remix-run/react' packages. requirements: | @@ -22,12 +22,13 @@ requirements: | - Your store must have the [Combined Listings app](https://admin.shopify.com/apps/combined-listings) installed. ingredients: - path: templates/skeleton/app/lib/combined-listings.ts - description: The `combined-listings.ts` file contains utilities and settings for + description: + The `combined-listings.ts` file contains utilities and settings for handling combined listings. deletedFiles: [] steps: - type: INFO - step: "1" + step: '1' name: Set up the Combined Listings app description: | 1. Install the [Combined Listings app](https://admin.shopify.com/apps/combined-listings). @@ -36,7 +37,7 @@ steps: 3. Add tags to the parent products of combined listings to indicate that they're part of a combined listing (for example `combined`). - type: INFO - step: "2" + step: '2' name: Configure combined listings behavior description: | You can customize how the parent products of combined listings are retrieved and displayed. @@ -55,14 +56,15 @@ steps: }; ``` - type: NEW_FILE - step: "3" + step: '3' name: Add combined listings utilities - description: Create a new `combined-listings.ts` file that contains utilities + description: + Create a new `combined-listings.ts` file that contains utilities and settings for handling combined listings. ingredients: - path: templates/skeleton/app/lib/combined-listings.ts - type: PATCH - step: "4" + step: '4' name: Hide the cart button for combined listing parent products description: | 1. Update the `ProductForm` component to hide the **Add to cart** button for the parent products of combined listings and for variants' selected state. @@ -71,7 +73,7 @@ steps: - file: app/components/ProductForm.tsx patchFile: ProductForm.tsx.aa8a24.patch - type: PATCH - step: "5" + step: '5' name: Support product and variant images description: | Update the `ProductImage` component to support images from both product variants and the product itself. @@ -79,7 +81,7 @@ steps: - file: app/components/ProductImage.tsx patchFile: ProductImage.tsx.a62ff5.patch - type: PATCH - step: "6" + step: '6' name: Show a range of prices for combined listings in ProductItem description: | Update `ProductItem.tsx` to show a range of prices for the combined listing parent product instead of the variant price. @@ -87,7 +89,7 @@ steps: - file: app/components/ProductItem.tsx patchFile: ProductItem.tsx.c6506f.patch - type: PATCH - step: "7" + step: '7' name: (Optional) Redirect to the first variant description: | If you want to redirect automatically to the first variant of a combined listing when the parent handle is selected, add a redirect utility that's called whenever the parent handle is requested. @@ -95,16 +97,17 @@ steps: - file: app/lib/redirect.ts patchFile: redirect.ts.c1ee52.patch - type: PATCH - step: "8" + step: '8' name: Filter combined listings from the all products page - description: Update the "all products" collection page to filter out combined + description: + Update the "all products" collection page to filter out combined listing parent products, showing only the individual variant products instead diffs: - file: app/routes/collections.all.tsx patchFile: collections.all.tsx.88626d.patch - type: PATCH - step: "9" + step: '9' name: Filter recommended products description: | 1. Add the `tags` property to the items returned by the product query. @@ -113,7 +116,7 @@ steps: - file: app/routes/_index.tsx patchFile: _index.tsx.8ba83c.patch - type: PATCH - step: "10" + step: '10' name: (Optional) Filter out combined listings from collections pages description: | Since it's not possible to directly apply query filters when retrieving collection products, you can manually filter out combined listings after they're retrieved based on their tags. @@ -121,14 +124,7 @@ steps: - file: app/routes/collections.$handle.tsx patchFile: collections.$handle.tsx.7abca5.patch - type: PATCH - step: "10" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch - - type: PATCH - step: "11" + step: '11' name: Show price ranges on product pages description: | 1. Display a range of prices for combined listings instead of the variant price. @@ -138,7 +134,7 @@ steps: - file: app/routes/products.$handle.tsx patchFile: products.$handle.tsx.30ba8d.patch - type: PATCH - step: "12" + step: '12' name: Style the price range display description: | Add a class to the product item to show a range of prices for combined listings. @@ -162,7 +158,8 @@ llms: variant price? troubleshooting: - issue: Combined listings are being displayed in the product list. - solution: Make sure to tag combined listing parent products in the Shopify admin + solution: + Make sure to tag combined listing parent products in the Shopify admin and use that tag to filter out combined listings from the product list in the GraphQL query. commit: 1040066d20b52667756fd1ebffd8607602a735b4 diff --git a/cookbook/recipes/custom-cart-method/README.md b/cookbook/recipes/custom-cart-method/README.md index 7969e5fd5d..368e463be0 100644 --- a/cookbook/recipes/custom-cart-method/README.md +++ b/cookbook/recipes/custom-cart-method/README.md @@ -8,6 +8,7 @@ the cart again. Their cart updates will happen automatically, without requiring a full page refresh. Key features: + - Custom cart method `updateLineByOptions` for variant selection - Inline dropdown selectors for each product option in cart - Automatic cart updates when options are changed @@ -30,21 +31,21 @@ Update the README file with custom cart method documentation and an implementati
    -~~~diff +````diff index c584e5370..d40091392 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,6 +1,8 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Custom Cart Method - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates how to implement custom cart methods for inline product option editing. Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates how to implement custom cart methods for inline product option editing. Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to enable users to change product variants (size, color, etc.) directly within the cart without removing and re-adding items, providing a smoother shopping experience. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,7 +18,29 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -73,13 +74,13 @@ index c584e5370..d40091392 100644 +2. Custom cart method queries for new variant +3. Cart line item updates with new variant +4. Total price and inventory automatically adjust - + ## Getting started - + @@ -28,6 +52,25 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov npm create @shopify/hydrogen@latest ``` - + +## Implementation Details + +### Custom Cart Method @@ -88,7 +89,7 @@ index c584e5370..d40091392 100644 + const {product} = await storefront.query(VARIANTS_QUERY, { + variables: {handle: productHandle, selectedOptions} + }); -+ ++ + return cart.updateLineItems([{ + id: lineId, + merchandiseId: product.variantBySelectedOptions?.id @@ -100,12 +101,12 @@ index c584e5370..d40091392 100644 +The recipe extends Hydrogen's cart context with proper TypeScript types for the custom method, ensuring type safety throughout your application. + ## Building for production - + ```bash @@ -40,6 +83,21 @@ npm run build npm run dev ``` - + +## Important Notes + +After applying this recipe: @@ -122,11 +123,11 @@ index c584e5370..d40091392 100644 +- Build advanced cart customization flows + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +````
    @@ -138,7 +139,7 @@ Add variant selector functionality to cart line items for changing product optio
    -~~~diff +```diff index 63a0ecd9d..4cf222237 100644 --- a/templates/skeleton/app/components/CartLineItem.tsx +++ b/templates/skeleton/app/components/CartLineItem.tsx @@ -177,7 +178,7 @@ index 63a0ecd9d..4cf222237 100644 @@ -185,6 +187,115 @@ function CartLineUpdateButton({ ); } - + +/** Option shape for cart line option selects */ +type CartLineOption = { + name: string; @@ -290,7 +291,7 @@ index 63a0ecd9d..4cf222237 100644 /** * Returns a unique key for the update action. This is used to make sure actions modifying the same line * items are not run concurrently, but cancel each other. For example, if the user clicks "Increase quantity" -~~~ +```
    @@ -302,11 +303,11 @@ Extend HydrogenCart context with updateLineByOptions method for variant switchin
    -~~~diff +```diff index 692d5ae17..c2dc8b338 100644 --- a/templates/skeleton/app/lib/context.ts +++ b/templates/skeleton/app/lib/context.ts -@@ -1,6 +1,15 @@ +@@ -1,7 +1,16 @@ -import {createHydrogenContext} from '@shopify/hydrogen'; +import { + createHydrogenContext, @@ -317,18 +318,19 @@ index 692d5ae17..c2dc8b338 100644 import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; +import {CART_QUERY_FRAGMENT, PRODUCT_VARIANT_QUERY} from '~/lib/fragments'; + import type {CartApiQueryFragment} from 'storefrontapi.generated'; +import type { + SelectedOptionInput, + CartLineUpdateInput, +} from '@shopify/hydrogen/storefront-api-types'; - + // Define the additional context object const additionalContext = { -@@ -16,6 +25,15 @@ type AdditionalContextType = typeof additionalContext; - - declare global { - interface HydrogenAdditionalContext extends AdditionalContextType {} -+ +@@ -21,6 +30,15 @@ + // Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so + // that context.cart.get() and all cart mutations return the extended cart type. + interface HydrogenCustomCartFragment extends CartApiQueryFragment {} ++ + // @description Augment the cart with custom methods for variant selection + interface HydrogenCustomCartMethods { + updateLineByOptions: ( @@ -338,19 +340,19 @@ index 692d5ae17..c2dc8b338 100644 + ) => Promise; + } } - + /** -@@ -40,7 +58,8 @@ export async function createHydrogenRouterContext( +@@ -45,7 +63,8 @@ AppSession.init(request, [env.SESSION_SECRET]), ]); - + - const hydrogenContext = createHydrogenContext( + // @description Create a placeholder context first to reference in customMethods + const hydrogenContext: ReturnType = createHydrogenContext( { env, request, -@@ -51,6 +70,33 @@ export async function createHydrogenRouterContext( +@@ -56,6 +75,33 @@ i18n: {language: 'EN', country: 'US'}, cart: { queryFragment: CART_QUERY_FRAGMENT, @@ -384,7 +386,7 @@ index 692d5ae17..c2dc8b338 100644 }, }, additionalContext, -~~~ +```
    @@ -396,7 +398,7 @@ Add product options to cart fragments and create PRODUCT_VARIANT_QUERY for fetch
    -~~~diff +```diff index f6b6b2a36..a289365b3 100644 --- a/templates/skeleton/app/lib/fragments.ts +++ b/templates/skeleton/app/lib/fragments.ts @@ -459,7 +461,7 @@ index f6b6b2a36..a289365b3 100644 + } + } +`; -~~~ +```
    @@ -469,7 +471,7 @@ Implement the CustomUpdateLineByOptions action handler for processing variant ch #### File: [app/routes/cart.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/cart.tsx) -~~~diff +```diff index 983f6505d..483814267 100644 --- a/templates/skeleton/app/routes/cart.tsx +++ b/templates/skeleton/app/routes/cart.tsx @@ -482,11 +484,11 @@ index 983f6505d..483814267 100644 + CartLineUpdateInput, +} from '@shopify/hydrogen/storefront-api-types'; import {CartMain} from '~/components/CartMain'; - + export const meta: Route.MetaFunction = () => { @@ -25,6 +29,21 @@ export async function action({request, context}: Route.ActionArgs) { let result: CartQueryDataReturn; - + switch (action) { + // @description Handle custom action for updating line item variant options + case 'CustomUpdateLineByOptions': @@ -506,52 +508,4 @@ index 983f6505d..483814267 100644 case CartForm.ACTIONS.LinesAdd: result = await cart.addLines(inputs.lines); break; -~~~ - -### Step 6: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ \ No newline at end of file +``` diff --git a/cookbook/recipes/custom-cart-method/patches/README.md.1764cd.patch b/cookbook/recipes/custom-cart-method/patches/README.md.1764cd.patch index c5b01954e7..e76a83f71a 100644 --- a/cookbook/recipes/custom-cart-method/patches/README.md.1764cd.patch +++ b/cookbook/recipes/custom-cart-method/patches/README.md.1764cd.patch @@ -5,13 +5,13 @@ index c584e5370..d40091392 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Custom Cart Method --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates how to implement custom cart methods for inline product option editing. Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates how to implement custom cart methods for inline product option editing. Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to enable users to change product variants (size, color, etc.) directly within the cart without removing and re-adding items, providing a smoother shopping experience. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,7 +18,29 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator diff --git a/cookbook/recipes/custom-cart-method/patches/package.json.8e0ff5.patch b/cookbook/recipes/custom-cart-method/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/custom-cart-method/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/custom-cart-method/recipe.yaml b/cookbook/recipes/custom-cart-method/recipe.yaml index b9560ca700..db32b03014 100644 --- a/cookbook/recipes/custom-cart-method/recipe.yaml +++ b/cookbook/recipes/custom-cart-method/recipe.yaml @@ -26,23 +26,25 @@ ingredients: [] deletedFiles: [] steps: - type: PATCH - step: "1" + step: '1' name: Document custom cart methods in the README - description: Update the README file with custom cart method documentation and an + description: + Update the README file with custom cart method documentation and an implementation guide. diffs: - file: README.md patchFile: README.md.1764cd.patch - type: PATCH - step: "2" + step: '2' name: Let customers change variants directly in the cart - description: Add variant selector functionality to cart line items for changing + description: + Add variant selector functionality to cart line items for changing product options. diffs: - file: app/components/CartLineItem.tsx patchFile: CartLineItem.tsx.c12ba7.patch - type: PATCH - step: "3" + step: '3' name: Add updateLineByOptions method to cart context description: Extend HydrogenCart context with updateLineByOptions method for variant switching. @@ -50,7 +52,7 @@ steps: - file: app/lib/context.ts patchFile: context.ts.c15041.patch - type: PATCH - step: "4" + step: '4' name: Query product options for variant selection description: Add product options to cart fragments and create PRODUCT_VARIANT_QUERY for fetching variants. @@ -58,20 +60,13 @@ steps: - file: app/lib/fragments.ts patchFile: fragments.ts.e564c1.patch - type: PATCH - step: "5" + step: '5' name: Handle variant updates in the cart route description: Implement the CustomUpdateLineByOptions action handler for processing variant changes in cart. diffs: - file: app/routes/cart.tsx patchFile: cart.tsx.825f67.patch - - type: PATCH - step: "6" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch nextSteps: null llms: userQueries: [] diff --git a/cookbook/recipes/express/README.md b/cookbook/recipes/express/README.md index 3bd6f4af11..4977fa258b 100644 --- a/cookbook/recipes/express/README.md +++ b/cookbook/recipes/express/README.md @@ -6,6 +6,7 @@ Hydrogen functionality including GraphQL codegen and Storefront API integration Oxygen-specific features with Express equivalents. Key changes: + - Replaces Oxygen server with Express server - Uses Vite for development with hot module replacement - Implements session management through Express middleware @@ -13,16 +14,17 @@ Key changes: - Keeps GraphQL codegen functionality intact Technical details: + - Uses nodemon for development server with automatic restarts - Environment variables are loaded from .env file using dotenv - Session management is handled through Express middleware with SESSION_SECRET - GraphQL codegen still works with Storefront API types -- Compatible with React Router 7.8.x +- Compatible with React Router framework mode - The .graphqlrc.ts file is preserved with customer account section commented out ## Requirements -- Node.js 20 or higher for production deployment +- Node.js 22.22.0 or higher for production deployment - npm or yarn package manager - Shopify Storefront API credentials @@ -30,12 +32,12 @@ Technical details: _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/env.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/app/env.ts) | Environment type definitions for Express server | -| [public/favicon.svg](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/public/favicon.svg) | Favicon for Express template | -| [scripts/dev.mjs](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/scripts/dev.mjs) | Development orchestration script | -| [server.mjs](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/server.mjs) | Express server with Hydrogen context and SSR | +| File | Description | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| [app/env.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/app/env.ts) | Environment type definitions for Express server | +| [public/favicon.svg](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/public/favicon.svg) | Favicon for Express template | +| [scripts/dev.mjs](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/scripts/dev.mjs) | Development orchestration script | +| [server.mjs](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/server.mjs) | Express server with Hydrogen context and SSR | ## Steps @@ -43,16 +45,16 @@ _New files added to the template by this recipe._ Comment out customer account GraphQL configuration -#### File: [.graphqlrc.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/.graphqlrc.ts) +#### File: [.graphqlrc.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/.graphqlrc.ts) -~~~diff +```diff index b1b5e85a..9fefa34a 100644 --- a/templates/skeleton/.graphqlrc.ts +++ b/templates/skeleton/.graphqlrc.ts @@ -17,10 +17,11 @@ const graphqlConfig: IGraphQLConfig = { ], }, - + - customer: { - schema: getSchema('customer-account'), - documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'], @@ -62,40 +64,40 @@ index b1b5e85a..9fefa34a 100644 + // schema: getSchema('customer-account'), + // documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'], + // }, - + // Add your own GraphQL projects here for CMS, Shopify Admin API, etc. }, -~~~ +``` ### Step 2: Update README for Express deployment Update README with Express-specific setup and deployment instructions -#### File: [README.md](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/README.md) +#### File: [README.md](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/README.md)
    -~~~diff +````diff index c584e537..4cecca6d 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,45 +1,89 @@ -# Hydrogen template: Skeleton +# Hydrogen Express Skeleton - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This is a Hydrogen skeleton template configured to run with NodeJS [Express](https://expressjs.com/) instead of Shopify Oxygen. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This is a Hydrogen skeleton template configured to run with NodeJS [Express](https://expressjs.com/) instead of Shopify Oxygen. + +Hydrogen is Shopify's stack for headless commerce, designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal Express setup** with basic components and routes to get started with Hydrogen on Node.js. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) --[Get familiar with Remix](https://remix.run/docs/en/v1) +-[Get familiar with React Router](https://reactrouter.com/) +[Get familiar with React Router](https://reactrouter.com/en/main) - + ## What's included - --- Remix -+- React Router 7 + +-- React Router ++- React Router - Hydrogen -- Oxygen +- Express server @@ -107,7 +109,7 @@ index c584e537..4cecca6d 100644 -- GraphQL generator -- TypeScript and JavaScript flavors - Minimal setup of components and routes - + +## Important Notes + +This Express setup differs from the standard Hydrogen template: @@ -117,11 +119,11 @@ index c584e537..4cecca6d 100644 +3. **Minimal Routes**: Only includes index and product routes. Add more routes as needed. + ## Getting started - + **Requirements:** - --- Node.js version 18.0.0 or higher -+- Node.js version 18.0.0 or higher (but less than 22.0.0) + +-- Node.js version 22.22.0 or higher ++- Node.js version 22.22.0 or higher + +### Environment Setup + @@ -137,33 +139,33 @@ index c584e537..4cecca6d 100644 +## Local development + +Start the Express development server: - + ```bash -npm create @shopify/hydrogen@latest +npm run dev ``` - + +This starts your app in development mode with hot module replacement. + ## Building for production - + ```bash npm run build ``` - + -## Local development +## Production deployment + +Run the app in production mode: - + ```bash -npm run dev +npm start ``` - + -## Setup for using Customer Account API (`/account` section) +### Deployment - + -Follow step 1 and 2 of +When deploying your Express application, ensure you deploy: + @@ -185,7 +187,7 @@ index c584e537..4cecca6d 100644 + - `routes/` - Application routes +- `build/` - Production build output (generated) \ No newline at end of file -~~~ +````
    @@ -193,11 +195,11 @@ index c584e537..4cecca6d 100644 Add environment type definitions for Hydrogen on Express -#### File: [env.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/app/env.ts) +#### File: [env.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/app/env.ts)
    -~~~ts +```ts // This file extends the Hydrogen types for this project // The types are automatically available via @shopify/hydrogen/react-router-types @@ -228,7 +230,7 @@ declare global { // Required to make this file a module and enable the augmentation export {}; -~~~ +```
    @@ -236,9 +238,9 @@ export {}; Update client entry to use React Router hydration without Oxygen-specific code -#### File: [app/entry.client.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/entry.client.tsx) +#### File: [app/entry.client.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/entry.client.tsx) -~~~diff +```diff index a1267215..c1aa68ad 100644 --- a/templates/skeleton/app/entry.client.tsx +++ b/templates/skeleton/app/entry.client.tsx @@ -247,7 +249,7 @@ index a1267215..c1aa68ad 100644 import {startTransition, StrictMode} from 'react'; import {hydrateRoot} from 'react-dom/client'; -import {NonceProvider} from '@shopify/hydrogen'; - + if (!window.location.origin.includes('webcache.googleusercontent.com')) { startTransition(() => { - // Extract nonce from existing script tags @@ -264,17 +266,17 @@ index a1267215..c1aa68ad 100644 , ); }); -~~~ +``` ### Step 5: Add the Express template favicon Add Express template favicon -#### File: [favicon.svg](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/public/favicon.svg) +#### File: [favicon.svg](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/public/favicon.svg)
    -~~~svg +```svg -~~~ +```
    @@ -312,11 +314,11 @@ Add Express template favicon Replace Oxygen server rendering with Express-compatible Node.js SSR using PassThrough streams -#### File: [app/entry.server.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/entry.server.tsx) +#### File: [app/entry.server.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/entry.server.tsx)
    -~~~diff +```diff index 6f5c4abf..a407bed6 100644 --- a/templates/skeleton/app/entry.server.tsx +++ b/templates/skeleton/app/entry.server.tsx @@ -334,7 +336,7 @@ index 6f5c4abf..a407bed6 100644 type HydrogenRouterContextProvider, } from '@shopify/hydrogen'; -import type {EntryContext} from 'react-router'; - + -export default async function handleRequest( +const ABORT_DELAY = 5_000; + @@ -375,18 +377,18 @@ index 6f5c4abf..a407bed6 100644 - }, - ); + }); - + - if (isbot(request.headers.get('user-agent'))) { - await body.allReady; - } + let shellRendered = false; + const userAgent = request.headers.get('user-agent'); - + - responseHeaders.set('Content-Type', 'text/html'); - responseHeaders.set('Content-Security-Policy', header); + const readyOption: keyof RenderToPipeableStreamOptions = + userAgent && isbot(userAgent) ? 'onAllReady' : 'onShellReady'; - + - return new Response(body, { - headers: responseHeaders, - status: responseStatusCode, @@ -434,7 +436,7 @@ index 6f5c4abf..a407bed6 100644 -} +} \ No newline at end of file -~~~ +```
    @@ -442,11 +444,11 @@ index 6f5c4abf..a407bed6 100644 Add development server orchestration script for Vite and nodemon -#### File: [dev.mjs](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/scripts/dev.mjs) +#### File: [dev.mjs](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/scripts/dev.mjs)
    -~~~mjs +```mjs #!/usr/bin/env node import {spawn} from 'child_process'; @@ -482,7 +484,7 @@ initialTypegen.stderr?.on('data', (data) => { initialTypegen.on('close', () => { console.log('✅ Initial types generated'); - + // Show dev server URL const port = process.env.PORT || 3000; console.log('\n🚀 Express server ready!\n'); @@ -506,7 +508,7 @@ const runTypegen = () => { shell: true, cwd: rootDir, }); - + // Filter out WebSocket errors typegen.stderr?.on('data', (data) => { const message = data.toString(); @@ -514,7 +516,7 @@ const runTypegen = () => { process.stderr.write(data); } }); - + typegen.on('close', (code) => { if (code === 0) { console.log('✅ Types regenerated'); @@ -548,7 +550,7 @@ process.on('SIGTERM', () => { server.kill(); process.exit(0); }); -~~~ +```
    @@ -556,11 +558,11 @@ process.on('SIGTERM', () => { Simplify root layout for Express template by removing complex components -#### File: [app/root.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/root.tsx) +#### File: [app/root.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/root.tsx)
    -~~~diff +```diff index df87425c..1ba9888f 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -574,9 +576,9 @@ index df87425c..1ba9888f 100644 -import appStyles from '~/styles/app.css?url'; -import {PageLayout} from './components/PageLayout'; +import styles from './styles/app.css?url'; - + export type RootLoader = typeof loader; - + -/** - * This is important to avoid re-fetching root queries on sub-navigations - */ @@ -598,7 +600,7 @@ index df87425c..1ba9888f 100644 - // For more details see: https://remix.run/docs/en/main/route/should-revalidate return false; }; - + -/** - * The main and reset stylesheets are added in the Layout component - * to prevent a bug in development HMR updates. @@ -620,7 +622,7 @@ index df87425c..1ba9888f 100644 + {rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg'}, ]; } - + -export async function loader(args: Route.LoaderArgs) { - // Start fetching non-critical data without blocking time to first byte - const deferredData = loadDeferredData(args); @@ -628,15 +630,15 @@ index df87425c..1ba9888f 100644 + const [customerAccessToken, cartId] = await Promise.all([ + context.session.get('customerAccessToken'), + ]); - + - // Await the critical data required to render initial state of the page - const criticalData = await loadCriticalData(args); + const deferredData = loadDeferredData({context}); + const criticalData = await loadCriticalData({context}); - + - const {storefront, env} = args.context; + const {storefront, env} = context; - + return { ...deferredData, @@ -86,59 +61,29 @@ export async function loader(args: Route.LoaderArgs) { @@ -652,7 +654,7 @@ index df87425c..1ba9888f 100644 + isLoggedIn: Boolean(customerAccessToken), }; } - + -/** - * 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. @@ -660,7 +662,7 @@ index df87425c..1ba9888f 100644 -async function loadCriticalData({context}: Route.LoaderArgs) { +async function loadCriticalData({context}: Pick) { const {storefront} = context; - + - const [header] = await Promise.all([ - storefront.query(HEADER_QUERY, { - cache: storefront.CacheLong(), @@ -673,11 +675,11 @@ index df87425c..1ba9888f 100644 + const layout = await storefront.query(LAYOUT_QUERY, { + cache: storefront.CacheLong(), + }); - + - return {header}; + return {layout}; } - + -/** - * 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. @@ -689,7 +691,7 @@ index df87425c..1ba9888f 100644 + context, +}: Pick & {cartId?: string}) { + const {storefront, cart} = context; - + - // defer the footer query (below the fold) - const footer = storefront - .query(FOOTER_QUERY, { @@ -710,7 +712,7 @@ index df87425c..1ba9888f 100644 - }; + return {cart: cart.get()}; } - + export function Layout({children}: {children?: React.ReactNode}) { @@ -149,8 +94,7 @@ export function Layout({children}: {children?: React.ReactNode}) { @@ -756,7 +758,7 @@ index df87425c..1ba9888f 100644 + } + } +` as const; -~~~ +```
    @@ -764,11 +766,11 @@ index df87425c..1ba9888f 100644 Add Express server with Hydrogen context, session management, and SSR support -#### File: [server.mjs](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/cookbook/recipes/express/ingredients/templates/skeleton/server.mjs) +#### File: [server.mjs](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/express/ingredients/templates/skeleton/server.mjs)
    -~~~mjs +```mjs import 'dotenv/config'; import {createRequestHandler} from '@react-router/express'; import {createCookieSessionStorage} from 'react-router'; @@ -1035,8 +1037,7 @@ class AppSession { return this.sessionStorage.commitSession(this.session); } } - -~~~ +```
    @@ -1044,9 +1045,9 @@ class AppSession { Update routes configuration to work with Hydrogen on Express -#### File: [app/routes.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/routes.ts) +#### File: [app/routes.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/routes.ts) -~~~diff +```diff index 7df8a1b9..a1610c2b 100644 --- a/templates/skeleton/app/routes.ts +++ b/templates/skeleton/app/routes.ts @@ -1054,7 +1055,7 @@ index 7df8a1b9..a1610c2b 100644 import {flatRoutes} from '@react-router/fs-routes'; import {type RouteConfig} from '@react-router/dev/routes'; -import {hydrogenRoutes} from '@shopify/hydrogen'; - + -export default hydrogenRoutes([ - ...(await flatRoutes()), - // Manual route definitions can be added to this array, in addition to or instead of using the `flatRoutes` file-based routing convention. @@ -1065,17 +1066,17 @@ index 7df8a1b9..a1610c2b 100644 + const routes = await flatRoutes(); + return hydrogenRoutes([...routes]); +})() satisfies Promise; -~~~ +``` ### Step 11: Create a basic homepage Simplify homepage route to basic Express example content -#### File: [app/routes/_index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/routes/_index.tsx) +#### File: [app/routes/\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/routes/_index.tsx)
    -~~~diff +```diff index 237edc91..dc121c80 100644 --- a/templates/skeleton/app/routes/_index.tsx +++ b/templates/skeleton/app/routes/_index.tsx @@ -1091,7 +1092,7 @@ index 237edc91..dc121c80 100644 -import {ProductItem} from '~/components/ProductItem'; -import {MockShopNotice} from '~/components/MockShopNotice'; +import {useRouteError, isRouteErrorResponse, Link} from 'react-router'; - + -export const meta: Route.MetaFunction = () => { - return [{title: 'Hydrogen | Home'}]; -}; @@ -1162,7 +1163,7 @@ index 237edc91..dc121c80 100644 + ); } - + -function FeaturedCollection({ - collection, -}: { @@ -1279,7 +1280,7 @@ index 237edc91..dc121c80 100644 - } - } -` as const; -~~~ +```
    @@ -1287,11 +1288,11 @@ index 237edc91..dc121c80 100644 Simplify product route to minimal implementation without cart functionality -#### File: [app/routes/products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/routes/products.$handle.tsx) +#### File: [app/routes/products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/routes/products.$handle.tsx)
    -~~~diff +```diff index 99730d41..061b059c 100644 --- a/templates/skeleton/app/routes/products.$handle.tsx +++ b/templates/skeleton/app/routes/products.$handle.tsx @@ -1311,13 +1312,13 @@ index 99730d41..061b059c 100644 -import {ProductImage} from '~/components/ProductImage'; -import {ProductForm} from '~/components/ProductForm'; -import {redirectIfHandleIsLocalized} from '~/lib/redirect'; - --export const meta: Route.MetaFunction = ({data}) => { + +-export const meta: Route.MetaFunction = ({loaderData}) => { - return [ -- {title: `Hydrogen | ${data?.product.title ?? ''}`}, +- {title: `Hydrogen | ${loaderData?.product.title ?? ''}`}, - { - rel: 'canonical', -- href: `/products/${data?.product.handle}`, +- href: `/products/${loaderData?.product.handle}`, - }, - ]; -}; @@ -1336,15 +1337,15 @@ index 99730d41..061b059c 100644 - * 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) { +-async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { +export async function loader({params, context}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; - -@@ -45,188 +9,42 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { + +@@ -45,188 +9,42 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { throw new Error('Expected product handle to be defined'); } - + - const [{product}] = await Promise.all([ - storefront.query(PRODUCT_QUERY, { - variables: {handle, selectedOptions: getSelectedProductOptions(request)}, @@ -1354,13 +1355,13 @@ index 99730d41..061b059c 100644 + const {product} = await storefront.query(PRODUCT_QUERY, { + variables: {handle}, + }); - + 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}); +- redirectIfHandleIsLocalized(url, {handle, data: product}); - - return { - product, @@ -1379,7 +1380,7 @@ index 99730d41..061b059c 100644 - return {}; + return {product}; } - + export default function Product() { - const {product} = useLoaderData(); - @@ -1403,7 +1404,7 @@ index 99730d41..061b059c 100644 + const { + product: {title, descriptionHtml}, + } = useLoaderData(); - + return (
    - @@ -1453,7 +1454,7 @@ index 99730d41..061b059c 100644
    ); } - + -const PRODUCT_VARIANT_FRAGMENT = `#graphql - fragment ProductVariant on ProductVariant { - availableForSale @@ -1549,23 +1550,43 @@ index 99730d41..061b059c 100644 } - ${PRODUCT_FRAGMENT} ` as const; -~~~ +```
    -### Step 13: Add basic styles +### Step 13: Update TypeScript configuration + +Remove Oxygen Workers type definitions since Express uses standard Node.js types + +#### File: [tsconfig.json](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/tsconfig.json) + +```diff +index 54587bd5..64a860ad 100644 +--- a/templates/skeleton/tsconfig.json ++++ b/templates/skeleton/tsconfig.json +@@ -26,7 +26,6 @@ + "skipLibCheck": true, + "baseUrl": ".", + "types": [ +- "@shopify/oxygen-workers-types", + "react-router", + "@shopify/hydrogen/react-router-types", + "vite/client" +``` + +### Step 14: Add basic styles Replace skeleton styles with minimal Express template styling -#### File: [app/styles/app.css](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/app/styles/app.css) +#### File: [app/styles/app.css](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/styles/app.css)
    -~~~diff -index 3be08d31..313080f1 100644 +```diff +index 3be08d31..98fde229 100644 --- a/templates/skeleton/app/styles/app.css +++ b/templates/skeleton/app/styles/app.css -@@ -1,645 +1,44 @@ +@@ -1,645 +1,54 @@ -:root { - --aside-width: 400px; - --cart-aside-summary-height-with-discount: 300px; @@ -1676,10 +1697,20 @@ index 3be08d31..313080f1 100644 -aside header h3 { margin: 0; + background: rgb(245, 245, 241); -+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, -+ Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; ++ font-family: ++ system-ui, ++ -apple-system, ++ BlinkMacSystemFont, ++ 'Segoe UI', ++ Roboto, ++ Oxygen, ++ Ubuntu, ++ Cantarell, ++ 'Open Sans', ++ 'Helvetica Neue', ++ sans-serif; } - + -aside header .close { - font-weight: bold; - opacity: 0.8; @@ -1727,7 +1758,7 @@ index 3be08d31..313080f1 100644 - white-space: nowrap; - border-width: 0; } - + -.overlay { - background: rgba(0, 0, 0, 0.2); - bottom: 0; @@ -2055,7 +2086,7 @@ index 3be08d31..313080f1 100644 - } + line-height: 1.4; } - + -.products-grid { - display: grid; - grid-gap: 1.5rem; @@ -2067,7 +2098,7 @@ index 3be08d31..313080f1 100644 + margin-bottom: 1rem; + line-height: 1.4; } - + -.product-item img { - height: auto; - width: 100%; @@ -2125,7 +2156,7 @@ index 3be08d31..313080f1 100644 - font-family: inherit; + line-height: 1.4; } - + -.product-option-label-swatch { - width: 1.25rem; - height: 1.25rem; @@ -2138,7 +2169,7 @@ index 3be08d31..313080f1 100644 + padding: 2rem; + max-width: 25rem; } - + /* -* -------------------------------------------------- -* routes/blog._index.tsx @@ -2236,19 +2267,19 @@ index 3be08d31..313080f1 100644 + * Express template styling + * -------------------------------------------------- + */ -~~~ +```
    -### Step 14: Update ESLint configuration +### Step 15: Update ESLint configuration Simplify ESLint configuration for Express template -#### File: [eslint.config.js](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/eslint.config.js) +#### File: [eslint.config.js](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/eslint.config.js)
    -~~~diff +```diff index 8bfe5484..fa805477 100644 --- a/templates/skeleton/eslint.config.js +++ b/templates/skeleton/eslint.config.js @@ -2503,19 +2534,19 @@ index 8bfe5484..fa805477 100644 +// Minimal ESLint configuration for Express template +export default []; \ No newline at end of file -~~~ +```
    -### Step 15: Install Express dependencies +### Step 16: Install Express dependencies Update dependencies and scripts for Express server deployment (add express, nodemon, compression, remove Oxygen packages) -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/package.json) +#### File: [package.json](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/package.json)
    -~~~diff +```diff index e971ba7e..ae934ffd 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json @@ -2536,41 +2567,39 @@ index e971ba7e..ae934ffd 100644 }, "prettier": "@shopify/prettier-config", "dependencies": { -+ "@react-router/express": "7.12.0", -+ "@react-router/node": "7.12.0", -+ "@remix-run/eslint-config": "^2.16.1", - "@shopify/hydrogen": "2026.1.1", ++ "@react-router/express": "8.0.1", ++ "@react-router/node": "8.0.1", + "@shopify/hydrogen": "workspace:*", + "compression": "^1.7.4", + "cross-env": "^7.0.3", -+ "express": "^4.19.2", ++ "express": "^4.22.2", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", "isbot": "^5.1.22", + "morgan": "^1.10.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "7.12.0", - "react-router-dom": "7.12.0" + "react": "catalog:", + "react-dom": "catalog:", + "react-router": "8.0.1" }, "devDependencies": { - "@eslint/compat": "^1.2.5", - "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@graphql-codegen/cli": "5.0.2", - "@react-router/dev": "7.12.0", - "@react-router/fs-routes": "7.12.0", - "@shopify/cli": "3.85.4", - "@shopify/hydrogen-codegen": "0.3.3", -- "@shopify/mini-oxygen": "4.0.1", + "@react-router/dev": "8.0.1", + "@react-router/fs-routes": "8.0.1", + "@shopify/cli": "3.93.2", + "@shopify/hydrogen-codegen": "workspace:*", +- "@shopify/mini-oxygen": "workspace:*", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "^1.1.2", +- "@shopify/prettier-config": "catalog:", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", + "@types/compression": "^1.7.2", + "@types/express": "^4.17.17", + "@types/morgan": "^1.9.4", - "@types/react": "^18.3.28", - "@types/react-dom": "^18.3.7", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", @@ -2589,29 +2618,28 @@ index e971ba7e..ae934ffd 100644 + "nodemon": "^2.0.22", + "npm-run-all": "^4.1.5", "typescript": "^5.9.2", - "vite": "^6.2.4", - "vite-tsconfig-paths": "^4.3.1" + "vite": "^8.0.1" }, "engines": { -- "node": "^22 || ^24" -+ "node": ">=20.0.0" +- "node": "^22.22.0 || ^24" ++ "node": "^22.22.0 || ^24" } } -~~~ +```
    -### Step 16: Configure Vite for Node.js +### Step 17: Configure Vite for Node.js Configure Vite for Express deployment with Node.js module externalization -#### File: [vite.config.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/vite.config.ts) +#### File: [vite.config.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/vite.config.ts) -~~~diff -index a1702446..058b559d 100644 +```diff +index 050b6ed7..ef18f2fc 100644 --- a/templates/skeleton/vite.config.ts +++ b/templates/skeleton/vite.config.ts -@@ -1,16 +1,17 @@ +@@ -1,17 +1,18 @@ import {defineConfig} from 'vite'; import {hydrogen} from '@shopify/hydrogen/vite'; -import {oxygen} from '@shopify/mini-oxygen/vite'; @@ -2632,7 +2660,7 @@ index a1702446..058b559d 100644 optimizeDeps: { /** * Include dependencies here if they throw CJS<>ESM errors. -@@ -23,10 +24,7 @@ export default defineConfig({ +@@ -23,14 +24,7 @@ export default defineConfig({ * Include 'example-dep' in the array below. * @see https://vitejs.dev/config/dep-optimization-options */ @@ -2648,99 +2676,82 @@ index a1702446..058b559d 100644 - allowedHosts: ['.tryhydrogen.dev'], - }, }); -~~~ - -### Step 17: Update TypeScript configuration - -Remove Oxygen worker types from tsconfig since Express replaces Oxygen - -#### File: [tsconfig.json](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/tsconfig.json) - -~~~diff ---- a/templates/skeleton/tsconfig.json -+++ b/templates/skeleton/tsconfig.json -@@ -26,7 +26,6 @@ - "skipLibCheck": true, - "baseUrl": ".", - "types": [ -- "@shopify/oxygen-workers-types", - "react-router", - "@shopify/hydrogen/react-router-types", - "vite/client" -~~~ +``` ## Deleted Files -- [templates/skeleton/app/components/AddToCartButton.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/AddToCartButton.tsx) -- [templates/skeleton/app/components/Aside.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/Aside.tsx) -- [templates/skeleton/app/components/CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/CartLineItem.tsx) -- [templates/skeleton/app/components/CartMain.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/CartMain.tsx) -- [templates/skeleton/app/components/CartSummary.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/CartSummary.tsx) -- [templates/skeleton/app/components/Footer.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/Footer.tsx) -- [templates/skeleton/app/components/Header.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/Header.tsx) -- [templates/skeleton/app/components/PageLayout.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/PageLayout.tsx) -- [templates/skeleton/app/components/PaginatedResourceSection.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/PaginatedResourceSection.tsx) -- [templates/skeleton/app/components/ProductForm.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/ProductForm.tsx) -- [templates/skeleton/app/components/ProductImage.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/ProductImage.tsx) -- [templates/skeleton/app/components/ProductItem.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/ProductItem.tsx) -- [templates/skeleton/app/components/ProductPrice.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/ProductPrice.tsx) -- [templates/skeleton/app/components/SearchForm.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/SearchForm.tsx) -- [templates/skeleton/app/components/SearchFormPredictive.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/SearchFormPredictive.tsx) -- [templates/skeleton/app/components/SearchResults.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/SearchResults.tsx) -- [templates/skeleton/app/components/SearchResultsPredictive.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/components/SearchResultsPredictive.tsx) -- [templates/skeleton/app/graphql/customer-account/CustomerAddressMutations.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerAddressMutations.ts) -- [templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery.ts) -- [templates/skeleton/app/graphql/customer-account/CustomerOrderQuery.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerOrderQuery.ts) -- [templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery.ts) -- [templates/skeleton/app/graphql/customer-account/CustomerUpdateMutation.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerUpdateMutation.ts) -- [templates/skeleton/app/lib/context.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/lib/context.ts) -- [templates/skeleton/app/lib/fragments.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/lib/fragments.ts) -- [templates/skeleton/app/lib/redirect.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/lib/redirect.ts) -- [templates/skeleton/app/lib/search.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/lib/search.ts) -- [templates/skeleton/app/lib/session.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/lib/session.ts) -- [templates/skeleton/app/lib/variants.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/lib/variants.ts) -- [templates/skeleton/app/routes/$.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/$.tsx) -- [templates/skeleton/app/routes/[robots.txt].tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/[robots.txt].tsx) -- [templates/skeleton/app/routes/[sitemap.xml].tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/[sitemap.xml].tsx) -- [templates/skeleton/app/routes/account.$.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account.$.tsx) -- [templates/skeleton/app/routes/account._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account._index.tsx) -- [templates/skeleton/app/routes/account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account.addresses.tsx) -- [templates/skeleton/app/routes/account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account.orders.$id.tsx) -- [templates/skeleton/app/routes/account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account.orders._index.tsx) -- [templates/skeleton/app/routes/account.profile.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account.profile.tsx) -- [templates/skeleton/app/routes/account.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account.tsx) -- [templates/skeleton/app/routes/account_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) -- [templates/skeleton/app/routes/account_.login.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account_.login.tsx) -- [templates/skeleton/app/routes/account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/account_.logout.tsx) -- [templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx) -- [templates/skeleton/app/routes/blogs.$blogHandle._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx) -- [templates/skeleton/app/routes/blogs._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/blogs._index.tsx) -- [templates/skeleton/app/routes/cart.$lines.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/cart.$lines.tsx) -- [templates/skeleton/app/routes/cart.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/cart.tsx) -- [templates/skeleton/app/routes/collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/collections.$handle.tsx) -- [templates/skeleton/app/routes/collections._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/collections._index.tsx) -- [templates/skeleton/app/routes/collections.all.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/collections.all.tsx) -- [templates/skeleton/app/routes/discount.$code.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/discount.$code.tsx) -- [templates/skeleton/app/routes/pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/pages.$handle.tsx) -- [templates/skeleton/app/routes/policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/policies.$handle.tsx) -- [templates/skeleton/app/routes/policies._index.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/policies._index.tsx) -- [templates/skeleton/app/routes/search.tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/search.tsx) -- [templates/skeleton/app/routes/sitemap.$type.$page[.xml].tsx](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/routes/sitemap.$type.$page[.xml].tsx) -- [templates/skeleton/app/styles/reset.css](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/app/styles/reset.css) -- [templates/skeleton/customer-accountapi.generated.d.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/customer-accountapi.generated.d.ts) -- [templates/skeleton/env.d.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/env.d.ts) -- [templates/skeleton/server.ts](https://github.com/Shopify/hydrogen/blob/14d09107663313bae8eac3c701b90a7bc49819e4/templates/skeleton/templates/skeleton/server.ts) +- [templates/skeleton/app/components/AddToCartButton.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/AddToCartButton.tsx) +- [templates/skeleton/app/components/Aside.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/Aside.tsx) +- [templates/skeleton/app/components/CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/CartLineItem.tsx) +- [templates/skeleton/app/components/CartMain.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/CartMain.tsx) +- [templates/skeleton/app/components/CartSummary.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/CartSummary.tsx) +- [templates/skeleton/app/components/Footer.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/Footer.tsx) +- [templates/skeleton/app/components/Header.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/Header.tsx) +- [templates/skeleton/app/components/PageLayout.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/PageLayout.tsx) +- [templates/skeleton/app/components/PaginatedResourceSection.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/PaginatedResourceSection.tsx) +- [templates/skeleton/app/components/ProductForm.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/ProductForm.tsx) +- [templates/skeleton/app/components/ProductImage.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/ProductImage.tsx) +- [templates/skeleton/app/components/ProductItem.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/ProductItem.tsx) +- [templates/skeleton/app/components/ProductPrice.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/ProductPrice.tsx) +- [templates/skeleton/app/components/SearchForm.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/SearchForm.tsx) +- [templates/skeleton/app/components/SearchFormPredictive.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/SearchFormPredictive.tsx) +- [templates/skeleton/app/components/SearchResults.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/SearchResults.tsx) +- [templates/skeleton/app/components/SearchResultsPredictive.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/components/SearchResultsPredictive.tsx) +- [templates/skeleton/app/graphql/customer-account/CustomerAddressMutations.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerAddressMutations.ts) +- [templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery.ts) +- [templates/skeleton/app/graphql/customer-account/CustomerOrderQuery.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerOrderQuery.ts) +- [templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery.ts) +- [templates/skeleton/app/graphql/customer-account/CustomerUpdateMutation.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/graphql/customer-account/CustomerUpdateMutation.ts) +- [templates/skeleton/app/lib/context.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/lib/context.ts) +- [templates/skeleton/app/lib/fragments.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/lib/fragments.ts) +- [templates/skeleton/app/lib/redirect.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/lib/redirect.ts) +- [templates/skeleton/app/lib/search.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/lib/search.ts) +- [templates/skeleton/app/lib/session.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/lib/session.ts) +- [templates/skeleton/app/lib/variants.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/lib/variants.ts) +- [templates/skeleton/app/routes/$.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/$.tsx) +- [templates/skeleton/app/routes/[robots.txt].tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/[robots.txt].tsx) +- [templates/skeleton/app/routes/[sitemap.xml].tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/[sitemap.xml].tsx) +- [templates/skeleton/app/routes/account.$.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account.$.tsx) +- [templates/skeleton/app/routes/account.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account._index.tsx) +- [templates/skeleton/app/routes/account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account.addresses.tsx) +- [templates/skeleton/app/routes/account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account.orders.$id.tsx) +- [templates/skeleton/app/routes/account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account.orders._index.tsx) +- [templates/skeleton/app/routes/account.profile.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account.profile.tsx) +- [templates/skeleton/app/routes/account.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account.tsx) +- [templates/skeleton/app/routes/account\_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) +- [templates/skeleton/app/routes/account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account_.login.tsx) +- [templates/skeleton/app/routes/account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/account_.logout.tsx) +- [templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx) +- [templates/skeleton/app/routes/blogs.$blogHandle.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx) +- [templates/skeleton/app/routes/blogs.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/blogs._index.tsx) +- [templates/skeleton/app/routes/cart.$lines.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/cart.$lines.tsx) +- [templates/skeleton/app/routes/cart.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/cart.tsx) +- [templates/skeleton/app/routes/collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/collections.$handle.tsx) +- [templates/skeleton/app/routes/collections.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/collections._index.tsx) +- [templates/skeleton/app/routes/collections.all.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/collections.all.tsx) +- [templates/skeleton/app/routes/discount.$code.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/discount.$code.tsx) +- [templates/skeleton/app/routes/pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/pages.$handle.tsx) +- [templates/skeleton/app/routes/policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/policies.$handle.tsx) +- [templates/skeleton/app/routes/policies.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/policies._index.tsx) +- [templates/skeleton/app/routes/search.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/search.tsx) +- [templates/skeleton/app/routes/sitemap.$type.$page[.xml].tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/routes/sitemap.$type.$page[.xml].tsx) +- [templates/skeleton/app/styles/reset.css](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/app/styles/reset.css) +- [templates/skeleton/customer-accountapi.generated.d.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/customer-accountapi.generated.d.ts) +- [templates/skeleton/env.d.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/env.d.ts) +- [templates/skeleton/server.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/templates/skeleton/server.ts) ## Next steps 1. Create a `.env` file with your Shopify Storefront API credentials: - ``` - PUBLIC_STOREFRONT_API_TOKEN="your-token" - PUBLIC_STORE_DOMAIN="your-store.myshopify.com" - PUBLIC_STOREFRONT_ID="your-storefront-id" - SESSION_SECRET="your-session-secret-at-least-32-chars" - ``` + +``` +PUBLIC_STOREFRONT_API_TOKEN="your-token" +PUBLIC_STORE_DOMAIN="your-store.myshopify.com" +PUBLIC_STOREFRONT_ID="your-storefront-id" +SESSION_SECRET="your-session-secret-at-least-32-chars" +``` + 2. Run `npm install` to install Express and other Node.js dependencies 3. Run `npm run dev` to start the development server with hot reload 4. For production, run `npm run build` followed by `npm start` -5. Deploy to your preferred Node.js hosting platform (Heroku, AWS, Vercel, Railway, etc.) \ No newline at end of file +5. Deploy to your preferred Node.js hosting platform (Heroku, AWS, Vercel, Railway, etc.) diff --git a/cookbook/recipes/express/patches/README.md.a0354d.patch b/cookbook/recipes/express/patches/README.md.a0354d.patch index 9483eee5c7..f7360ddbfb 100644 --- a/cookbook/recipes/express/patches/README.md.a0354d.patch +++ b/cookbook/recipes/express/patches/README.md.a0354d.patch @@ -5,19 +5,19 @@ index c584e537..4cecca6d 100644 -# Hydrogen template: Skeleton +# Hydrogen Express Skeleton --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +This is a Hydrogen skeleton template configured to run with NodeJS [Express](https://expressjs.com/) instead of Shopify Oxygen. + +Hydrogen is Shopify's stack for headless commerce, designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal Express setup** with basic components and routes to get started with Hydrogen on Node.js. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) --[Get familiar with Remix](https://remix.run/docs/en/v1) +-[Get familiar with React Router](https://reactrouter.com/) +[Get familiar with React Router](https://reactrouter.com/en/main) ## What's included --- Remix -+- React Router 7 +-- React Router ++- React Router - Hydrogen -- Oxygen +- Express server @@ -42,8 +42,8 @@ index c584e537..4cecca6d 100644 **Requirements:** --- Node.js version 18.0.0 or higher -+- Node.js version 18.0.0 or higher (but less than 22.0.0) +-- Node.js version 22.22.0 or higher ++- Node.js version 22.22.0 or higher + +### Environment Setup + diff --git a/cookbook/recipes/express/patches/package.json.acbf33.patch b/cookbook/recipes/express/patches/package.json.acbf33.patch index 6551f2519a..4a5dc5aa8c 100644 --- a/cookbook/recipes/express/patches/package.json.acbf33.patch +++ b/cookbook/recipes/express/patches/package.json.acbf33.patch @@ -18,30 +18,28 @@ index e971ba7e..ae934ffd 100644 }, "prettier": "@shopify/prettier-config", "dependencies": { -+ "@react-router/express": "7.12.0", -+ "@react-router/node": "7.12.0", -+ "@remix-run/eslint-config": "^2.16.1", ++ "@react-router/express": "8.0.1", ++ "@react-router/node": "8.0.1", "@shopify/hydrogen": "workspace:*", + "compression": "^1.7.4", + "cross-env": "^7.0.3", -+ "express": "^4.19.2", ++ "express": "^4.22.2", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", "isbot": "^5.1.22", + "morgan": "^1.10.0", "react": "catalog:", "react-dom": "catalog:", - "react-router": "7.12.0", - "react-router-dom": "7.12.0" + "react-router": "8.0.1" }, "devDependencies": { - "@eslint/compat": "^1.2.5", - "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@graphql-codegen/cli": "5.0.2", - "@react-router/dev": "7.12.0", - "@react-router/fs-routes": "7.12.0", - "@shopify/cli": "3.85.4", + "@react-router/dev": "8.0.1", + "@react-router/fs-routes": "8.0.1", + "@shopify/cli": "3.93.2", "@shopify/hydrogen-codegen": "workspace:*", - "@shopify/mini-oxygen": "workspace:*", - "@shopify/oxygen-workers-types": "^4.1.6", @@ -71,11 +69,10 @@ index e971ba7e..ae934ffd 100644 + "nodemon": "^2.0.22", + "npm-run-all": "^4.1.5", "typescript": "^5.9.2", - "vite": "^6.2.4", - "vite-tsconfig-paths": "^4.3.1" + "vite": "^8.0.1" }, "engines": { -- "node": "^22 || ^24" -+ "node": ">=20.0.0 <22.0.0" +- "node": "^22.22.0 || ^24" ++ "node": "^22.22.0 || ^24" } } diff --git a/cookbook/recipes/express/patches/products.$handle.tsx.2292d3.patch b/cookbook/recipes/express/patches/products.$handle.tsx.2292d3.patch index 18dcfb5712..627265b997 100644 --- a/cookbook/recipes/express/patches/products.$handle.tsx.2292d3.patch +++ b/cookbook/recipes/express/patches/products.$handle.tsx.2292d3.patch @@ -18,12 +18,12 @@ index 99730d41..061b059c 100644 -import {ProductForm} from '~/components/ProductForm'; -import {redirectIfHandleIsLocalized} from '~/lib/redirect'; --export const meta: Route.MetaFunction = ({data}) => { +-export const meta: Route.MetaFunction = ({loaderData}) => { - return [ -- {title: `Hydrogen | ${data?.product.title ?? ''}`}, +- {title: `Hydrogen | ${loaderData?.product.title ?? ''}`}, - { - rel: 'canonical', -- href: `/products/${data?.product.handle}`, +- href: `/products/${loaderData?.product.handle}`, - }, - ]; -}; @@ -42,12 +42,12 @@ index 99730d41..061b059c 100644 - * 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) { +-async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { +export async function loader({params, context}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; -@@ -45,188 +9,42 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { +@@ -45,188 +9,42 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { throw new Error('Expected product handle to be defined'); } @@ -66,7 +66,7 @@ index 99730d41..061b059c 100644 } - // The API handle might be localized, so redirect to the localized handle -- redirectIfHandleIsLocalized(request, {handle, data: product}); +- redirectIfHandleIsLocalized(url, {handle, data: product}); - - return { - product, diff --git a/cookbook/recipes/express/recipe.yaml b/cookbook/recipes/express/recipe.yaml index 820bfeb24d..0582837d36 100644 --- a/cookbook/recipes/express/recipe.yaml +++ b/cookbook/recipes/express/recipe.yaml @@ -21,11 +21,11 @@ description: | - Environment variables are loaded from .env file using dotenv - Session management is handled through Express middleware with SESSION_SECRET - GraphQL codegen still works with Storefront API types - - Compatible with React Router 7.8.x + - Compatible with React Router framework mode - The .graphqlrc.ts file is preserved with customer account section commented out notes: [] requirements: | - - Node.js 20 or higher (less than 22.0.0) for production deployment + - Node.js 22.22.0 or higher for production deployment - npm or yarn package manager - Shopify Storefront API credentials ingredients: diff --git a/cookbook/recipes/gtm/README.md b/cookbook/recipes/gtm/README.md index 93b1a2fad4..fccb0ece7b 100644 --- a/cookbook/recipes/gtm/README.md +++ b/cookbook/recipes/gtm/README.md @@ -5,6 +5,7 @@ This recipe integrates Google Tag Manager (GTM) into your Hydrogen storefront, e Hydrogen includes built-in support for the Customer Privacy API, a browser-based JavaScript API that you can use to display cookie-consent banners and verify data processing permissions. Key features: + - GTM script integration with proper CSP nonce support - Content Security Policy configuration for GTM domains - Analytics integration for product views via dataLayer @@ -14,6 +15,7 @@ Key features: - Support for various analytics events by subscribing to event types using the GoogleTagManager component The recipe includes: + 1. Content Security Policy updates in entry.server.tsx for GTM domains 2. GTM script tags in the head and body sections 3. GoogleTagManager component that subscribes to analytics events @@ -25,12 +27,14 @@ The recipe includes: ## Requirements Prerequisites: + - A Google Tag Manager account and container ID - Customer privacy settings configured in your Shopify admin (for cookie consent) - Basic understanding of GTM and dataLayer events - Knowledge of Shopify's analytics events To enable cookie consent: + 1. In your Shopify admin, go to **Settings** → **Customer Privacy** → **Cookie Banner**. 2. Configure region visibility for the cookie banner. 3. Customize the banner's appearance and position as needed. @@ -39,8 +43,8 @@ To enable cookie consent: _New files added to the template by this recipe._ -| File | Description | -| --- | --- | +| File | Description | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | [app/components/GoogleTagManager.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/gtm/ingredients/templates/skeleton/app/components/GoogleTagManager.tsx) | Analytics component that subscribes to Hydrogen events and pushes them to GTM's dataLayer | ## Steps @@ -53,19 +57,19 @@ Update the README file with GTM-specific documentation and setup instructions.
    -~~~diff +````diff index c584e5370..a31bfebf0 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,6 +1,6 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Google Tag Manager (GTM) - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +This Hydrogen template demonstrates how to implement Google Tag Manager with analytics integration. Hydrogen supports both Shopify analytics and third-party services with built-in support for the [Customer Privacy API](https://shopify.dev/docs/api/customer-privacy). - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,18 +16,67 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -74,18 +78,18 @@ index c584e5370..a31bfebf0 100644 +- **Google Tag Manager integration** +- **Analytics.Provider setup** +- **Customer Privacy API support** - + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- Google Tag Manager account with container ID - + ```bash npm create @shopify/hydrogen@latest ``` - + +## Google Tag Manager Setup + +### 1. Enable Customer Privacy / Cookie Consent Banner @@ -133,16 +137,16 @@ index c584e5370..a31bfebf0 100644 +``` + ## Building for production - + ```bash @@ -42,4 +91,4 @@ npm run dev - + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +````
    @@ -152,7 +156,7 @@ Configure CSP headers to allow Google Tag Manager and Analytics scripts. #### File: [app/entry.server.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/entry.server.tsx) -~~~diff +```diff index 6f5c4abfc..b8eb74f4b 100644 --- a/templates/skeleton/app/entry.server.tsx +++ b/templates/skeleton/app/entry.server.tsx @@ -181,7 +185,7 @@ index 6f5c4abfc..b8eb74f4b 100644 shop: { checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, storeDomain: context.env.PUBLIC_STORE_DOMAIN, -~~~ +``` ### Step 3: Create the analytics component @@ -191,7 +195,7 @@ Build a component that subscribes to Hydrogen analytics events and pushes them t
    -~~~tsx +```tsx import {useAnalytics} from '@shopify/hydrogen'; import {useEffect} from 'react'; @@ -208,7 +212,7 @@ export function GoogleTagManager() { useEffect(() => { subscribe('product_viewed', () => { // Triggering a custom event in GTM when a product is viewed - window.dataLayer.push({'event': 'viewed-product'}); + window.dataLayer.push({event: 'viewed-product'}); }); ready(); @@ -216,7 +220,7 @@ export function GoogleTagManager() { return null; } -~~~ +```
    @@ -228,7 +232,7 @@ Insert Google Tag Manager tracking code in the head and body sections.
    -~~~diff +```diff index df87425c5..aa25c6d7c 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -243,9 +247,9 @@ index df87425c5..aa25c6d7c 100644 import appStyles from '~/styles/app.css?url'; import {PageLayout} from './components/PageLayout'; +import {GoogleTagManager} from '~/components/GoogleTagManager'; - + export type RootLoader = typeof loader; - + @@ -153,8 +154,32 @@ export function Layout({children}: {children?: React.ReactNode}) { @@ -288,58 +292,10 @@ index df87425c5..aa25c6d7c 100644 ); } -~~~ +```
    -### Step 4: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - ## Next steps After applying this recipe: @@ -362,4 +318,4 @@ After applying this recipe: - Check browser console for dataLayer pushes - Verify CSP is not blocking any GTM resources -5. Enable customer privacy settings for GDPR compliance in your Shopify admin. \ No newline at end of file +5. Enable customer privacy settings for GDPR compliance in your Shopify admin. diff --git a/cookbook/recipes/gtm/patches/README.md.1764cd.patch b/cookbook/recipes/gtm/patches/README.md.1764cd.patch index 47ef6ec2c8..05c196b667 100644 --- a/cookbook/recipes/gtm/patches/README.md.1764cd.patch +++ b/cookbook/recipes/gtm/patches/README.md.1764cd.patch @@ -5,11 +5,11 @@ index c584e5370..a31bfebf0 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Google Tag Manager (GTM) --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. +This Hydrogen template demonstrates how to implement Google Tag Manager with analytics integration. Hydrogen supports both Shopify analytics and third-party services with built-in support for the [Customer Privacy API](https://shopify.dev/docs/api/customer-privacy). [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,18 +16,67 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -23,7 +23,7 @@ index c584e5370..a31bfebf0 100644 **Requirements:** - - Node.js version 18.0.0 or higher + - Node.js version 22.22.0 or higher +- Google Tag Manager account with container ID ```bash diff --git a/cookbook/recipes/gtm/patches/package.json.8e0ff5.patch b/cookbook/recipes/gtm/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/gtm/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/gtm/recipe.yaml b/cookbook/recipes/gtm/recipe.yaml index cba6b8db74..595f0cb1be 100644 --- a/cookbook/recipes/gtm/recipe.yaml +++ b/cookbook/recipes/gtm/recipe.yaml @@ -39,46 +39,42 @@ requirements: | 3. Customize the banner's appearance and position as needed. ingredients: - path: templates/skeleton/app/components/GoogleTagManager.tsx - description: Analytics component that subscribes to Hydrogen events and pushes + description: + Analytics component that subscribes to Hydrogen events and pushes them to GTM's dataLayer deletedFiles: [] steps: - type: PATCH - step: "1" + step: '1' name: Document GTM setup in the README - description: Update the README file with GTM-specific documentation and setup + description: + Update the README file with GTM-specific documentation and setup instructions. diffs: - file: README.md patchFile: README.md.1764cd.patch - type: PATCH - step: "2" + step: '2' name: Add GTM domains to Content Security Policy description: Configure CSP headers to allow Google Tag Manager and Analytics scripts. diffs: - file: app/entry.server.tsx patchFile: entry.server.tsx.a8386e.patch - type: NEW_FILE - step: "3" + step: '3' name: Create the analytics component - description: Build a component that subscribes to Hydrogen analytics events and + description: + Build a component that subscribes to Hydrogen analytics events and pushes them to GTM's dataLayer. ingredients: - path: templates/skeleton/app/components/GoogleTagManager.tsx - type: PATCH - step: "4" + step: '4' name: Add GTM scripts to the app description: Insert Google Tag Manager tracking code in the head and body sections. diffs: - file: app/root.tsx patchFile: root.tsx.8f1e92.patch - - type: PATCH - step: "4" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch nextSteps: | After applying this recipe: diff --git a/cookbook/recipes/infinite-scroll/README.md b/cookbook/recipes/infinite-scroll/README.md index d3960c0c54..c7d10efffd 100644 --- a/cookbook/recipes/infinite-scroll/README.md +++ b/cookbook/recipes/infinite-scroll/README.md @@ -3,6 +3,7 @@ This recipe implements infinite scroll functionality on collection pages using the Intersection Observer API. Key features: + - Automatic loading when "Load more" button comes into view using Intersection Observer API - Preserves browser history and URL state (replace mode to avoid clutter) - Maintains scroll position during navigation @@ -24,21 +25,21 @@ Update the README file with infinite scroll documentation and implementation det
    -~~~diff +````diff index c584e537..6eacfd82 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,6 +1,8 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Infinite Scroll - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates infinite scroll pagination for collection pages. Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates infinite scroll pagination for collection pages. Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to implement a seamless browsing experience where products automatically load as users scroll down, replacing traditional pagination with continuous content loading. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,7 +18,28 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -66,19 +67,19 @@ index c584e537..6eacfd82 100644 +- Maintains scroll position during navigation +- Clean URL updates using replace mode +- No history cluttering from pagination - + ## Getting started - + @@ -28,6 +51,25 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov npm create @shopify/hydrogen@latest ``` - + +## Implementation Details + +The infinite scroll implementation uses: +- React's `useEffect` hook for scroll detection +- Intersection Observer API for viewport detection -+- Remix's navigation for URL updates ++- React Router's navigation for URL updates +- Shopify's Pagination component as the base + +### Key Components @@ -93,12 +94,12 @@ index c584e537..6eacfd82 100644 +``` + ## Building for production - + ```bash @@ -40,6 +82,14 @@ npm run build npm run dev ``` - + +## Customization + +You can adjust the infinite scroll behavior by: @@ -108,11 +109,11 @@ index c584e537..6eacfd82 100644 +- Adding scroll-to-top functionality + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +````
    @@ -124,7 +125,7 @@ Implement automatic loading with Intersection Observer when users scroll to the
    -~~~diff +```diff index c416c2b3..e6a35150 100644 --- a/templates/skeleton/app/routes/collections.$handle.tsx +++ b/templates/skeleton/app/routes/collections.$handle.tsx @@ -144,14 +145,14 @@ index c416c2b3..e6a35150 100644 +import {useEffect} from 'react'; +import {useInView} from 'react-intersection-observer'; import type {ProductItemFragment} from 'storefrontapi.generated'; - - export const meta: Route.MetaFunction = ({data}) => { + + export const meta: Route.MetaFunction = ({loaderData}) => { @@ -67,23 +72,41 @@ function loadDeferredData({context}: Route.LoaderArgs) { - + export default function Collection() { const {collection} = useLoaderData(); + const {ref, inView} = useInView(); - + return (

    {collection.title}

    @@ -202,7 +203,7 @@ index c416c2b3..e6a35150 100644 @@ -96,6 +119,47 @@ export default function Collection() { ); } - + +// @description ProductsGrid component with infinite scroll functionality +function ProductsGrid({ + products, @@ -230,7 +231,7 @@ index c416c2b3..e6a35150 100644 + }, [inView, navigate, state, nextPageUrl, hasNextPage]); + + return ( -+
    ++
    + {products.map((product, index) => { + return ( + @@ -257,16 +258,14 @@ Add the react-intersection-observer package for viewport detection. #### File: [package.json](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/templates/skeleton/package.json) -~~~diff -index e9ebd1d3..e51634ee 100644 +```diff +index e971ba7e..98bd2d0f 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json -@@ -20,6 +20,7 @@ +@@ -20,4 +20,5 @@ "isbot": "^5.1.22", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "catalog:", + "react-dom": "catalog:", + "react-intersection-observer": "^8.34.0", - "react-router": "7.9.2", - "react-router-dom": "7.9.2" - }, -~~~ \ No newline at end of file + "react-router": "8.0.1" +``` diff --git a/cookbook/recipes/infinite-scroll/patches/README.md.db10ed.patch b/cookbook/recipes/infinite-scroll/patches/README.md.db10ed.patch index cf09a89475..d00b2b3b83 100644 --- a/cookbook/recipes/infinite-scroll/patches/README.md.db10ed.patch +++ b/cookbook/recipes/infinite-scroll/patches/README.md.db10ed.patch @@ -5,13 +5,13 @@ index c584e537..6eacfd82 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Infinite Scroll --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates infinite scroll pagination for collection pages. Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates infinite scroll pagination for collection pages. Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to implement a seamless browsing experience where products automatically load as users scroll down, replacing traditional pagination with continuous content loading. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,7 +18,28 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -51,7 +51,7 @@ index c584e537..6eacfd82 100644 +The infinite scroll implementation uses: +- React's `useEffect` hook for scroll detection +- Intersection Observer API for viewport detection -+- Remix's navigation for URL updates ++- React Router's navigation for URL updates +- Shopify's Pagination component as the base + +### Key Components diff --git a/cookbook/recipes/infinite-scroll/patches/collections.$handle.tsx.f062a9.patch b/cookbook/recipes/infinite-scroll/patches/collections.$handle.tsx.f062a9.patch index 65775cf330..1fe9db319e 100644 --- a/cookbook/recipes/infinite-scroll/patches/collections.$handle.tsx.f062a9.patch +++ b/cookbook/recipes/infinite-scroll/patches/collections.$handle.tsx.f062a9.patch @@ -18,7 +18,7 @@ index c416c2b3..e6a35150 100644 +import {useInView} from 'react-intersection-observer'; import type {ProductItemFragment} from 'storefrontapi.generated'; - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { @@ -67,23 +72,41 @@ function loadDeferredData({context}: Route.LoaderArgs) { export default function Collection() { diff --git a/cookbook/recipes/infinite-scroll/patches/package.json.acbf33.patch b/cookbook/recipes/infinite-scroll/patches/package.json.acbf33.patch index 3bf0aed01a..75ef4fe11a 100644 --- a/cookbook/recipes/infinite-scroll/patches/package.json.acbf33.patch +++ b/cookbook/recipes/infinite-scroll/patches/package.json.acbf33.patch @@ -1,11 +1,9 @@ index e971ba7e..98bd2d0f 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json -@@ -20,6 +20,7 @@ +@@ -20,4 +20,5 @@ "isbot": "^5.1.22", "react": "catalog:", "react-dom": "catalog:", + "react-intersection-observer": "^8.34.0", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, + "react-router": "8.0.1" diff --git a/cookbook/recipes/legacy-customer-account-flow/README.md b/cookbook/recipes/legacy-customer-account-flow/README.md index 3188154949..56370d1e96 100644 --- a/cookbook/recipes/legacy-customer-account-flow/README.md +++ b/cookbook/recipes/legacy-customer-account-flow/README.md @@ -6,6 +6,7 @@ that haven't migrated to the new Customer Account API yet or need to maintain co with existing customer authentication systems. Key features: + - Full customer registration and login flow with form-based authentication - Password recovery and reset functionality via email - Account activation via email tokens @@ -16,6 +17,7 @@ Key features: - Secure server-side rendering for all account routes Technical details: + - Customer access tokens are stored in session cookies for authentication. - The login/register/recover routes use the account\_ prefix to avoid layout nesting. - Account data routes use the `account.` prefix to inherit the account layout @@ -36,12 +38,12 @@ Technical details: _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/routes/account_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) | Customer account activation route that processes email activation tokens | -| [app/routes/account_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.recover.tsx) | Password recovery form for requesting reset emails via Storefront API | -| [app/routes/account_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.register.tsx) | Customer registration form with email validation and account creation | -| [app/routes/account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) | Password reset confirmation route that validates tokens and updates passwords | +| File | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| [app/routes/account\_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) | Customer account activation route that processes email activation tokens | +| [app/routes/account\_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.recover.tsx) | Password recovery form for requesting reset emails via Storefront API | +| [app/routes/account\_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.register.tsx) | Customer registration form with email validation and account creation | +| [app/routes/account\_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) | Password reset confirmation route that validates tokens and updates passwords | ## Steps @@ -51,27 +53,27 @@ Update the README file to document the legacy customer account flow. #### File: [README.md](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/README.md) -~~~diff +```diff index c584e5370..11bb1c8cc 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,10 +1,26 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Legacy Customer Account Flow - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with legacy customer account authentication flow. - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with legacy customer account authentication flow. + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) - + [Get familiar with React Router](https://reactrouter.com/) + +## Legacy Customer Account Flow + +🚨 **Caution**: This legacy authentication strategy will not maintain authentication between your Hydrogen storefront and checkout; for better support, use either the New Customer Accounts strategy or Multipass. + +This template includes the legacy customer account flow made with [Storefront API](https://shopify.dev/docs/api/storefront) which provides: +- Full customer registration and login flow -+- Password recovery and reset functionality ++- Password recovery and reset functionality +- Account activation via email +- Customer profile management +- Order history and address management @@ -82,9 +84,9 @@ index c584e5370..11bb1c8cc 100644 +🗒️ Read about the Customer Account API: [https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores](https://www.shopify.com/partners/blog/introducing-customer-account-api-for-headless-stores) + ## What's included - - - Remix -~~~ + + - React Router +``` ### Step 2: Add account link to header navigation @@ -92,7 +94,7 @@ Add an account link to the header navigation. #### File: [app/components/Header.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/Header.tsx) -~~~diff +```diff index 045a75925..05b871c3f 100644 --- a/templates/skeleton/app/components/Header.tsx +++ b/templates/skeleton/app/components/Header.tsx @@ -105,7 +107,7 @@ index 045a75925..05b871c3f 100644 + isLoggedIn: boolean; publicStoreDomain: string; } - + @@ -103,11 +104,8 @@ function HeaderCtas({
    @@ -580,7 +580,7 @@ index df87425c5..aa6f51660 100644 + + return {isLoggedIn, headers}; +} -~~~ +````
    @@ -588,11 +588,11 @@ index df87425c5..aa6f51660 100644 Add a customer registration form. -#### File: [account_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.register.tsx) +#### File: [account\_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.register.tsx)
    -~~~tsx +```tsx import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.register'; import type {CustomerCreateMutation} from 'storefrontapi.generated'; @@ -604,7 +604,8 @@ type ActionResponse = { | null; }; -export const headers: Route.HeadersFunction = ({actionHeaders}) => actionHeaders; +export const headers: Route.HeadersFunction = ({actionHeaders}) => + actionHeaders; export async function loader({context}: Route.LoaderArgs) { const customerAccessToken = await context.session.get('customerAccessToken'); @@ -796,8 +797,7 @@ const REGISTER_LOGIN_MUTATION = `#graphql } } ` as const; - -~~~ +```
    @@ -807,14 +807,14 @@ Convert the catch-all route to use Storefront API authentication. #### File: [app/routes/account.$.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.$.tsx) -~~~diff +```diff index f718f607a..6a8a24520 100644 --- a/templates/skeleton/app/routes/account.$.tsx +++ b/templates/skeleton/app/routes/account.$.tsx @@ -1,9 +1,9 @@ import {redirect} from 'react-router'; import type {Route} from './+types/account.$'; - + -// fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); @@ -825,17 +825,17 @@ index f718f607a..6a8a24520 100644 + } + return redirect('/account/login'); } -~~~ +``` ### Step 9: Build password reset flow Add a password reset form with token validation. -#### File: [account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) +#### File: [account\_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/legacy-customer-account-flow/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx)
    -~~~tsx +```tsx import {Form, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.reset.$id.$resetToken'; @@ -968,8 +968,7 @@ const CUSTOMER_RESET_MUTATION = `#graphql } } ` as const; - -~~~ +```
    @@ -981,7 +980,7 @@ Convert address management to use Storefront API mutations.
    -~~~diff +```diff index e0f97e10f..e95f252f4 100644 --- a/templates/skeleton/app/routes/account.addresses.tsx +++ b/templates/skeleton/app/routes/account.addresses.tsx @@ -1008,12 +1007,12 @@ index e0f97e10f..e95f252f4 100644 - DELETE_ADDRESS_MUTATION, - CREATE_ADDRESS_MUTATION, -} from '~/graphql/customer-account/CustomerAddressMutations'; - + export type ActionResponse = { addressId?: string | null; @@ -32,13 +24,16 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -1024,17 +1023,17 @@ index e0f97e10f..e95f252f4 100644 + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {storefront, session} = context; - + try { const form = await request.formData(); @@ -50,31 +45,26 @@ export async function action({request, context}: Route.ActionArgs) { 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) { @@ -1049,7 +1048,7 @@ index e0f97e10f..e95f252f4 100644 + return data({error: {[addressId]: 'Unauthorized'}}, {status: 401}); } + const {accessToken} = customerAccessToken; - + const defaultAddress = form.has('defaultAddress') ? String(form.get('defaultAddress')) === 'on' - : false; @@ -1072,7 +1071,7 @@ index e0f97e10f..e95f252f4 100644 + 'province', 'zip', ]; - + @@ -89,170 +79,134 @@ export async function action({request, context}: Route.ActionArgs) { case 'POST': { // handle new address creation @@ -1089,14 +1088,14 @@ index e0f97e10f..e95f252f4 100644 + variables: {customerAccessToken: accessToken, address}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressCreate?.customerUserErrors?.length) { + const error = customerAddressCreate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressCreate?.userErrors?.length) { - throw new Error(data?.customerAddressCreate?.userErrors[0].message); - } @@ -1156,7 +1155,7 @@ index e0f97e10f..e95f252f4 100644 + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'PUT': { // handle address updates try { @@ -1174,7 +1173,7 @@ index e0f97e10f..e95f252f4 100644 }, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + const updatedAddress = customerAddressUpdate?.customerAddress; @@ -1183,7 +1182,7 @@ index e0f97e10f..e95f252f4 100644 + const error = customerAddressUpdate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressUpdate?.userErrors?.length) { - throw new Error(data?.customerAddressUpdate?.userErrors[0].message); - } @@ -1233,7 +1232,7 @@ index e0f97e10f..e95f252f4 100644 + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'DELETE': { // handles address deletion try { @@ -1248,7 +1247,7 @@ index e0f97e10f..e95f252f4 100644 + variables: {customerAccessToken: accessToken, id: addressId}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressDelete?.customerUserErrors?.length) { @@ -1284,7 +1283,7 @@ index e0f97e10f..e95f252f4 100644 + return data({error: {[addressId]: error}}, {status: 400}); } } - + default: { return data( {error: {[addressId]: 'Method not allowed'}}, @@ -1314,7 +1313,7 @@ index e0f97e10f..e95f252f4 100644 + return data({error}, {status: 400}); } } - + @@ -291,21 +245,17 @@ function NewAddressForm() { address2: '', city: '', @@ -1331,7 +1330,7 @@ index e0f97e10f..e95f252f4 100644 zip: '', - } as CustomerAddressInput; + } as AddressFragment; - + return ( - @@ -360,26 +309,26 @@ function ExistingAddresses({ } - + export function AddressForm({ - addressId, address, @@ -1541,7 +1540,7 @@ index e0f97e10f..e95f252f4 100644 + } + } +` as const; -~~~ +```
    @@ -1553,7 +1552,7 @@ Convert the order details page to use Storefront API queries.
    -~~~diff +```diff index 3c2430385..52280986d 100644 --- a/templates/skeleton/app/routes/account.orders.$id.tsx +++ b/templates/skeleton/app/routes/account.orders.$id.tsx @@ -1570,11 +1569,11 @@ index 3c2430385..52280986d 100644 -import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery'; + DiscountApplicationFragment, +} from 'storefrontapi.generated'; - - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Order ${data?.order?.name}`}]; + + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; - + export async function loader({params, context}: Route.LoaderArgs) { - const {customerAccount} = context; + const {session, storefront} = context; @@ -1582,7 +1581,7 @@ index 3c2430385..52280986d 100644 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, { @@ -1592,29 +1591,29 @@ index 3c2430385..52280986d 100644 - }, - }); + const customerAccessToken = await session.get('customerAccessToken'); - + - if (errors?.length || !data?.order) { - throw new Error('Order not found'); + if (!customerAccessToken) { + return redirect('/account/login'); } - + - const {order} = data; + const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, { + variables: {orderId}, + }); - + - // Extract line items directly from nodes array - const lineItems = order.lineItems.nodes; + if (!order || !('lineItems' in order)) { + throw new Response('Order not found', {status: 404}); + } - + - // Extract discount applications directly from nodes array - const discountApplications = order.discountApplications.nodes; + const lineItems = flattenConnection(order.lineItems) as OrderLineItemFullFragment[]; + const discountApplications = flattenConnection(order.discountApplications) as DiscountApplicationFragment[]; - + - // Get fulfillment status from first fulfillment node - const fulfillmentStatus = order.fulfillments.nodes[0]?.status ?? 'N/A'; - @@ -1623,17 +1622,17 @@ index 3c2430385..52280986d 100644 - - // Type guard for MoneyV2 discount + const firstDiscount = discountApplications[0]; -+ ++ const discountValue = - firstDiscount?.__typename === 'MoneyV2' - ? (firstDiscount as Extract< - typeof firstDiscount, - {__typename: 'MoneyV2'} - >) -+ firstDiscount?.value?.__typename === 'MoneyV2' -+ ? firstDiscount.value ++ firstDiscount?.value?.__typename === 'MoneyV2' ++ ? firstDiscount.value : null; - + - // Type guard for percentage discount const discountPercentage = - firstDiscount?.__typename === 'PricingPercentageValue' @@ -1646,7 +1645,7 @@ index 3c2430385..52280986d 100644 + firstDiscount?.value?.__typename === 'PricingPercentageValue' + ? firstDiscount.value.percentage : null; - + return { @@ -69,25 +52,16 @@ export async function loader({params, context}: Route.LoaderArgs) { lineItems, @@ -1655,7 +1654,7 @@ index 3c2430385..52280986d 100644 - fulfillmentStatus, }; } - + export default function OrderRoute() { - const { - order, @@ -1747,7 +1746,7 @@ index 3c2430385..52280986d 100644

    @@ -196,27 +171,145 @@ export default function OrderRoute() { - + function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { return ( - @@ -1901,7 +1900,7 @@ index 3c2430385..52280986d 100644 + } + } +` as const; -~~~ +```
    @@ -1909,11 +1908,11 @@ index 3c2430385..52280986d 100644 Convert the orders list to use the Storefront API with pagination. -#### File: [app/routes/account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.orders._index.tsx) +#### File: [app/routes/account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.orders._index.tsx)
    -~~~diff +```diff index a9f479ea7..2d30caa0d 100644 --- a/templates/skeleton/app/routes/account.orders._index.tsx +++ b/templates/skeleton/app/routes/account.orders._index.tsx @@ -1945,7 +1944,7 @@ index a9f479ea7..2d30caa0d 100644 -} from 'customer-accountapi.generated'; +} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; - + -type OrdersLoaderData = { - customer: CustomerOrdersFragment; - filters: OrderFilterParams; @@ -1954,14 +1953,14 @@ index a9f479ea7..2d30caa0d 100644 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 {session, storefront} = context; - + - const url = new URL(request.url); - const filters = parseOrderFilters(url.searchParams); - const query = buildOrderSearchQuery(filters); @@ -1980,7 +1979,7 @@ index a9f479ea7..2d30caa0d 100644 + if (!customerAccessToken?.accessToken) { + return redirect('/account/login'); } - + - return {customer: data.customer, filters}; + try { + const paginationVariables = getPaginationVariables(request, { @@ -2009,18 +2008,18 @@ index a9f479ea7..2d30caa0d 100644 + return data({error: String(error)}, {status: 400}); + } } - + export default function Orders() { - const {customer, filters} = useLoaderData(); + const data = useLoaderData(); -+ ++ + if ('error' in data) { + return
    Error: {data.error}
    ; + } -+ ++ + const {customer} = data; const {orders} = customer; - + return (
    - @@ -2029,7 +2028,7 @@ index a9f479ea7..2d30caa0d 100644
    ); } - + -function OrdersTable({ - orders, - filters, @@ -2054,7 +2053,7 @@ index a9f479ea7..2d30caa0d 100644
    ); } - + -function EmptyOrders({hasFilters = false}: {hasFilters?: boolean}) { +function EmptyOrders() { return ( @@ -2084,7 +2083,7 @@ index a9f479ea7..2d30caa0d 100644 ); } - + -function OrderSearchForm({ - currentFilters, -}: { @@ -2265,7 +2264,7 @@ index a9f479ea7..2d30caa0d 100644 + } +` as const; \ No newline at end of file -~~~ +``` @@ -2277,7 +2276,7 @@ Convert the customer profile page to use Storefront API queries.
    -~~~diff +```diff index 6f10065fb..db95ad78a 100644 --- a/templates/skeleton/app/routes/account.profile.tsx +++ b/templates/skeleton/app/routes/account.profile.tsx @@ -2297,10 +2296,10 @@ index 6f10065fb..db95ad78a 100644 + redirect, } from 'react-router'; import type {Route} from './+types/account.profile'; - + @@ -20,23 +20,40 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -2310,21 +2309,21 @@ index 6f10065fb..db95ad78a 100644 + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {session, storefront} = context; - + if (request.method !== 'PUT') { return data({error: 'Method not allowed'}, {status: 405}); } - + const form = await request.formData(); + const customerAccessToken = await session.get('customerAccessToken'); + if (!customerAccessToken) { + return data({error: 'Unauthorized'}, {status: 401}); + } - + try { + const password = getPassword(form); const customer: CustomerUpdateInput = {}; @@ -2346,7 +2345,7 @@ index 6f10065fb..db95ad78a 100644 @@ -46,36 +63,37 @@ export async function action({request, context}: Route.ActionArgs) { } } - + + if (password) { + customer.password = password; + } @@ -2366,7 +2365,7 @@ index 6f10065fb..db95ad78a 100644 }, - ); + }); - + - if (errors?.length) { - throw new Error(errors[0].message); + // check for mutation errors @@ -2376,7 +2375,7 @@ index 6f10065fb..db95ad78a 100644 + {status: 400}, + ); } - + - if (!data?.customerUpdate?.customer) { - throw new Error('Customer profile update failed.'); + // update session with the updated access token @@ -2386,7 +2385,7 @@ index 6f10065fb..db95ad78a 100644 + updated.customerUpdate?.customerAccessToken, + ); } - + - return { - error: null, - customer: data?.customerUpdate?.customer, @@ -2402,7 +2401,7 @@ index 6f10065fb..db95ad78a 100644 + return data({error: error.message, customer: null}, {status: 400}); } } - + @@ -114,6 +132,64 @@ export default function AccountProfile() { defaultValue={customer.lastName ?? ''} minLength={2} @@ -2517,58 +2516,10 @@ index 6f10065fb..db95ad78a 100644 + } + } +` as const; -~~~ +```
    -### Step 13: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - ### Step 14: Update account layout for session auth Convert the account layout to use session-based authentication. @@ -2577,7 +2528,7 @@ Convert the account layout to use session-based authentication.
    -~~~diff +```diff index 0429e7ebe..ef2075d91 100644 --- a/templates/skeleton/app/routes/account.tsx +++ b/templates/skeleton/app/routes/account.tsx @@ -2594,11 +2545,11 @@ index 0429e7ebe..ef2075d91 100644 import type {Route} from './+types/account'; -import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import type {CustomerFragment} from 'storefrontapi.generated'; - + export function shouldRevalidate() { return true; } - + -export async function loader({context}: Route.LoaderArgs) { - const {customerAccount} = context; - const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { @@ -2608,7 +2559,7 @@ index 0429e7ebe..ef2075d91 100644 - }); +export const headers: Route.HeadersFunction = ({loaderHeaders}) => + loaderHeaders; - + - if (errors?.length || !data?.customer) { - throw new Error('Customer not found'); +export async function loader({request, context}: Route.LoaderArgs) { @@ -2626,7 +2577,7 @@ index 0429e7ebe..ef2075d91 100644 + session.unset('customerAccessToken'); + return redirect('/account/login'); } - + - return remixData( - {customer: data.customer}, - { @@ -2697,7 +2648,7 @@ index 0429e7ebe..ef2075d91 100644 + ); } - + -export default function AccountLayout() { - const {customer} = useLoaderData(); - @@ -2773,7 +2724,7 @@ index 0429e7ebe..ef2075d91 100644 + } + ${CUSTOMER_FRAGMENT} +` as const; -~~~ +```
    @@ -2781,18 +2732,18 @@ index 0429e7ebe..ef2075d91 100644 Replace the Customer Account API login with the Storefront API form. -#### File: [app/routes/account_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.login.tsx) +#### File: [app/routes/account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.login.tsx)
    -~~~diff +```diff index e89c8e0a2..37ff4d409 100644 --- a/templates/skeleton/app/routes/account_.login.tsx +++ b/templates/skeleton/app/routes/account_.login.tsx @@ -1,17 +1,139 @@ +import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.login'; - + -export async function loader({request, context}: Route.LoaderArgs) { - const url = new URL(request.url); - const acrValues = url.searchParams.get('acr_values') || undefined; @@ -2802,7 +2753,7 @@ index e89c8e0a2..37ff4d409 100644 +type ActionResponse = { + error: string | null; +}; - + - return context.customerAccount.login({ - countryCode: context.storefront.i18n.country, - acrValues, @@ -2942,7 +2893,7 @@ index e89c8e0a2..37ff4d409 100644 + } + } +` as const; -~~~ +```
    @@ -2950,9 +2901,9 @@ index e89c8e0a2..37ff4d409 100644 Replace the Customer Account API logout with a session cleanup. -#### File: [app/routes/account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.logout.tsx) +#### File: [app/routes/account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.logout.tsx) -~~~diff +```diff index 5e67cc857..d88e717d5 100644 --- a/templates/skeleton/app/routes/account_.logout.tsx +++ b/templates/skeleton/app/routes/account_.logout.tsx @@ -2960,7 +2911,7 @@ index 5e67cc857..d88e717d5 100644 -import {redirect} from 'react-router'; +import {data, redirect} from 'react-router'; import type {Route} from './+types/account_.logout'; - + -// if we don't implement this, /account/logout will get caught by account.$.tsx to do login +export const meta: Route.MetaFunction = () => { + return [{title: 'Logout'}]; @@ -2980,7 +2931,7 @@ index 5e67cc857..d88e717d5 100644 + return redirect('/'); } - + -export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); -} @@ -2988,7 +2939,7 @@ index 5e67cc857..d88e717d5 100644 + return null; +} \ No newline at end of file -~~~ +``` ## Next steps @@ -3010,4 +2961,4 @@ After applying this recipe: - "Remember me" functionality with longer session expiry - Social login integration - Two-factor authentication - - Customer profile fields (phone, marketing preferences) \ No newline at end of file + - Customer profile fields (phone, marketing preferences) diff --git a/cookbook/recipes/legacy-customer-account-flow/patches/README.md.1764cd.patch b/cookbook/recipes/legacy-customer-account-flow/patches/README.md.1764cd.patch index 84e354c6bb..6a60e72717 100644 --- a/cookbook/recipes/legacy-customer-account-flow/patches/README.md.1764cd.patch +++ b/cookbook/recipes/legacy-customer-account-flow/patches/README.md.1764cd.patch @@ -5,11 +5,11 @@ index c584e5370..11bb1c8cc 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Legacy Customer Account Flow --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with legacy customer account authentication flow. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with legacy customer account authentication flow. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) +## Legacy Customer Account Flow + @@ -29,4 +29,4 @@ index c584e5370..11bb1c8cc 100644 + ## What's included - - Remix + - React Router diff --git a/cookbook/recipes/legacy-customer-account-flow/patches/account.orders.$id.tsx.767824.patch b/cookbook/recipes/legacy-customer-account-flow/patches/account.orders.$id.tsx.767824.patch index cffb543b04..ba9a9b2fa4 100644 --- a/cookbook/recipes/legacy-customer-account-flow/patches/account.orders.$id.tsx.767824.patch +++ b/cookbook/recipes/legacy-customer-account-flow/patches/account.orders.$id.tsx.767824.patch @@ -15,8 +15,8 @@ index 3c2430385..52280986d 100644 + DiscountApplicationFragment, +} from 'storefrontapi.generated'; - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Order ${data?.order?.name}`}]; + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { diff --git a/cookbook/recipes/legacy-customer-account-flow/patches/package.json.8e0ff5.patch b/cookbook/recipes/legacy-customer-account-flow/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/legacy-customer-account-flow/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/legacy-customer-account-flow/recipe.yaml b/cookbook/recipes/legacy-customer-account-flow/recipe.yaml index 1a92e96c58..d75eee794f 100644 --- a/cookbook/recipes/legacy-customer-account-flow/recipe.yaml +++ b/cookbook/recipes/legacy-customer-account-flow/recipe.yaml @@ -41,120 +41,114 @@ ingredients: - path: templates/skeleton/app/routes/account_.register.tsx description: Customer registration form with email validation and account creation - path: templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx - description: Password reset confirmation route that validates tokens and updates + description: + Password reset confirmation route that validates tokens and updates passwords deletedFiles: [] steps: - type: PATCH - step: "1" + step: '1' name: Document legacy customer accounts in the README description: Update the README file to document the legacy customer account flow. diffs: - file: README.md patchFile: README.md.1764cd.patch - type: PATCH - step: "2" + step: '2' name: Add account link to header navigation description: Add an account link to the header navigation. diffs: - file: app/components/Header.tsx patchFile: Header.tsx.00776f.patch - type: NEW_FILE - step: "3" + step: '3' name: Create account activation flow description: Add an account activation route for email verification. ingredients: - path: templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx - type: PATCH - step: "4" + step: '4' name: Update PageLayout for legacy auth description: Update PageLayout to handle account routes. diffs: - file: app/components/PageLayout.tsx patchFile: PageLayout.tsx.e72b8d.patch - type: NEW_FILE - step: "5" + step: '5' name: Build password recovery flow description: Add a password recovery form. ingredients: - path: templates/skeleton/app/routes/account_.recover.tsx - type: PATCH - step: "6" + step: '6' name: Validate customer access tokens description: Add customer access token validation to the root loader. diffs: - file: app/root.tsx patchFile: root.tsx.8f1e92.patch - type: NEW_FILE - step: "7" + step: '7' name: Create customer registration flow description: Add a customer registration form. ingredients: - path: templates/skeleton/app/routes/account_.register.tsx - type: PATCH - step: "8" + step: '8' name: Handle unauthenticated account routes description: Convert the catch-all route to use Storefront API authentication. diffs: - file: app/routes/account.$.tsx patchFile: account.$.tsx.8a1da5.patch - type: NEW_FILE - step: "9" + step: '9' name: Build password reset flow description: Add a password reset form with token validation. ingredients: - path: templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx - type: PATCH - step: "10" + step: '10' name: Add address management description: Convert address management to use Storefront API mutations. diffs: - file: app/routes/account.addresses.tsx patchFile: account.addresses.tsx.ab9d7b.patch - type: PATCH - step: "11" + step: '11' name: Show order details description: Convert the order details page to use Storefront API queries. diffs: - file: app/routes/account.orders.$id.tsx patchFile: account.orders.$id.tsx.767824.patch - type: PATCH - step: "12" + step: '12' name: Display order history description: Convert the orders list to use the Storefront API with pagination. diffs: - file: app/routes/account.orders._index.tsx patchFile: account.orders._index.tsx.8d7c8a.patch - type: PATCH - step: "13" + step: '13' name: Build customer profile page description: Convert the customer profile page to use Storefront API queries. diffs: - file: app/routes/account.profile.tsx patchFile: account.profile.tsx.0d4ef1.patch - type: PATCH - step: "13" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch - - type: PATCH - step: "14" + step: '14' name: Update account layout for session auth description: Convert the account layout to use session-based authentication. diffs: - file: app/routes/account.tsx patchFile: account.tsx.d6599a.patch - type: PATCH - step: "15" + step: '15' name: Create login page description: Replace the Customer Account API login with the Storefront API form. diffs: - file: app/routes/account_.login.tsx patchFile: account_.login.tsx.a8f71d.patch - type: PATCH - step: "16" + step: '16' name: Handle logout and session cleanup description: Replace the Customer Account API logout with a session cleanup. diffs: @@ -191,13 +185,15 @@ llms: solution: Ensure the customer account is activated. Check if the store has classic customer accounts enabled, not the new customer accounts. - issue: '"Customer Account API not configured" error' - solution: This recipe replaces the Customer Account API. Make sure all patches + solution: + This recipe replaces the Customer Account API. Make sure all patches were applied successfully and run `npm run codegen`. - issue: Password reset emails not being sent solution: Configure email notifications in Shopify admin under Settings > Notifications. Ensure the customer email is verified. - issue: Session expires too quickly - solution: Adjust the session configuration in server.ts to increase the cookie + solution: + Adjust the session configuration in server.ts to increase the cookie maxAge for longer sessions. - issue: TypeScript errors about missing types solution: Run `npm run codegen` to generate the Storefront API types after diff --git a/cookbook/recipes/markets/README.md b/cookbook/recipes/markets/README.md index dcbde2f7e6..e79fe86916 100644 --- a/cookbook/recipes/markets/README.md +++ b/cookbook/recipes/markets/README.md @@ -21,16 +21,16 @@ market information visible in the URL. This provides two key benefits: 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 + - **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 + - **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 @@ -45,45 +45,45 @@ In this recipe, we'll implement **path-based localization**. ## Requirements - Set up your store's regions and languages using [Shopify -Markets](https://help.shopify.com/en/manual/markets). + Markets](https://help.shopify.com/en/manual/markets). - Configure your products appropriately for each market. - Make sure your Hydrogen app is configured to use a default `language` and -`country code`. They will be used as the fallback when no market is explicitly -selected. + `country code`. They will be used as the fallback when no market is explicitly + selected. ## Ingredients _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/CountrySelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/CountrySelector.tsx) | A component that displays a country selector inside the Header. | -| [app/components/Link.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx) | A unified locale-aware Link component that handles both regular links and navigation links with active states. Automatically prepends locale prefixes and cleans menu URLs. | -| [app/lib/i18n.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/lib/i18n.ts) | Comprehensive i18n utilities including locale detection, path transformation hooks, URL cleaning functions, and locale validation. Centralizes all localization logic in one place. | -| [app/routes/($locale)._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx) | A route that renders a localized version of the home page. | -| [app/routes/($locale).account.$.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx) | Fallback route for unauthenticated account pages with locale support | -| [app/routes/($locale).account._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account._index.tsx) | Localized account dashboard redirect route | -| [app/routes/($locale).account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx) | Customer address management page with locale-aware forms and links | -| [app/routes/($locale).account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx) | Individual order details page with localized currency and date formatting | -| [app/routes/($locale).account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx) | Customer order history listing with locale-specific pagination | -| [app/routes/($locale).account.profile.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx) | Customer profile editing form with localized field labels | -| [app/routes/($locale).account.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx) | Account layout wrapper with locale-aware navigation tabs | -| [app/routes/($locale).account_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx) | OAuth authorization callback route with locale preservation | -| [app/routes/($locale).account_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx) | Customer login redirect with locale-specific return URL | -| [app/routes/($locale).account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx) | Logout handler that maintains locale after sign out | -| [app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx) | Blog article page with locale-specific content and SEO metadata | -| [app/routes/($locale).blogs.$blogHandle._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx) | Blog listing page with localized article previews and pagination | -| [app/routes/($locale).blogs._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx) | All blogs overview page with locale-aware navigation links | -| [app/routes/($locale).cart.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx) | A localized cart route. | -| [app/routes/($locale).collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx) | Collection page displaying products with locale-specific pricing and availability | -| [app/routes/($locale).collections._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx) | Collections listing page with localized collection names and images | -| [app/routes/($locale).collections.all.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx) | All products page with locale-based filtering and sorting | -| [app/routes/($locale).pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx) | Dynamic page route for locale-specific content pages | -| [app/routes/($locale).policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx) | Policy page (privacy, terms, etc.) with locale-specific legal content | -| [app/routes/($locale).policies._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx) | Policies index page listing all available store policies | -| [app/routes/($locale).products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx) | A route that renders a localized version of the product page. | -| [app/routes/($locale).search.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx) | Search results page with locale-aware product matching and predictive search | -| [app/routes/($locale).tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).tsx) | A utility route that makes sure the locale is valid. | +| File | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [app/components/CountrySelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/CountrySelector.tsx) | A component that displays a country selector inside the Header. | +| [app/components/Link.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx) | A unified locale-aware Link component that handles both regular links and navigation links with active states. Automatically prepends locale prefixes and cleans menu URLs. | +| [app/lib/i18n.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/lib/i18n.ts) | Comprehensive i18n utilities including locale detection, path transformation hooks, URL cleaning functions, and locale validation. Centralizes all localization logic in one place. | +| [app/routes/($locale).\_index.tsx]() | A route that renders a localized version of the home page. | +| [app/routes/($locale).account.$.tsx]() | Fallback route for unauthenticated account pages with locale support | +| [app/routes/($locale).account.\_index.tsx]() | Localized account dashboard redirect route | +| [app/routes/($locale).account.addresses.tsx]() | Customer address management page with locale-aware forms and links | +| [app/routes/($locale).account.orders.$id.tsx]() | Individual order details page with localized currency and date formatting | +| [app/routes/($locale).account.orders.\_index.tsx]() | Customer order history listing with locale-specific pagination | +| [app/routes/($locale).account.profile.tsx]() | Customer profile editing form with localized field labels | +| [app/routes/($locale).account.tsx]() | Account layout wrapper with locale-aware navigation tabs | +| [app/routes/($locale).account\_.authorize.tsx]() | OAuth authorization callback route with locale preservation | +| [app/routes/($locale).account\_.login.tsx]() | Customer login redirect with locale-specific return URL | +| [app/routes/($locale).account\_.logout.tsx]() | Logout handler that maintains locale after sign out | +| [app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx]() | Blog article page with locale-specific content and SEO metadata | +| [app/routes/($locale).blogs.$blogHandle.\_index.tsx]() | Blog listing page with localized article previews and pagination | +| [app/routes/($locale).blogs.\_index.tsx]() | All blogs overview page with locale-aware navigation links | +| [app/routes/($locale).cart.tsx]() | A localized cart route. | +| [app/routes/($locale).collections.$handle.tsx]() | Collection page displaying products with locale-specific pricing and availability | +| [app/routes/($locale).collections.\_index.tsx]() | Collections listing page with localized collection names and images | +| [app/routes/($locale).collections.all.tsx]() | All products page with locale-based filtering and sorting | +| [app/routes/($locale).pages.$handle.tsx]() | Dynamic page route for locale-specific content pages | +| [app/routes/($locale).policies.$handle.tsx]() | Policy page (privacy, terms, etc.) with locale-specific legal content | +| [app/routes/($locale).policies.\_index.tsx]() | Policies index page listing all available store policies | +| [app/routes/($locale).products.$handle.tsx]() | A route that renders a localized version of the product page. | +| [app/routes/($locale).search.tsx]() | Search results page with locale-aware product matching and predictive search | +| [app/routes/($locale).tsx]() | A utility route that makes sure the locale is valid. | ## Steps @@ -97,7 +97,7 @@ Update cart line items to use the unified Link component for product links. ##### File: [app/components/CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/CartLineItem.tsx) -~~~diff +```diff index 63a0ecd9d..47da6dfa0 100644 --- a/templates/skeleton/app/components/CartLineItem.tsx +++ b/templates/skeleton/app/components/CartLineItem.tsx @@ -110,7 +110,7 @@ index 63a0ecd9d..47da6dfa0 100644 import {ProductPrice} from './ProductPrice'; import {useAside} from './Aside'; import type { -~~~ +``` #### Step 1.2: Update AddToCartButton to use locale-aware cart route @@ -118,7 +118,7 @@ Update the AddToCartButton component to post to the localized cart route (e.g., ##### File: [app/components/AddToCartButton.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/AddToCartButton.tsx) -~~~diff +```diff index 6e553f9f6..a8aefc7d8 100644 --- a/templates/skeleton/app/components/AddToCartButton.tsx +++ b/templates/skeleton/app/components/AddToCartButton.tsx @@ -126,7 +126,7 @@ index 6e553f9f6..a8aefc7d8 100644 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({ @@ -141,7 +141,7 @@ index 6e553f9f6..a8aefc7d8 100644 {(fetcher: FetcherWithComponents) => ( <> -~~~tsx +```tsx import {Form, useLocation} from 'react-router'; import type {Locale} from '../lib/i18n'; import { @@ -235,8 +235,7 @@ function LocaleForm({locale}: {locale: Locale}) { ); } - -~~~ +``` @@ -244,6 +243,7 @@ function LocaleForm({locale}: {locale: Locale}) { 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 @@ -253,7 +253,7 @@ This component automatically:
    -~~~tsx +```tsx import type {LinkProps, NavLinkProps} from 'react-router'; import {Link as ReactLink, NavLink as ReactNavLink} from 'react-router'; import {useLocalizedPath, cleanPath} from '../lib/i18n'; @@ -264,31 +264,33 @@ type BaseProps = { preservePath?: boolean; }; -type LinkVariantProps = BaseProps & LinkProps & { - variant?: never; -}; +type LinkVariantProps = BaseProps & + LinkProps & { + variant?: never; + }; -type NavLinkVariantProps = BaseProps & NavLinkProps & { - variant: 'nav'; -}; +type NavLinkVariantProps = BaseProps & + NavLinkProps & { + variant: 'nav'; + }; export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; /** * Locale-aware Link component that handles both regular and navigation links - * + * * @example * // Regular link (auto-adds current locale) * Products - * + * * @example * // Navigation link with active styles * About - * + * * @example * // Switch locale while preserving current path * Français - * + * * @example * // Link to specific locale * Canadian Products @@ -296,7 +298,7 @@ export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; 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('://')) { @@ -308,21 +310,22 @@ export function Link(props: ExtendedLinkProps) { } to = cleanPath(to); } - + to = useLocalizedPath(to, locale, preservePath); - + if (variant === 'nav') { return ; } return ; } -~~~ +```
    #### Step 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 @@ -335,7 +338,7 @@ Create a centralized i18n module that includes:
    -~~~ts +```ts import {useMatches, useLocation} from 'react-router'; import type { CountryCode as CustomerCountryCode, @@ -546,8 +549,7 @@ export function useLocalizedPath( // Add current locale to path return normalizePrefix(currentLocale?.pathPrefix || '') + to; } - -~~~ +```
    @@ -558,7 +560,7 @@ This ensures all product links automatically include the correct locale prefix. ##### File: [app/components/ProductItem.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/ProductItem.tsx) -~~~diff +```diff index 3b0f69133..81ff9ec9f 100644 --- a/templates/skeleton/app/components/ProductItem.tsx +++ b/templates/skeleton/app/components/ProductItem.tsx @@ -572,10 +574,10 @@ index 3b0f69133..81ff9ec9f 100644 } from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; +import {Link} from './Link'; - + export function ProductItem({ product, -~~~ +``` #### Step 1.7: Add the selected locale to the context @@ -583,28 +585,28 @@ Detect the locale from the URL path, and add it to the HydrogenContext. ##### File: [app/lib/context.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/lib/context.ts) -~~~diff +```diff index 692d5ae17..7373ebca2 100644 --- a/templates/skeleton/app/lib/context.ts +++ b/templates/skeleton/app/lib/context.ts -@@ -1,6 +1,7 @@ - import {createHydrogenContext} from '@shopify/hydrogen'; +@@ -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 = { -@@ -40,6 +41,8 @@ export async function createHydrogenRouterContext( + // Additional context for custom properties, CMS clients, 3P SDKs, etc. +@@ -42,6 +43,8 @@ export async function createHydrogenRouterContext( AppSession.init(request, [env.SESSION_SECRET]), ]); - + + const i18n = getLocaleFromRequest(request); + const hydrogenContext = createHydrogenContext( { env, -@@ -47,8 +50,7 @@ export async function createHydrogenRouterContext( +@@ -49,8 +52,7 @@ export async function createHydrogenRouterContext( cache, waitUntil, session, @@ -614,7 +616,7 @@ index 692d5ae17..7373ebca2 100644 cart: { queryFragment: CART_QUERY_FRAGMENT, }, -~~~ +``` #### Step 1.8: Update Header with CountrySelector and locale-aware Links @@ -627,7 +629,7 @@ Menu URLs are automatically cleaned of invalid locale prefixes.
    -~~~diff +```diff index 045a75925..8da92cdef 100644 --- a/templates/skeleton/app/components/Header.tsx +++ b/templates/skeleton/app/components/Header.tsx @@ -644,7 +646,7 @@ index 045a75925..8da92cdef 100644 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({ @@ -714,7 +716,7 @@ index 045a75925..8da92cdef 100644 -~~~ +```
    @@ -723,23 +725,23 @@ index 045a75925..8da92cdef 100644 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. + when the locale changes. ##### File: [app/root.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/root.tsx) -~~~diff +```diff index df87425c5..97ca81746 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx -@@ -77,6 +77,7 @@ export async function loader(args: Route.LoaderArgs) { +@@ -85,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) { return { ...deferredData, ...criticalData, -+ selectedLocale: args.context.storefront.i18n, ++ selectedLocale: args.context.get(hydrogenContext.storefront).i18n, publicStoreDomain: env.PUBLIC_STORE_DOMAIN, shop: getShopAnalytics({ storefront, -@@ -176,7 +177,10 @@ export default function App() { +@@ -183,7 +184,10 @@ export default function App() { shop={data.shop} consent={data.consent} > @@ -751,7 +753,7 @@ index df87425c5..97ca81746 100644 -~~~ +``` ### Step 2: Localizing the individual routes @@ -763,7 +765,7 @@ Replace all Link imports to use the unified locale-aware `Link` component for co ##### File: [app/components/CartMain.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/CartMain.tsx) -~~~diff +```diff index f70b74760..39c4c4731 100644 --- a/templates/skeleton/app/components/CartMain.tsx +++ b/templates/skeleton/app/components/CartMain.tsx @@ -774,7 +776,7 @@ index f70b74760..39c4c4731 100644 import type {CartApiQueryFragment} from 'storefrontapi.generated'; import {useAside} from '~/components/Aside'; import {CartLineItem, type CartLine} from '~/components/CartLineItem'; -~~~ +``` #### Step 2.2: Add language dynamic segment to the desired routes @@ -792,15 +794,15 @@ For brevity, we'll focus on the home page, the cart page, and the product page i > [!NOTE] > Rename `app/routes/_index.tsx` to `app/routes/($locale)._index.tsx`. -##### File: [($locale)._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx) +##### File: [($locale).\_index.tsx]()
    -~~~tsx +```tsx import {Await, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale)._index'; import {Suspense} from 'react'; -import {Image} from '@shopify/hydrogen'; +import {Image, hydrogenContext} from '@shopify/hydrogen'; import type { FeaturedCollectionFragment, RecommendedProductsQuery, @@ -828,7 +830,7 @@ export async function loader(args: Route.LoaderArgs) { */ async function loadCriticalData({context}: Route.LoaderArgs) { const [{collections}] = await Promise.all([ - context.storefront.query(FEATURED_COLLECTION_QUERY), + context.get(hydrogenContext.storefront).query(FEATURED_COLLECTION_QUERY), // Add other queries here, so that they are loaded in parallel ]); @@ -843,7 +845,8 @@ async function loadCriticalData({context}: Route.LoaderArgs) { * 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 + const recommendedProducts = context + .get(hydrogenContext.storefront) .query(RECOMMENDED_PRODUCTS_QUERY) .catch((error: Error) => { // Log query errors, but don't throw them so the page can still render @@ -965,8 +968,7 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -977,16 +979,12 @@ Add the dynamic segment to the cart page route. > [!NOTE] > Rename `app/routes/cart.tsx` to `app/routes/($locale).cart.tsx`. -##### File: [($locale).cart.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx) +##### File: [($locale).cart.tsx]()
    -~~~tsx -import { - useLoaderData, - data, - type HeadersFunction, -} from 'react-router'; +```tsx +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'; @@ -1099,8 +1097,7 @@ export default function Cart() { ); } - -~~~ +```
    @@ -1108,16 +1105,16 @@ export default function Cart() { 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. + localized prefix. > [!NOTE] > Rename `app/routes/products.$handle.tsx` to `app/routes/($locale).products.$handle.tsx`. -##### File: [($locale).products.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx) +##### File: [($locale).products.$handle.tsx]()
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).products.$handle'; import { @@ -1172,6 +1169,7 @@ async function loadCriticalData({ context, params, request, + url, }: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; @@ -1197,7 +1195,7 @@ async function loadCriticalData({ } // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); + redirectIfHandleIsLocalized(url, {handle, data: product}); return { product, @@ -1370,8 +1368,7 @@ const PRODUCT_QUERY = `#graphql } ${PRODUCT_FRAGMENT} ` as const; - -~~~ +```
    @@ -1381,11 +1378,11 @@ Add a utility route in `$(locale).tsx` that will use `localeMatchesPrefix` to validate the locale from the URL params. If the locale is invalid, the route will throw a 404 error. -##### File: [($locale).tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).tsx) +##### File: [($locale).tsx]()
    -~~~tsx +```tsx import type {Route} from './+types/($locale)'; import {localeMatchesPrefix} from '~/lib/i18n'; @@ -1396,8 +1393,7 @@ export async function loader({params}: Route.LoaderArgs) { return null; } - -~~~ +```
    @@ -1405,22 +1401,22 @@ export async function loader({params}: Route.LoaderArgs) { Add a fallback route for unauthenticated account pages with locale support. -##### File: [($locale).account.$.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx) +##### File: [($locale).account.$.tsx]()
    -~~~tsx +```tsx import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account.$'; +import {hydrogenContext} from '@shopify/hydrogen'; // fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return redirect('/account'); } - -~~~ +```
    @@ -1428,18 +1424,17 @@ export async function loader({context}: Route.LoaderArgs) { Add a localized account dashboard redirect route. -##### File: [($locale).account._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account._index.tsx) +##### File: [($locale).account.\_index.tsx]()
    -~~~tsx +```tsx import {redirect} from 'react-router'; export async function loader() { return redirect('/account/orders'); } - -~~~ +```
    @@ -1447,11 +1442,11 @@ export async function loader() { Add a customer address management page with locale-aware forms and links. -##### File: [($locale).account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx) +##### File: [($locale).account.addresses.tsx]()
    -~~~tsx +```tsx import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; import type { AddressFragment, @@ -1471,6 +1466,7 @@ import { DELETE_ADDRESS_MUTATION, CREATE_ADDRESS_MUTATION, } from '~/graphql/customer-account/CustomerAddressMutations'; +import {hydrogenContext} from '@shopify/hydrogen'; export type ActionResponse = { addressId?: string | null; @@ -1486,7 +1482,7 @@ export const meta: Route.MetaFunction = () => { }; export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return {}; } @@ -1968,8 +1964,7 @@ export function AddressForm({ ); } - -~~~ +```
    @@ -1977,11 +1972,11 @@ export function AddressForm({ Add an individual order details page with localized currency and date formatting. -##### File: [($locale).account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx) +##### File: [($locale).account.orders.$id.tsx]()
    -~~~tsx +```tsx import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).account.orders.$id'; import {Money, Image} from '@shopify/hydrogen'; @@ -1991,8 +1986,8 @@ import type { } 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { @@ -2016,19 +2011,10 @@ export async function loader({params, context}: Route.LoaderArgs) { 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< @@ -2204,8 +2190,7 @@ function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { ); } - -~~~ +```
    @@ -2213,16 +2198,12 @@ function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { Implement customer order history listing with locale-specific pagination. -##### File: [($locale).account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx) +##### File: [($locale).account.orders.\_index.tsx]()
    -~~~tsx -import { - useLoaderData, - useNavigation, - useSearchParams, -} from 'react-router'; +```tsx +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'; @@ -2440,8 +2421,7 @@ function OrderItem({order}: {order: OrderItemFragment}) { ); } - -~~~ +```
    @@ -2449,11 +2429,11 @@ function OrderItem({order}: {order: OrderItemFragment}) { Add a customer profile editing form with localized field labels. -##### File: [($locale).account.profile.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx) +##### File: [($locale).account.profile.tsx]()
    -~~~tsx +```tsx import type {CustomerFragment} from 'customer-accountapi.generated'; import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types'; import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation'; @@ -2465,6 +2445,7 @@ import { useOutletContext, } from 'react-router'; import type {Route} from './+types/($locale).account.profile'; +import {hydrogenContext} from '@shopify/hydrogen'; export type ActionResponse = { error: string | null; @@ -2476,7 +2457,7 @@ export const meta: Route.MetaFunction = () => { }; export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return {}; } @@ -2587,8 +2568,7 @@ export default function AccountProfile() { ); } - -~~~ +```
    @@ -2596,20 +2576,16 @@ export default function AccountProfile() { Add an account layout wrapper with locale-aware navigation tabs. -##### File: [($locale).account.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx) +##### File: [($locale).account.tsx]()
    -~~~tsx -import { - data as remixData, - Form, - Outlet, - useLoaderData, -} from 'react-router'; +```tsx +import {data as remixData, Form, Outlet, useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).account'; import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import {hydrogenContext} from '@shopify/hydrogen'; export function shouldRevalidate() { return true; @@ -2617,14 +2593,11 @@ export function shouldRevalidate() { 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, - }, + const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { + variables: { + language: context.get(hydrogenContext.customerAccount).i18n.language, }, - ); + }); if (errors?.length || !data?.customer) { throw new Error('Customer not found'); @@ -2701,8 +2674,7 @@ function Logout() { ); } - -~~~ +```
    @@ -2710,18 +2682,18 @@ function Logout() { Add an OAuth authorization callback route with locale preservation. -##### File: [($locale).account_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx) +##### File: [($locale).account\_.authorize.tsx]()
    -~~~tsx +```tsx import type {Route} from './+types/($locale).account_.authorize'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({context}: Route.LoaderArgs) { - return context.customerAccount.authorize(); + return context.get(hydrogenContext.customerAccount).authorize(); } - -~~~ +```
    @@ -2729,12 +2701,13 @@ export async function loader({context}: Route.LoaderArgs) { Add a customer login redirect with a locale-specific return URL. -##### File: [($locale).account_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx) +##### File: [($locale).account\_.login.tsx]()
    -~~~tsx +```tsx import type {Route} from './+types/($locale).account_.login'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({request, context}: Route.LoaderArgs) { const url = new URL(request.url); @@ -2743,16 +2716,15 @@ export async function loader({request, context}: Route.LoaderArgs) { 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, + return context.get(hydrogenContext.customerAccount).login({ + countryCode: context.get(hydrogenContext.storefront).i18n.country, acrValues, loginHint, loginHintMode, locale, }); } - -~~~ +```
    @@ -2760,13 +2732,14 @@ export async function loader({request, context}: Route.LoaderArgs) { Add a logout handler that maintains locale after the user signs out. -##### File: [($locale).account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx) +##### File: [($locale).account\_.logout.tsx]()
    -~~~tsx +```tsx import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account_.logout'; +import {hydrogenContext} from '@shopify/hydrogen'; // if we don't implement this, /account/logout will get caught by account.$.tsx to do login export async function loader() { @@ -2774,10 +2747,9 @@ export async function loader() { } export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); + return context.get(hydrogenContext.customerAccount).logout(); } - -~~~ +```
    @@ -2785,18 +2757,18 @@ export async function action({context}: Route.ActionArgs) { Add a blog article page with locale-specific content and SEO metadata. -##### File: [($locale).blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx) +##### File: [($locale).blogs.$blogHandle.$articleHandle.tsx]()
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).blogs.$blogHandle.$articleHandle'; -import {Image} from '@shopify/hydrogen'; +import {Image, hydrogenContext} from '@shopify/hydrogen'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.article.title ?? ''} article`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -2813,7 +2785,7 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({context, url, params}: Route.LoaderArgs) { const {blogHandle, articleHandle} = params; if (!articleHandle || !blogHandle) { @@ -2821,7 +2793,7 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { } const [{blog}] = await Promise.all([ - context.storefront.query(ARTICLE_QUERY, { + context.get(hydrogenContext.storefront).query(ARTICLE_QUERY, { variables: {blogHandle, articleHandle}, }), // Add other queries here, so that they are loaded in parallel @@ -2832,7 +2804,7 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { } redirectIfHandleIsLocalized( - request, + url, { handle: articleHandle, data: blog.articleByHandle, @@ -2919,8 +2891,7 @@ const ARTICLE_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -2928,23 +2899,25 @@ const ARTICLE_QUERY = `#graphql Add a blog listing page with localized article previews and pagination. -##### File: [($locale).blogs.$blogHandle._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx) +##### File: [($locale).blogs.$blogHandle.\_index.tsx]()
    -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs.$blogHandle._index'; -import {Image, getPaginationVariables} from '@shopify/hydrogen'; +import { + Image, + getPaginationVariables, + hydrogenContext, +} 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.blog.title ?? ''} blog`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -2964,6 +2937,7 @@ export async function loader(args: Route.LoaderArgs) { async function loadCriticalData({ context, request, + url, params, }: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { @@ -2975,7 +2949,7 @@ async function loadCriticalData({ } const [{blog}] = await Promise.all([ - context.storefront.query(BLOGS_QUERY, { + context.get(hydrogenContext.storefront).query(BLOGS_QUERY, { variables: { blogHandle: params.blogHandle, ...paginationVariables, @@ -2988,7 +2962,7 @@ async function loadCriticalData({ throw new Response('Not found', {status: 404}); } - redirectIfHandleIsLocalized(request, {handle: params.blogHandle, data: blog}); + redirectIfHandleIsLocalized(url, {handle: params.blogHandle, data: blog}); return {blog}; } @@ -3115,8 +3089,7 @@ const BLOGS_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -3124,15 +3097,15 @@ const BLOGS_QUERY = `#graphql Add an overview page for all blogs with locale-aware navigation links. -##### File: [($locale).blogs._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx) +##### File: [($locale).blogs.\_index.tsx]()
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs._index'; -import {getPaginationVariables} from '@shopify/hydrogen'; +import {getPaginationVariables, hydrogenContext} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import type {BlogsQuery} from 'storefrontapi.generated'; @@ -3162,7 +3135,7 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { }); const [{blogs}] = await Promise.all([ - context.storefront.query(BLOGS_QUERY, { + context.get(hydrogenContext.storefront).query(BLOGS_QUERY, { variables: { ...paginationVariables, }, @@ -3239,8 +3212,7 @@ const BLOGS_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -3248,11 +3220,11 @@ const BLOGS_QUERY = `#graphql Add a collection page displaying products with locale-specific pricing and availability. -##### File: [($locale).collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx) +##### File: [($locale).collections.$handle.tsx]()
    -~~~tsx +```tsx import {redirect, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).collections.$handle'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; @@ -3261,8 +3233,10 @@ 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [ + {title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}, + ]; }; export async function loader(args: Route.LoaderArgs) { @@ -3279,7 +3253,12 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({ + context, + params, + request, + url, +}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; const paginationVariables = getPaginationVariables(request, { @@ -3304,7 +3283,7 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { } // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: collection}); + redirectIfHandleIsLocalized(url, {handle, data: collection}); return { collection, @@ -3414,8 +3393,7 @@ const COLLECTION_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -3423,15 +3401,19 @@ const COLLECTION_QUERY = `#graphql Add a collections listing page with localized collection names and images. -##### File: [($locale).collections._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx) +##### File: [($locale).collections.\_index.tsx]()
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).collections._index'; -import {getPaginationVariables, Image} from '@shopify/hydrogen'; +import { + getPaginationVariables, + Image, + hydrogenContext, +} from '@shopify/hydrogen'; import type {CollectionFragment} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; @@ -3455,7 +3437,7 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { }); const [{collections}] = await Promise.all([ - context.storefront.query(COLLECTIONS_QUERY, { + context.get(hydrogenContext.storefront).query(COLLECTIONS_QUERY, { variables: paginationVariables, }), // Add other queries here, so that they are loaded in parallel @@ -3562,8 +3544,7 @@ const COLLECTIONS_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -3571,15 +3552,13 @@ const COLLECTIONS_QUERY = `#graphql Add an "All products" page with locale-based filtering and sorting. -##### File: [($locale).collections.all.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx) +##### File: [($locale).collections.all.tsx]()
    -~~~tsx +```tsx import type {Route} from './+types/($locale).collections.all'; -import { - useLoaderData, -} from 'react-router'; +import {useLoaderData} from 'react-router'; import {getPaginationVariables, Image, Money} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {ProductItem} from '~/components/ProductItem'; @@ -3700,8 +3679,7 @@ const CATALOG_QUERY = `#graphql } ${COLLECTION_ITEM_FRAGMENT} ` as const; - -~~~ +```
    @@ -3709,19 +3687,18 @@ const CATALOG_QUERY = `#graphql Add a dynamic page route for locale-specific content pages. -##### File: [($locale).pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx) +##### File: [($locale).pages.$handle.tsx]()
    -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).pages.$handle'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; +import {hydrogenContext} from '@shopify/hydrogen'; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.page.title ?? ''}`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.page.title ?? ''}`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -3738,17 +3715,13 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({context, url, params}: Route.LoaderArgs) { if (!params.handle) { throw new Error('Missing page handle'); } const [{page}] = await Promise.all([ - context.storefront.query(PAGE_QUERY, { + context.get(hydrogenContext.storefront).query(PAGE_QUERY, { variables: { handle: params.handle, }, @@ -3760,7 +3733,7 @@ async function loadCriticalData({ throw new Response('Not Found', {status: 404}); } - redirectIfHandleIsLocalized(request, {handle: params.handle, data: page}); + redirectIfHandleIsLocalized(url, {handle: params.handle, data: page}); return { page, @@ -3808,8 +3781,7 @@ const PAGE_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -3817,25 +3789,24 @@ const PAGE_QUERY = `#graphql Add a policy page (privacy, terms, etc.) with locale-specific legal content. -##### File: [($locale).policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx) +##### File: [($locale).policies.$handle.tsx]()
    -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).policies.$handle'; import {type Shop} from '@shopify/hydrogen/storefront-api-types'; +import {hydrogenContext} from '@shopify/hydrogen'; type SelectedPolicies = keyof Pick< Shop, 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy' >; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.policy.title ?? ''}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { @@ -3848,16 +3819,18 @@ export async function loader({params, context}: Route.LoaderArgs) { (_: 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 data = await context + .get(hydrogenContext.storefront) + .query(POLICY_CONTENT_QUERY, { + variables: { + privacyPolicy: false, + shippingPolicy: false, + termsOfService: false, + refundPolicy: false, + [policyName]: true, + language: context.get(hydrogenContext.storefront).i18n?.language, + }, + }); const policy = data.shop?.[policyName]; @@ -3918,8 +3891,7 @@ const POLICY_CONTENT_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -3927,19 +3899,22 @@ const POLICY_CONTENT_QUERY = `#graphql Add a policies index page that lists all available store policies. -##### File: [($locale).policies._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx) +##### File: [($locale).policies.\_index.tsx]()
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).policies._index'; import type {PoliciesQuery, PolicyItemFragment} from 'storefrontapi.generated'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({context}: Route.LoaderArgs) { - const data: PoliciesQuery = await context.storefront.query(POLICIES_QUERY); - + const data: PoliciesQuery = await context + .get(hydrogenContext.storefront) + .query(POLICIES_QUERY); + const shopPolicies = data.shop; const policies: PolicyItemFragment[] = [ shopPolicies?.privacyPolicy, @@ -4002,8 +3977,7 @@ const POLICIES_QUERY = `#graphql } } ` as const; - -~~~ +```
    @@ -4011,14 +3985,12 @@ const POLICIES_QUERY = `#graphql Add a search results page with locale-aware product matching and predictive search. -##### File: [($locale).search.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx) +##### File: [($locale).search.tsx]()
    -~~~tsx -import { - useLoaderData, -} from 'react-router'; +```tsx +import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).search'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; import {SearchForm} from '~/components/SearchForm'; @@ -4028,7 +4000,10 @@ import { type PredictiveSearchReturn, getEmptyPredictiveSearchResult, } from '~/lib/search'; -import type {RegularSearchQuery, PredictiveSearchQuery} from 'storefrontapi.generated'; +import type { + RegularSearchQuery, + PredictiveSearchQuery, +} from 'storefrontapi.generated'; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Search`}]; @@ -4243,9 +4218,13 @@ async function regularSearch({ 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}, - }); + 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'); @@ -4407,17 +4386,18 @@ async function predictiveSearch({ 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, - { + 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( @@ -4436,86 +4416,37 @@ async function predictiveSearch({ return {type, term, result: {items, total}}; } - -~~~ +```
    -### Step 8: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - ## Deleted Files - [templates/skeleton/app/routes/account.$.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.$.tsx) -- [templates/skeleton/app/routes/account._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account._index.tsx) +- [templates/skeleton/app/routes/account.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account._index.tsx) - [templates/skeleton/app/routes/account.addresses.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.addresses.tsx) - [templates/skeleton/app/routes/account.orders.$id.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.orders.$id.tsx) -- [templates/skeleton/app/routes/account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.orders._index.tsx) +- [templates/skeleton/app/routes/account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.orders._index.tsx) - [templates/skeleton/app/routes/account.profile.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.profile.tsx) - [templates/skeleton/app/routes/account.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account.tsx) -- [templates/skeleton/app/routes/account_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) -- [templates/skeleton/app/routes/account_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.login.tsx) -- [templates/skeleton/app/routes/account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.logout.tsx) +- [templates/skeleton/app/routes/account\_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) +- [templates/skeleton/app/routes/account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.login.tsx) +- [templates/skeleton/app/routes/account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.logout.tsx) - [templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx) -- [templates/skeleton/app/routes/blogs.$blogHandle._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx) -- [templates/skeleton/app/routes/blogs._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/blogs._index.tsx) +- [templates/skeleton/app/routes/blogs.$blogHandle.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx) +- [templates/skeleton/app/routes/blogs.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/blogs._index.tsx) - [templates/skeleton/app/routes/collections.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/collections.$handle.tsx) -- [templates/skeleton/app/routes/collections._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/collections._index.tsx) +- [templates/skeleton/app/routes/collections.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/collections._index.tsx) - [templates/skeleton/app/routes/collections.all.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/collections.all.tsx) - [templates/skeleton/app/routes/pages.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/pages.$handle.tsx) - [templates/skeleton/app/routes/policies.$handle.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/policies.$handle.tsx) -- [templates/skeleton/app/routes/policies._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/policies._index.tsx) +- [templates/skeleton/app/routes/policies.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/policies._index.tsx) - [templates/skeleton/app/routes/search.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/search.tsx) ## Next steps - Test your implementation by going to your store and selecting a different -market from the country selector. + market from the country selector. - Refer to the [Shopify -Help Center](https://help.shopify.com/en/manual/markets) for -more information on how to optimize and manage your international markets. \ No newline at end of file + Help Center](https://help.shopify.com/en/manual/markets) for + more information on how to optimize and manage your international markets. diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx index 9a8af53306..cb7aa9b435 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/components/Link.tsx @@ -8,31 +8,33 @@ type BaseProps = { preservePath?: boolean; }; -type LinkVariantProps = BaseProps & LinkProps & { - variant?: never; -}; +type LinkVariantProps = BaseProps & + LinkProps & { + variant?: never; + }; -type NavLinkVariantProps = BaseProps & NavLinkProps & { - variant: 'nav'; -}; +type NavLinkVariantProps = BaseProps & + NavLinkProps & { + variant: 'nav'; + }; export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; /** * Locale-aware Link component that handles both regular and navigation links - * + * * @example * // Regular link (auto-adds current locale) * Products - * + * * @example * // Navigation link with active styles * About - * + * * @example * // Switch locale while preserving current path * Français - * + * * @example * // Link to specific locale * Canadian Products @@ -40,7 +42,7 @@ export type ExtendedLinkProps = LinkVariantProps | NavLinkVariantProps; 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('://')) { @@ -52,11 +54,11 @@ export function Link(props: ExtendedLinkProps) { } to = cleanPath(to); } - + to = useLocalizedPath(to, locale, preservePath); - + if (variant === 'nav') { return ; } return ; -} \ No newline at end of file +} diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx index 8ef791f953..0b2091fb41 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale)._index.tsx @@ -1,7 +1,7 @@ import {Await, useLoaderData} from 'react-router'; import type {Route} from './+types/($locale)._index'; import {Suspense} from 'react'; -import {Image} from '@shopify/hydrogen'; +import {Image, hydrogenContext} from '@shopify/hydrogen'; import type { FeaturedCollectionFragment, RecommendedProductsQuery, @@ -29,7 +29,7 @@ export async function loader(args: Route.LoaderArgs) { */ async function loadCriticalData({context}: Route.LoaderArgs) { const [{collections}] = await Promise.all([ - context.storefront.query(FEATURED_COLLECTION_QUERY), + context.get(hydrogenContext.storefront).query(FEATURED_COLLECTION_QUERY), // Add other queries here, so that they are loaded in parallel ]); @@ -44,7 +44,8 @@ async function loadCriticalData({context}: Route.LoaderArgs) { * 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 + const recommendedProducts = context + .get(hydrogenContext.storefront) .query(RECOMMENDED_PRODUCTS_QUERY) .catch((error: Error) => { // Log query errors, but don't throw them so the page can still render diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx index 9d03ae4eb0..e6a62e12b8 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.$.tsx @@ -1,9 +1,10 @@ import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account.$'; +import {hydrogenContext} from '@shopify/hydrogen'; // fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return redirect('/account'); } diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx index 15bad2a675..c4075b467f 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.addresses.tsx @@ -17,6 +17,7 @@ import { DELETE_ADDRESS_MUTATION, CREATE_ADDRESS_MUTATION, } from '~/graphql/customer-account/CustomerAddressMutations'; +import {hydrogenContext} from '@shopify/hydrogen'; export type ActionResponse = { addressId?: string | null; @@ -32,7 +33,7 @@ export const meta: Route.MetaFunction = () => { }; export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return {}; } diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx index cc56a90491..ec311e3241 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders.$id.tsx @@ -7,8 +7,8 @@ import type { } 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { @@ -32,19 +32,10 @@ export async function loader({params, context}: Route.LoaderArgs) { 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< diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx index 76d990aba8..febad393c6 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.orders._index.tsx @@ -1,8 +1,4 @@ -import { - useLoaderData, - useNavigation, - useSearchParams, -} from 'react-router'; +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'; diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx index b2563204b2..5c139841c1 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.profile.tsx @@ -9,6 +9,7 @@ import { useOutletContext, } from 'react-router'; import type {Route} from './+types/($locale).account.profile'; +import {hydrogenContext} from '@shopify/hydrogen'; export type ActionResponse = { error: string | null; @@ -20,7 +21,7 @@ export const meta: Route.MetaFunction = () => { }; export async function loader({context}: Route.LoaderArgs) { - context.customerAccount.handleAuthStatus(); + context.get(hydrogenContext.customerAccount).handleAuthStatus(); return {}; } diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx index 2a01c6d786..fab22e6b96 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account.tsx @@ -1,12 +1,8 @@ -import { - data as remixData, - Form, - Outlet, - useLoaderData, -} from 'react-router'; +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'; +import {hydrogenContext} from '@shopify/hydrogen'; export function shouldRevalidate() { return true; @@ -14,14 +10,11 @@ export function shouldRevalidate() { 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, - }, + const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { + variables: { + language: context.get(hydrogenContext.customerAccount).i18n.language, }, - ); + }); if (errors?.length || !data?.customer) { throw new Error('Customer not found'); diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx index 295ae6102e..aa51bb049d 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.authorize.tsx @@ -1,5 +1,6 @@ import type {Route} from './+types/($locale).account_.authorize'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({context}: Route.LoaderArgs) { - return context.customerAccount.authorize(); + return context.get(hydrogenContext.customerAccount).authorize(); } diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx index 483cf171ff..9b907b0d8b 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.login.tsx @@ -1,4 +1,5 @@ import type {Route} from './+types/($locale).account_.login'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({request, context}: Route.LoaderArgs) { const url = new URL(request.url); @@ -7,8 +8,8 @@ export async function loader({request, context}: Route.LoaderArgs) { 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, + return context.get(hydrogenContext.customerAccount).login({ + countryCode: context.get(hydrogenContext.storefront).i18n.country, acrValues, loginHint, loginHintMode, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx index 5c00e9c290..f838791763 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).account_.logout.tsx @@ -1,5 +1,6 @@ import {redirect} from 'react-router'; import type {Route} from './+types/($locale).account_.logout'; +import {hydrogenContext} from '@shopify/hydrogen'; // if we don't implement this, /account/logout will get caught by account.$.tsx to do login export async function loader() { @@ -7,5 +8,5 @@ export async function loader() { } export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); + return context.get(hydrogenContext.customerAccount).logout(); } diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx index 1a838ae388..7068d8e4db 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx @@ -1,10 +1,10 @@ import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).blogs.$blogHandle.$articleHandle'; -import {Image} from '@shopify/hydrogen'; +import {Image, hydrogenContext} from '@shopify/hydrogen'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.article.title ?? ''} article`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -21,7 +21,7 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({context, url, params}: Route.LoaderArgs) { const {blogHandle, articleHandle} = params; if (!articleHandle || !blogHandle) { @@ -29,7 +29,7 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { } const [{blog}] = await Promise.all([ - context.storefront.query(ARTICLE_QUERY, { + context.get(hydrogenContext.storefront).query(ARTICLE_QUERY, { variables: {blogHandle, articleHandle}, }), // Add other queries here, so that they are loaded in parallel @@ -40,7 +40,7 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) { } redirectIfHandleIsLocalized( - request, + url, { handle: articleHandle, data: blog.articleByHandle, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx index ed9c77265b..e2f16ac632 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx @@ -1,15 +1,17 @@ -import { - useLoaderData, -} from 'react-router'; +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 { + Image, + getPaginationVariables, + hydrogenContext, +} 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.blog.title ?? ''} blog`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -29,6 +31,7 @@ export async function loader(args: Route.LoaderArgs) { async function loadCriticalData({ context, request, + url, params, }: Route.LoaderArgs) { const paginationVariables = getPaginationVariables(request, { @@ -40,7 +43,7 @@ async function loadCriticalData({ } const [{blog}] = await Promise.all([ - context.storefront.query(BLOGS_QUERY, { + context.get(hydrogenContext.storefront).query(BLOGS_QUERY, { variables: { blogHandle: params.blogHandle, ...paginationVariables, @@ -53,7 +56,7 @@ async function loadCriticalData({ throw new Response('Not found', {status: 404}); } - redirectIfHandleIsLocalized(request, {handle: params.blogHandle, data: blog}); + redirectIfHandleIsLocalized(url, {handle: params.blogHandle, data: blog}); return {blog}; } diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx index 6e34bb7679..851a6a2bcb 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs._index.tsx @@ -1,7 +1,7 @@ import {useLoaderData} from 'react-router'; import {Link} from '~/components/Link'; import type {Route} from './+types/($locale).blogs._index'; -import {getPaginationVariables} from '@shopify/hydrogen'; +import {getPaginationVariables, hydrogenContext} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import type {BlogsQuery} from 'storefrontapi.generated'; @@ -31,7 +31,7 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { }); const [{blogs}] = await Promise.all([ - context.storefront.query(BLOGS_QUERY, { + context.get(hydrogenContext.storefront).query(BLOGS_QUERY, { variables: { ...paginationVariables, }, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx index cf62df36cd..8e81b7489d 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).cart.tsx @@ -1,8 +1,4 @@ -import { - useLoaderData, - data, - type HeadersFunction, -} from 'react-router'; +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'; diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx index 96e1394b0b..e6d67dfdd1 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.$handle.tsx @@ -6,8 +6,10 @@ 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 const meta: Route.MetaFunction = ({loaderData}) => { + return [ + {title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}, + ]; }; export async function loader(args: Route.LoaderArgs) { @@ -24,7 +26,12 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({ + context, + params, + request, + url, +}: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; const paginationVariables = getPaginationVariables(request, { @@ -49,7 +56,7 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { } // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: collection}); + redirectIfHandleIsLocalized(url, {handle, data: collection}); return { collection, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx index f76ad62de6..ddd69f9222 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections._index.tsx @@ -1,7 +1,11 @@ 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 { + getPaginationVariables, + Image, + hydrogenContext, +} from '@shopify/hydrogen'; import type {CollectionFragment} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; @@ -25,7 +29,7 @@ async function loadCriticalData({context, request}: Route.LoaderArgs) { }); const [{collections}] = await Promise.all([ - context.storefront.query(COLLECTIONS_QUERY, { + context.get(hydrogenContext.storefront).query(COLLECTIONS_QUERY, { variables: paginationVariables, }), // Add other queries here, so that they are loaded in parallel diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx index 84f66da034..d9c4defc29 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).collections.all.tsx @@ -1,7 +1,5 @@ import type {Route} from './+types/($locale).collections.all'; -import { - useLoaderData, -} from 'react-router'; +import {useLoaderData} from 'react-router'; import {getPaginationVariables, Image, Money} from '@shopify/hydrogen'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; import {ProductItem} from '~/components/ProductItem'; diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx index b3129e0e4e..bd2a629d7f 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx @@ -1,11 +1,10 @@ -import { - useLoaderData, -} from 'react-router'; +import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).pages.$handle'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; +import {hydrogenContext} from '@shopify/hydrogen'; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.page.title ?? ''}`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.page.title ?? ''}`}]; }; export async function loader(args: Route.LoaderArgs) { @@ -22,17 +21,13 @@ export async function loader(args: Route.LoaderArgs) { * 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) { +async function loadCriticalData({context, url, params}: Route.LoaderArgs) { if (!params.handle) { throw new Error('Missing page handle'); } const [{page}] = await Promise.all([ - context.storefront.query(PAGE_QUERY, { + context.get(hydrogenContext.storefront).query(PAGE_QUERY, { variables: { handle: params.handle, }, @@ -44,7 +39,7 @@ async function loadCriticalData({ throw new Response('Not Found', {status: 404}); } - redirectIfHandleIsLocalized(request, {handle: params.handle, data: page}); + redirectIfHandleIsLocalized(url, {handle: params.handle, data: page}); return { page, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx index 7375735555..dfee0fafe3 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx @@ -1,17 +1,16 @@ -import { - useLoaderData, -} from 'react-router'; +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 {hydrogenContext} from '@shopify/hydrogen'; type SelectedPolicies = keyof Pick< Shop, 'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy' >; -export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}]; +export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.policy.title ?? ''}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { @@ -24,16 +23,18 @@ export async function loader({params, context}: Route.LoaderArgs) { (_: 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 data = await context + .get(hydrogenContext.storefront) + .query(POLICY_CONTENT_QUERY, { + variables: { + privacyPolicy: false, + shippingPolicy: false, + termsOfService: false, + refundPolicy: false, + [policyName]: true, + language: context.get(hydrogenContext.storefront).i18n?.language, + }, + }); const policy = data.shop?.[policyName]; diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx index 4b12e11d67..2e00de80ae 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies._index.tsx @@ -2,10 +2,13 @@ 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'; +import {hydrogenContext} from '@shopify/hydrogen'; export async function loader({context}: Route.LoaderArgs) { - const data: PoliciesQuery = await context.storefront.query(POLICIES_QUERY); - + const data: PoliciesQuery = await context + .get(hydrogenContext.storefront) + .query(POLICIES_QUERY); + const shopPolicies = data.shop; const policies: PolicyItemFragment[] = [ shopPolicies?.privacyPolicy, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx index d1cddf684a..b4b47f420a 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx @@ -52,6 +52,7 @@ async function loadCriticalData({ context, params, request, + url, }: Route.LoaderArgs) { const {handle} = params; const {storefront} = context; @@ -77,7 +78,7 @@ async function loadCriticalData({ } // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); + redirectIfHandleIsLocalized(url, {handle, data: product}); return { product, diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx index 10d014ea1b..4ec9547779 100644 --- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx +++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).search.tsx @@ -1,6 +1,4 @@ -import { - useLoaderData, -} from 'react-router'; +import {useLoaderData} from 'react-router'; import type {Route} from './+types/($locale).search'; import {getPaginationVariables, Analytics} from '@shopify/hydrogen'; import {SearchForm} from '~/components/SearchForm'; @@ -10,7 +8,10 @@ import { type PredictiveSearchReturn, getEmptyPredictiveSearchResult, } from '~/lib/search'; -import type {RegularSearchQuery, PredictiveSearchQuery} from 'storefrontapi.generated'; +import type { + RegularSearchQuery, + PredictiveSearchQuery, +} from 'storefrontapi.generated'; export const meta: Route.MetaFunction = () => { return [{title: `Hydrogen | Search`}]; @@ -225,9 +226,13 @@ async function regularSearch({ 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}, - }); + 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'); @@ -389,17 +394,18 @@ async function predictiveSearch({ 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, - { + 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( diff --git a/cookbook/recipes/markets/patches/context.ts.c15041.patch b/cookbook/recipes/markets/patches/context.ts.c15041.patch index 70333de984..aedb1a3e28 100644 --- a/cookbook/recipes/markets/patches/context.ts.c15041.patch +++ b/cookbook/recipes/markets/patches/context.ts.c15041.patch @@ -7,9 +7,9 @@ index 692d5ae17..7373ebca2 100644 import type {CartApiQueryFragment} from 'storefrontapi.generated'; +import {getLocaleFromRequest} from './i18n'; - // Define the additional context object const additionalContext = { -@@ -45,6 +46,8 @@ + // Additional context for custom properties, CMS clients, 3P SDKs, etc. +@@ -42,6 +43,8 @@ export async function createHydrogenRouterContext( AppSession.init(request, [env.SESSION_SECRET]), ]); @@ -18,7 +18,7 @@ index 692d5ae17..7373ebca2 100644 const hydrogenContext = createHydrogenContext( { env, -@@ -52,8 +55,7 @@ +@@ -49,8 +52,7 @@ export async function createHydrogenRouterContext( cache, waitUntil, session, diff --git a/cookbook/recipes/markets/patches/package.json.8e0ff5.patch b/cookbook/recipes/markets/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/markets/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/markets/patches/root.tsx.8f1e92.patch b/cookbook/recipes/markets/patches/root.tsx.8f1e92.patch index e82bb8564b..2d1a0b1944 100644 --- a/cookbook/recipes/markets/patches/root.tsx.8f1e92.patch +++ b/cookbook/recipes/markets/patches/root.tsx.8f1e92.patch @@ -1,15 +1,15 @@ index df87425c5..97ca81746 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx -@@ -77,6 +77,7 @@ export async function loader(args: Route.LoaderArgs) { +@@ -85,6 +85,7 @@ export async function loader(args: Route.LoaderArgs) { return { ...deferredData, ...criticalData, -+ selectedLocale: args.context.storefront.i18n, ++ selectedLocale: args.context.get(hydrogenContext.storefront).i18n, publicStoreDomain: env.PUBLIC_STORE_DOMAIN, shop: getShopAnalytics({ storefront, -@@ -176,7 +177,10 @@ export default function App() { +@@ -186,7 +187,10 @@ export default function App() { shop={data.shop} consent={data.consent} > diff --git a/cookbook/recipes/markets/recipe.yaml b/cookbook/recipes/markets/recipe.yaml index cb015a4eb2..28a7d8fec0 100644 --- a/cookbook/recipes/markets/recipe.yaml +++ b/cookbook/recipes/markets/recipe.yaml @@ -98,7 +98,8 @@ ingredients: - path: templates/skeleton/app/routes/($locale).cart.tsx description: A localized cart route. - path: templates/skeleton/app/routes/($locale).collections.$handle.tsx - description: Collection page displaying products with locale-specific pricing + description: + Collection page displaying products with locale-specific pricing and availability - path: templates/skeleton/app/routes/($locale).collections._index.tsx description: Collections listing page with localized collection names and images @@ -143,28 +144,30 @@ deletedFiles: - templates/skeleton/app/routes/search.tsx steps: - type: INFO - step: "1" + step: '1' name: Add localization utilities and update core components - description: In this section, we'll create utilities to handle localization and + description: + In this section, we'll create utilities to handle localization and country selection, and update the core components to use these utilities. - type: PATCH - step: "1.1" + step: '1.1' name: Update CartLineItem with locale-aware product links description: Update cart line items to use the unified Link component for product links. diffs: - file: app/components/CartLineItem.tsx patchFile: CartLineItem.tsx.c12ba7.patch - type: PATCH - step: "1.2" + step: '1.2' name: Update AddToCartButton to use locale-aware cart route - description: Update the AddToCartButton component to post to the localized cart + description: + 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. diffs: - file: app/components/AddToCartButton.tsx patchFile: AddToCartButton.tsx.9bc3f0.patch - type: NEW_FILE - step: "1.3" + step: '1.3' name: Create a CountrySelector component with accessibility labels description: | Create a new `CountrySelector` component that allows users to select the locale from a dropdown of the supported locales. @@ -173,7 +176,7 @@ steps: ingredients: - path: templates/skeleton/app/components/CountrySelector.tsx - type: NEW_FILE - step: "1.4" + step: '1.4' name: Create a unified locale-aware Link component description: | Create a single Link component that handles both regular links and navigation links. @@ -185,7 +188,7 @@ steps: ingredients: - path: templates/skeleton/app/components/Link.tsx - type: NEW_FILE - step: "1.5" + step: '1.5' name: Create comprehensive i18n utilities description: | Create a centralized i18n module that includes: @@ -199,7 +202,7 @@ steps: ingredients: - path: templates/skeleton/app/lib/i18n.ts - type: PATCH - step: "1.6" + step: '1.6' name: Update ProductItem to use locale-aware Link description: | Replace standard react-router Link imports with the new unified Link component. @@ -208,7 +211,7 @@ steps: - file: app/components/ProductItem.tsx patchFile: ProductItem.tsx.c6506f.patch - type: PATCH - step: "1.7" + step: '1.7' name: Add the selected locale to the context description: | Detect the locale from the URL path, and add it to the HydrogenContext. @@ -216,7 +219,7 @@ steps: - file: app/lib/context.ts patchFile: context.ts.c15041.patch - type: PATCH - step: "1.8" + step: '1.8' name: Update Header with CountrySelector and locale-aware Links description: | 1. Add the `CountrySelector` component to the header navigation. @@ -227,7 +230,7 @@ steps: - file: app/components/Header.tsx patchFile: Header.tsx.00776f.patch - type: PATCH - step: "1.9" + step: '1.9' name: Add the selected locale to the root route description: | 1. Include the selected locale in the root route's loader data. @@ -238,12 +241,12 @@ steps: - file: app/root.tsx patchFile: root.tsx.8f1e92.patch - type: INFO - step: "2" + step: '2' name: Localizing the individual routes description: | In this section, we'll add localization to the individual routes using the language [dynamic segment](https://reactrouter.com/start/data/routing#optional-segments). - type: PATCH - step: "2.1" + step: '2.1' name: Update CartMain with locale-aware links description: Replace all Link imports to use the unified locale-aware `Link` component for consistent navigation. @@ -251,7 +254,7 @@ steps: - file: app/components/CartMain.tsx patchFile: CartMain.tsx.6ef433.patch - type: INFO - step: "2.2" + step: '2.2' name: Add language dynamic segment to the desired routes description: | To implement path-based localization, add a language @@ -260,7 +263,7 @@ steps: 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. - type: NEW_FILE - step: "2.3" + step: '2.3' name: Add localization to the home page description: | 1. Add the dynamic segment to the home page route. @@ -269,14 +272,14 @@ steps: - path: templates/skeleton/app/routes/($locale)._index.tsx renamedFrom: templates/skeleton/app/routes/_index.tsx - type: NEW_FILE - step: "2.4" + step: '2.4' name: Add localization to the cart page description: Add the dynamic segment to the cart page route. ingredients: - path: templates/skeleton/app/routes/($locale).cart.tsx renamedFrom: templates/skeleton/app/routes/cart.tsx - type: NEW_FILE - step: "2.5" + step: '2.5' name: Add localization to the product page description: | 1. Add the dynamic segment to the product page route. @@ -286,7 +289,7 @@ steps: - path: templates/skeleton/app/routes/($locale).products.$handle.tsx renamedFrom: templates/skeleton/app/routes/products.$handle.tsx - type: NEW_FILE - step: "2.6" + step: '2.6' name: Add a utility route to validate the locale. description: | Add a utility route in `$(locale).tsx` that will use `localeMatchesPrefix` @@ -295,136 +298,132 @@ steps: ingredients: - path: templates/skeleton/app/routes/($locale).tsx - type: NEW_FILE - step: "2.7" + step: '2.7' name: Handle unauthenticated account pages description: Add a fallback route for unauthenticated account pages with locale support. ingredients: - path: templates/skeleton/app/routes/($locale).account.$.tsx - type: NEW_FILE - step: "2.8" + step: '2.8' name: Redirect to account dashboard description: Add a localized account dashboard redirect route. ingredients: - path: templates/skeleton/app/routes/($locale).account._index.tsx - type: NEW_FILE - step: "2.9" + step: '2.9' name: Add address management description: Add a customer address management page with locale-aware forms and links. ingredients: - path: templates/skeleton/app/routes/($locale).account.addresses.tsx - type: NEW_FILE - step: "2.10" + step: '2.10' name: Show order details - description: Add an individual order details page with localized currency and + description: + Add an individual order details page with localized currency and date formatting. ingredients: - path: templates/skeleton/app/routes/($locale).account.orders.$id.tsx - type: NEW_FILE - step: "2.11" + step: '2.11' name: Display order history description: Implement customer order history listing with locale-specific pagination. ingredients: - path: templates/skeleton/app/routes/($locale).account.orders._index.tsx - type: NEW_FILE - step: "2.12" + step: '2.12' name: Build customer profile page description: Add a customer profile editing form with localized field labels. ingredients: - path: templates/skeleton/app/routes/($locale).account.profile.tsx - type: NEW_FILE - step: "2.13" + step: '2.13' name: Create account layout description: Add an account layout wrapper with locale-aware navigation tabs. ingredients: - path: templates/skeleton/app/routes/($locale).account.tsx - type: NEW_FILE - step: "2.14" + step: '2.14' name: Handle OAuth authorization description: Add an OAuth authorization callback route with locale preservation. ingredients: - path: templates/skeleton/app/routes/($locale).account_.authorize.tsx - type: NEW_FILE - step: "2.15" + step: '2.15' name: Create login redirect description: Add a customer login redirect with a locale-specific return URL. ingredients: - path: templates/skeleton/app/routes/($locale).account_.login.tsx - type: NEW_FILE - step: "2.16" + step: '2.16' name: Handle logout description: Add a logout handler that maintains locale after the user signs out. ingredients: - path: templates/skeleton/app/routes/($locale).account_.logout.tsx - type: NEW_FILE - step: "2.17" + step: '2.17' name: Show blog articles description: Add a blog article page with locale-specific content and SEO metadata. ingredients: - path: templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx - type: NEW_FILE - step: "2.18" + step: '2.18' name: List blog posts description: Add a blog listing page with localized article previews and pagination. ingredients: - path: templates/skeleton/app/routes/($locale).blogs.$blogHandle._index.tsx - type: NEW_FILE - step: "2.19" + step: '2.19' name: Display all blogs description: Add an overview page for all blogs with locale-aware navigation links. ingredients: - path: templates/skeleton/app/routes/($locale).blogs._index.tsx - type: NEW_FILE - step: "2.20" + step: '2.20' name: Show collection pages description: Add a collection page displaying products with locale-specific pricing and availability. ingredients: - path: templates/skeleton/app/routes/($locale).collections.$handle.tsx - type: NEW_FILE - step: "2.21" + step: '2.21' name: List all collections description: Add a collections listing page with localized collection names and images. ingredients: - path: templates/skeleton/app/routes/($locale).collections._index.tsx - type: NEW_FILE - step: "2.22" + step: '2.22' name: Show all products description: Add an "All products" page with locale-based filtering and sorting. ingredients: - path: templates/skeleton/app/routes/($locale).collections.all.tsx - type: NEW_FILE - step: "2.23" + step: '2.23' name: Display content pages description: Add a dynamic page route for locale-specific content pages. ingredients: - path: templates/skeleton/app/routes/($locale).pages.$handle.tsx - type: NEW_FILE - step: "2.24" + step: '2.24' name: Show policy pages - description: Add a policy page (privacy, terms, etc.) with locale-specific legal + description: + Add a policy page (privacy, terms, etc.) with locale-specific legal content. ingredients: - path: templates/skeleton/app/routes/($locale).policies.$handle.tsx - type: NEW_FILE - step: "2.25" + step: '2.25' name: List all policies description: Add a policies index page that lists all available store policies. ingredients: - path: templates/skeleton/app/routes/($locale).policies._index.tsx - type: NEW_FILE - step: "2.26" + step: '2.26' name: Build search functionality - description: Add a search results page with locale-aware product matching and + description: + Add a search results page with locale-aware product matching and predictive search. ingredients: - path: templates/skeleton/app/routes/($locale).search.tsx - - type: PATCH - step: "8" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch nextSteps: | - Test your implementation by going to your store and selecting a different market from the country selector. @@ -439,13 +438,15 @@ llms: - Help me segment my customers based on their location troubleshooting: - issue: I'm not seeing the localized content on my storefront. - solution: In your Shopify admin, make sure you've set up your store's regions + solution: + In your Shopify admin, make sure you've set up your store's regions and langauges using Shopify Markets, then check that your products are appropriately configured for each market. In your Hydrogen app, make sure you've added the dynamic segment to the desired routes and that the routes are correctly named. - issue: Some pages are showing localized content, but others aren't. - solution: Make sure you update *all* routes that need localization (not only the + solution: + Make sure you update *all* routes that need localization (not only the routes for the home page, the cart page, and the product page). See step 2.1 for details. commit: 1040066d20b52667756fd1ebffd8607602a735b4 diff --git a/cookbook/recipes/metaobjects/README.md b/cookbook/recipes/metaobjects/README.md index e8f8c3d477..1b47446460 100644 --- a/cookbook/recipes/metaobjects/README.md +++ b/cookbook/recipes/metaobjects/README.md @@ -5,6 +5,7 @@ It lets you create and manage dynamic content sections through your Shopify admi providing a flexible way to build pages with reusable components. Key features: + - Dynamic route-based content rendering - Modular section components (Hero, Featured Products, Featured Collections, Stores) - Content editing capabilities with direct links to Shopify admin @@ -28,30 +29,30 @@ to match your specific content needs. See the included `guides/metaobjects/READM _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/EditRoute.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/components/EditRoute.tsx) | Component that provides an edit link for managing metaobject-based content in development mode | -| [app/routes/stores.$name.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores.$name.tsx) | Dynamic route for displaying individual store profiles with metaobject content | -| [app/routes/stores._index.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores._index.tsx) | Index route that displays a grid of all stores from metaobjects | -| [app/sections/RouteContent.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/RouteContent.tsx) | Main component for fetching and rendering metaobject-based route content | -| [app/sections/SectionFeaturedCollections.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedCollections.tsx) | Section component for displaying featured product collections from metaobjects | -| [app/sections/SectionFeaturedProducts.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedProducts.tsx) | Section component for showcasing featured products with customizable layout | -| [app/sections/SectionHero.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionHero.tsx) | Hero banner component with image, heading, and call-to-action from metaobjects | -| [app/sections/SectionStoreProfile.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStoreProfile.tsx) | Section component that displays store details, hours, and contact information | -| [app/sections/SectionStores.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStores.tsx) | Grid layout component for displaying multiple store locations | -| [app/sections/Sections.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/Sections.tsx) | Dynamic section renderer that maps metaobject types to React components | -| [app/utils/parseSection.ts](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/utils/parseSection.ts) | Utility function for parsing and transforming metaobject field data | -| [guides/metaobjects/README.md](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/README.md) | Comprehensive setup guide for creating metaobject definitions in Shopify admin | -| [guides/metaobjects/images/definition_link.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) | Screenshot showing Link metaobject field configuration | -| [guides/metaobjects/images/definition_section_featured_collections.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) | Screenshot of Featured Collections section metaobject definition | -| [guides/metaobjects/images/definition_section_featured_products.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) | Screenshot of Featured Products section metaobject definition | -| [guides/metaobjects/images/definition_section_hero.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) | Screenshot of Hero section metaobject definition with image and text fields | -| [guides/metaobjects/images/definition_section_rich_text.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) | Screenshot of Rich Text section metaobject definition | -| [guides/metaobjects/images/definition_section_store_profile.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) | Screenshot of Store Profile section metaobject definition | -| [guides/metaobjects/images/definition_section_stores_grid.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) | Screenshot of Stores Grid section metaobject definition | -| [guides/metaobjects/images/definition_store.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) | Screenshot of Store metaobject definition with location and contact fields | -| [guides/metaobjects/images/definitions_list.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) | Screenshot showing list of all metaobject definitions in Shopify admin | -| [guides/metaobjects/images/definiton_route.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) | Screenshot of Route metaobject definition with Sections reference field | +| File | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [app/components/EditRoute.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/components/EditRoute.tsx) | Component that provides an edit link for managing metaobject-based content in development mode | +| [app/routes/stores.$name.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores.$name.tsx) | Dynamic route for displaying individual store profiles with metaobject content | +| [app/routes/stores.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores._index.tsx) | Index route that displays a grid of all stores from metaobjects | +| [app/sections/RouteContent.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/RouteContent.tsx) | Main component for fetching and rendering metaobject-based route content | +| [app/sections/SectionFeaturedCollections.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedCollections.tsx) | Section component for displaying featured product collections from metaobjects | +| [app/sections/SectionFeaturedProducts.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedProducts.tsx) | Section component for showcasing featured products with customizable layout | +| [app/sections/SectionHero.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionHero.tsx) | Hero banner component with image, heading, and call-to-action from metaobjects | +| [app/sections/SectionStoreProfile.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStoreProfile.tsx) | Section component that displays store details, hours, and contact information | +| [app/sections/SectionStores.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStores.tsx) | Grid layout component for displaying multiple store locations | +| [app/sections/Sections.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/Sections.tsx) | Dynamic section renderer that maps metaobject types to React components | +| [app/utils/parseSection.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/utils/parseSection.ts) | Utility function for parsing and transforming metaobject field data | +| [guides/metaobjects/README.md](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/README.md) | Comprehensive setup guide for creating metaobject definitions in Shopify admin | +| [guides/metaobjects/images/definition_link.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) | Screenshot showing Link metaobject field configuration | +| [guides/metaobjects/images/definition_section_featured_collections.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) | Screenshot of Featured Collections section metaobject definition | +| [guides/metaobjects/images/definition_section_featured_products.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) | Screenshot of Featured Products section metaobject definition | +| [guides/metaobjects/images/definition_section_hero.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) | Screenshot of Hero section metaobject definition with image and text fields | +| [guides/metaobjects/images/definition_section_rich_text.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) | Screenshot of Rich Text section metaobject definition | +| [guides/metaobjects/images/definition_section_store_profile.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) | Screenshot of Store Profile section metaobject definition | +| [guides/metaobjects/images/definition_section_stores_grid.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) | Screenshot of Stores Grid section metaobject definition | +| [guides/metaobjects/images/definition_store.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) | Screenshot of Store metaobject definition with location and contact fields | +| [guides/metaobjects/images/definitions_list.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) | Screenshot showing list of all metaobject definitions in Shopify admin | +| [guides/metaobjects/images/definiton_route.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) | Screenshot of Route metaobject definition with Sections reference field | ## Steps @@ -59,25 +60,25 @@ _New files added to the template by this recipe._ Update the README file with metaobjects CMS documentation and an architecture overview. -#### File: [README.md](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/templates/skeleton/README.md) +#### File: [README.md](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/README.md)
    -~~~diff +````diff index c584e537..927d99f1 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,6 +1,8 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Metaobjects as CMS - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates how to use Shopify Metaobjects as a content management system (CMS). Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates how to use Shopify Metaobjects as a content management system (CMS). Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to create a flexible, section-based content architecture using Shopify's native Metaobjects, allowing merchants to manage content directly from the Shopify admin without external CMS dependencies. - + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,18 +18,60 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -108,19 +109,19 @@ index c584e537..927d99f1 100644 +- **Type-safe**: Full TypeScript support with generated types +- **Merchant-friendly**: Content managed directly in Shopify admin +- **Extensible**: Easy to add new section types - + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- Shopify store with Metaobjects enabled +- Metaobject definitions created in Shopify admin - + ```bash npm create @shopify/hydrogen@latest ``` - + +## Setting up Metaobjects + +1. **Create Metaobject definitions** in your Shopify admin: @@ -138,12 +139,12 @@ index c584e537..927d99f1 100644 + - Sections component handles dynamic rendering based on type + ## Building for production - + ```bash @@ -40,6 +84,21 @@ npm run build npm run dev ``` - + +## Creating New Sections + +1. Define the Metaobject in Shopify admin @@ -160,11 +161,11 @@ index c584e537..927d99f1 100644 +``` + ## Setup for using Customer Account API (`/account` section) - + -Follow step 1 and 2 of +Follow step 1 and 2 of \ No newline at end of file -~~~ +````
    @@ -172,13 +173,13 @@ index c584e537..927d99f1 100644 Add RECOMMENDED_PRODUCT_FRAGMENT for displaying product collections in metaobject sections. -#### File: [app/lib/fragments.ts](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/templates/skeleton/app/lib/fragments.ts) +#### File: [app/lib/fragments.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/lib/fragments.ts) -~~~diff -index cf35c25e..99874574 100644 +```diff +index f6b6b2a3..490f8daf 100644 --- a/templates/skeleton/app/lib/fragments.ts +++ b/templates/skeleton/app/lib/fragments.ts -@@ -232,3 +232,25 @@ export const FOOTER_QUERY = `#graphql +@@ -240,3 +240,25 @@ export const FOOTER_QUERY = `#graphql } ${MENU_FRAGMENT} ` as const; @@ -204,17 +205,17 @@ index cf35c25e..99874574 100644 + } + } +` as const; -~~~ +``` ### Step 3: Create edit route component Add the edit route component for managing metaobject-based content in development. -#### File: [EditRoute.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/components/EditRoute.tsx) +#### File: [EditRoute.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/components/EditRoute.tsx)
    -~~~tsx +```tsx import {useState, useEffect} from 'react'; import {Link, useMatches} from 'react-router'; @@ -263,8 +264,7 @@ export function EditRoute({routeId}: {routeId: string}) { ); } - -~~~ +```
    @@ -272,9 +272,9 @@ export function EditRoute({routeId}: {routeId: string}) { Expose the public store subdomain for metaobject queries and content management. -#### File: [app/root.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/templates/skeleton/app/root.tsx) +#### File: [app/root.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/root.tsx) -~~~diff +```diff index df87425c..12434050 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -283,20 +283,20 @@ index df87425c..12434050 100644 language: args.context.storefront.i18n.language, }, + // @description Add public store subdomain for metaobjects -+ publictoreSubdomain: args.context.env.PUBLIC_STORE_DOMAIN, ++ publicStoreSubdomain: args.context.env.PUBLIC_STORE_DOMAIN, }; } -~~~ +``` ### Step 5: Build store profile route Add a dynamic store profile route for displaying store-specific metaobject content. -#### File: [stores.$name.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores.$name.tsx) +#### File: [stores.$name.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores.$name.tsx)
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/stores.$name'; @@ -324,7 +324,7 @@ export async function loader(args: Route.LoaderArgs) { async function loadCriticalData({context, params}: Route.LoaderArgs) { const {storefront} = context; const {name} = params; - + // 2. Query for the route's content metaobject const [{route}] = await Promise.all([ storefront.query(ROUTE_CONTENT_QUERY, { @@ -355,8 +355,7 @@ export default function Store() { ); } - -~~~ +```
    @@ -364,20 +363,16 @@ export default function Store() { Integrate the `RouteContent` component to render metaobject sections on the homepage. -#### File: [app/routes/_index.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/templates/skeleton/app/routes/_index.tsx) +#### File: [app/routes/\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/app/routes/_index.tsx)
    -~~~diff -index 28102dbe..27dbb95c 100644 +```diff +index 237edc91..4502e979 100644 --- a/templates/skeleton/app/routes/_index.tsx +++ b/templates/skeleton/app/routes/_index.tsx -@@ -1,19 +1,11 @@ --import { -- Await, -- useLoaderData, -- Link, --} from 'react-router'; +@@ -1,16 +1,11 @@ +-import {Await, useLoaderData, Link} from 'react-router'; +import {useLoaderData} from 'react-router'; import type {Route} from './+types/_index'; -import {Suspense} from 'react'; @@ -387,17 +382,18 @@ index 28102dbe..27dbb95c 100644 - RecommendedProductsQuery, -} from 'storefrontapi.generated'; -import {ProductItem} from '~/components/ProductItem'; +-import {MockShopNotice} from '~/components/MockShopNotice'; + +// @description Add metaobject content imports +import {ROUTE_CONTENT_QUERY, RouteContent} from '~/sections/RouteContent'; - + export const meta: Route.MetaFunction = () => { - return [{title: 'Hydrogen | Home'}]; + return [{title: 'Hydrogen Metaobject | Home'}]; }; - + export async function loader(args: Route.LoaderArgs) { -@@ -31,14 +23,18 @@ export async function loader(args: Route.LoaderArgs) { +@@ -28,150 +23,37 @@ export async function loader(args: Route.LoaderArgs) { * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. */ async function loadCriticalData({context}: Route.LoaderArgs) { @@ -413,15 +409,18 @@ index 28102dbe..27dbb95c 100644 + }), // Add other queries here, so that they are loaded in parallel ]); - + - return { +- isShopLinked: Boolean(context.env.PUBLIC_STORE_DOMAIN), - featuredCollection: collections.nodes[0], - }; + return {route}; } - + /** -@@ -47,125 +43,17 @@ async function loadCriticalData({context}: Route.LoaderArgs) { + * 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. ++ * fetched after the initial page load. If it's unavailable, the page should still render * Make sure to not throw any errors here, as it will cause the page to 500. */ function loadDeferredData({context}: Route.LoaderArgs) { @@ -439,13 +438,14 @@ index 28102dbe..27dbb95c 100644 + // No deferred data for this route + return {}; } - + export default function Homepage() { - const data = useLoaderData(); + const {route} = useLoaderData(); + return (
    +- {data.isShopLinked ? null : } - - + {/* @description Render the route's content sections */} @@ -468,7 +468,11 @@ index 28102dbe..27dbb95c 100644 - > - {image && ( -
    -- +- {image.altText -
    - )} -

    {collection.title}

    @@ -482,8 +486,11 @@ index 28102dbe..27dbb95c 100644 - products: Promise; -}) { - return ( --
    --

    Recommended Products

    +-
    +- - Loading...
    }> - - {(response) => ( @@ -498,7 +505,7 @@ index 28102dbe..27dbb95c 100644 - - -
    --
    +-
  • - ); -} - @@ -553,7 +560,7 @@ index 28102dbe..27dbb95c 100644 - } - } -` as const; -~~~ +``` @@ -561,11 +568,11 @@ index 28102dbe..27dbb95c 100644 Add a store listing page that shows all stores from metaobjects with a grid layout. -#### File: [stores._index.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores._index.tsx) +#### File: [stores.\_index.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/routes/stores._index.tsx)
    -~~~tsx +```tsx import {useLoaderData} from 'react-router'; import type {Route} from './+types/stores._index'; @@ -625,8 +632,7 @@ export default function Stores() { ); } - -~~~ +```
    @@ -634,34 +640,30 @@ export default function Stores() { Add Slate dependencies for rich text editing in metaobject sections. -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/templates/skeleton/package.json) +#### File: [package.json](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/templates/skeleton/package.json) -~~~diff -index e9ebd1d3..1731cce2 100644 +```diff +index e971ba7e..f2c3af10 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json -@@ -21,7 +21,9 @@ - "react": "18.3.1", - "react-dom": "18.3.1", - "react-router": "7.9.2", -- "react-router-dom": "7.9.2" -+ "react-router-dom": "7.9.2", +@@ -21,3 +21,5 @@ + "react": "catalog:", + "react-dom": "catalog:", +- "react-router": "8.0.1" ++ "react-router": "8.0.1", + "slate": "^0.101.4", + "slate-react": "^0.101.3" - }, - "devDependencies": { - "@eslint/compat": "^1.2.5", -~~~ +``` ### Step 9: Create route content component Add the main component for fetching and rendering metaobject-based route content. -#### File: [RouteContent.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/RouteContent.tsx) +#### File: [RouteContent.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/RouteContent.tsx)
    -~~~tsx +```tsx import {SECTIONS_FRAGMENT, Sections} from '~/sections/Sections'; import {EditRoute} from '~/components/EditRoute'; @@ -696,8 +698,7 @@ export const ROUTE_CONTENT_QUERY = `#graphql } ${SECTIONS_FRAGMENT} `; - -~~~ +```
    @@ -705,11 +706,11 @@ export const ROUTE_CONTENT_QUERY = `#graphql Add a section component for displaying featured product collections from metaobjects. -#### File: [SectionFeaturedCollections.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedCollections.tsx) +#### File: [SectionFeaturedCollections.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedCollections.tsx)
    -~~~tsx +```tsx import type { SectionFeaturedCollectionsFragment, FeaturedCollectionImageFragment, @@ -797,8 +798,7 @@ export const SECTION_FEATURED_COLLECTIONS_FRAGMENT = `#graphql } ${FEATURED_COLLECTION_FRAGMENT} `; - -~~~ +```
    @@ -806,11 +806,11 @@ export const SECTION_FEATURED_COLLECTIONS_FRAGMENT = `#graphql Add a section component for showcasing featured products with a customizable layout. -#### File: [SectionFeaturedProducts.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedProducts.tsx) +#### File: [SectionFeaturedProducts.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionFeaturedProducts.tsx)
    -~~~tsx +```tsx import {Money, Image} from '@shopify/hydrogen'; import {Link} from 'react-router'; import type {SectionFeaturedProductsFragment} from 'storefrontapi.generated'; @@ -914,8 +914,7 @@ export const SECTION_FEATURED_PRODUCTS_FRAGMENT = `#graphql } ${FEATURED_PRODUCT_FRAGMENT} `; - -~~~ +```
    @@ -923,11 +922,11 @@ export const SECTION_FEATURED_PRODUCTS_FRAGMENT = `#graphql Add a hero banner section with an image, heading, and call-to-action from metaobjects. -#### File: [SectionHero.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionHero.tsx) +#### File: [SectionHero.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionHero.tsx)
    -~~~tsx +```tsx import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseSection} from '~/utils/parseSection'; import {Link} from 'react-router'; @@ -951,6 +950,7 @@ export function SectionHero(props: SectionHeroFragment) { return (
    @@ -1061,11 +1060,11 @@ export const SECTION_HERO_FRAGMENT = `#graphql Add a store profile section that displays store details, hours, and contact information. -#### File: [SectionStoreProfile.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStoreProfile.tsx) +#### File: [SectionStoreProfile.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStoreProfile.tsx)
    -~~~tsx +```tsx import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseSection} from '~/utils/parseSection'; import {Link} from 'react-router'; @@ -1086,7 +1085,7 @@ export function SectionStoreProfile(props: SectionStoreProfileFragment) { const {image, heading, description, hours, address} = section.store; return ( -
    +
    Back to Stores

    @@ -1176,8 +1175,7 @@ export const SECTION_STORE_PROFILE_FRAGMENT = `#graphql } ${STORE_PROFILE_FRAGMENT} `; - -~~~ +```
    @@ -1185,11 +1183,11 @@ export const SECTION_STORE_PROFILE_FRAGMENT = `#graphql Add a grid layout section for displaying multiple store locations from metaobjects. -#### File: [SectionStores.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStores.tsx) +#### File: [SectionStores.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/SectionStores.tsx)
    -~~~tsx +```tsx import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseSection} from '~/utils/parseSection'; import {Link} from 'react-router'; @@ -1207,7 +1205,7 @@ export function SectionStores(props: SectionStoresFragment) { const {heading, stores} = section; return ( -
    +
    {heading?.value &&

    {heading.value}

    }
    @@ -1306,11 +1303,11 @@ export const SECTION_STORES_FRAGMENT = `#graphql Add a dynamic section renderer that maps metaobject types to React components. -#### File: [Sections.tsx](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/Sections.tsx) +#### File: [Sections.tsx](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/sections/Sections.tsx)
    -~~~tsx +```tsx import {SECTION_HERO_FRAGMENT, SectionHero} from '~/sections/SectionHero'; import { SECTION_FEATURED_PRODUCTS_FRAGMENT, @@ -1330,7 +1327,7 @@ import type {SectionsFragment} from 'storefrontapi.generated'; export function Sections({sections}: {sections: SectionsFragment}) { return ( -
    +
    {sections?.references?.nodes.map((section) => { switch (section.type) { case 'section_hero': @@ -1380,8 +1377,7 @@ export const SECTIONS_FRAGMENT = `#graphql ${SECTION_STORES_FRAGMENT} ${SECTION_STORE_PROFILE_FRAGMENT} `; - -~~~ +```
    @@ -1389,11 +1385,11 @@ export const SECTIONS_FRAGMENT = `#graphql Add a utility function for parsing and transforming metaobject field data. -#### File: [parseSection.ts](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/utils/parseSection.ts) +#### File: [parseSection.ts](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/app/utils/parseSection.ts)
    -~~~ts +```ts import type {ParsedMetafields} from '@shopify/hydrogen'; import {parseMetafield} from '@shopify/hydrogen'; @@ -1526,8 +1522,7 @@ function liftEach>( return lift(result, keyToLift); }, obj) as LiftEach; } - -~~~ +```
    @@ -1535,11 +1530,11 @@ function liftEach>( A comprehensive guide for setting up metaobject definitions in the Shopify admin. -#### File: [README.md](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/README.md) +#### File: [README.md](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/README.md)
    -~~~md +````md # Metaobjects Overview This document describes the high-level content architecture and metaobject definitions @@ -1809,8 +1804,7 @@ export const SECTIONS_FRAGMENT = `#graphql ${SECTION_EXAMPLE_FRAGMENT} `; ``` - -~~~ +````
    @@ -1818,7 +1812,7 @@ export const SECTIONS_FRAGMENT = `#graphql A screenshot showing the Link metaobject field configuration. -#### File: [definition_link.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) +#### File: [definition_link.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) ![templates/skeleton/guides/metaobjects/images/definition_link.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_link.png) @@ -1826,7 +1820,7 @@ A screenshot showing the Link metaobject field configuration. A screenshot of a "Featured Collections" section metaobject definition. -#### File: [definition_section_featured_collections.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) +#### File: [definition_section_featured_collections.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) ![templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_collections.png) @@ -1834,7 +1828,7 @@ A screenshot of a "Featured Collections" section metaobject definition. A screenshot of a "Featured Products" section metaobject definition. -#### File: [definition_section_featured_products.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) +#### File: [definition_section_featured_products.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) ![templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_featured_products.png) @@ -1842,7 +1836,7 @@ A screenshot of a "Featured Products" section metaobject definition. A screenshot of a Hero section metaobject definition with image and text fields. -#### File: [definition_section_hero.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) +#### File: [definition_section_hero.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) ![templates/skeleton/guides/metaobjects/images/definition_section_hero.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_hero.png) @@ -1850,7 +1844,7 @@ A screenshot of a Hero section metaobject definition with image and text fields. A screenshot of a "Richtext" section metaobject definition. -#### File: [definition_section_rich_text.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) +#### File: [definition_section_rich_text.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) ![templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_rich_text.png) @@ -1858,7 +1852,7 @@ A screenshot of a "Richtext" section metaobject definition. A screenshot of a "Store Profile" section metaobject definition. -#### File: [definition_section_store_profile.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) +#### File: [definition_section_store_profile.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) ![templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_store_profile.png) @@ -1866,7 +1860,7 @@ A screenshot of a "Store Profile" section metaobject definition. A screenshot of a "Stores Grid" section metaobject definition. -#### File: [definition_section_stores_grid.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) +#### File: [definition_section_stores_grid.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) ![templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_section_stores_grid.png) @@ -1874,7 +1868,7 @@ A screenshot of a "Stores Grid" section metaobject definition. A screenshot of a "Store" metaobject definition with location and contact fields. -#### File: [definition_store.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) +#### File: [definition_store.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) ![templates/skeleton/guides/metaobjects/images/definition_store.png](ingredients/templates/skeleton/guides/metaobjects/images/definition_store.png) @@ -1882,7 +1876,7 @@ A screenshot of a "Store" metaobject definition with location and contact fields A screenshot showing a list of all metaobject definitions in the Shopify admin. -#### File: [definitions_list.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) +#### File: [definitions_list.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) ![templates/skeleton/guides/metaobjects/images/definitions_list.png](ingredients/templates/skeleton/guides/metaobjects/images/definitions_list.png) @@ -1890,6 +1884,6 @@ A screenshot showing a list of all metaobject definitions in the Shopify admin. A screenshot of a "Route" metaobject definition with a "Sections" reference field. -#### File: [definiton_route.png](https://github.com/Shopify/hydrogen/blob/4f5db289f8a9beb5c46dda9416a7ae8151f7e08e/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) +#### File: [definiton_route.png](https://github.com/Shopify/hydrogen/blob/d27e9178195e76ed6a3ead38b87788677bb449cd/cookbook/recipes/metaobjects/ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) -![templates/skeleton/guides/metaobjects/images/definiton_route.png](ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) \ No newline at end of file +![templates/skeleton/guides/metaobjects/images/definiton_route.png](ingredients/templates/skeleton/guides/metaobjects/images/definiton_route.png) diff --git a/cookbook/recipes/metaobjects/patches/README.md.a0354d.patch b/cookbook/recipes/metaobjects/patches/README.md.a0354d.patch index 636706387a..abe2c57bfe 100644 --- a/cookbook/recipes/metaobjects/patches/README.md.a0354d.patch +++ b/cookbook/recipes/metaobjects/patches/README.md.a0354d.patch @@ -5,13 +5,13 @@ index c584e537..927d99f1 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Metaobjects as CMS --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+This Hydrogen template demonstrates how to use Shopify Metaobjects as a content management system (CMS). Hydrogen is Shopify's stack for headless commerce, designed to work with [Remix](https://remix.run/), Shopify's full stack web framework. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++This Hydrogen template demonstrates how to use Shopify Metaobjects as a content management system (CMS). Hydrogen is Shopify's stack for headless commerce, designed to work with [React Router](https://reactrouter.com/), the full stack web framework. + +This template shows how to create a flexible, section-based content architecture using Shopify's native Metaobjects, allowing merchants to manage content directly from the Shopify admin without external CMS dependencies. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -16,18 +18,60 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - Prettier - GraphQL generator @@ -47,7 +47,7 @@ index c584e537..927d99f1 100644 **Requirements:** - - Node.js version 18.0.0 or higher + - Node.js version 22.22.0 or higher +- Shopify store with Metaobjects enabled +- Metaobject definitions created in Shopify admin diff --git a/cookbook/recipes/metaobjects/patches/package.json.acbf33.patch b/cookbook/recipes/metaobjects/patches/package.json.acbf33.patch index ebc779b262..78102d56b2 100644 --- a/cookbook/recipes/metaobjects/patches/package.json.acbf33.patch +++ b/cookbook/recipes/metaobjects/patches/package.json.acbf33.patch @@ -1,14 +1,10 @@ index e971ba7e..f2c3af10 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json -@@ -21,7 +21,9 @@ +@@ -21,3 +21,5 @@ "react": "catalog:", "react-dom": "catalog:", - "react-router": "7.16.0", -- "react-router-dom": "7.16.0" -+ "react-router-dom": "7.16.0", +- "react-router": "8.0.1" ++ "react-router": "8.0.1", + "slate": "^0.101.4", + "slate-react": "^0.101.3" - }, - "devDependencies": { - "@eslint/compat": "^1.2.5", diff --git a/cookbook/recipes/multipass/README.md b/cookbook/recipes/multipass/README.md index 05f940e09b..98aa16c97f 100644 --- a/cookbook/recipes/multipass/README.md +++ b/cookbook/recipes/multipass/README.md @@ -6,6 +6,7 @@ state across the storefront and checkout process. This is particularly useful fo integrate with external authentication systems or maintain customer sessions across different platforms. Key features: + - Converts all customer account routes from Customer Account API to Storefront API - Implements session-based authentication with customer access tokens - Adds Multipass checkout button for seamless checkout experience @@ -28,23 +29,23 @@ Key features: - Shopify Plus subscription for Multipass functionality - PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET environment variable must be set -- React Router 7.9.x or higher +- React Router framework mode ## Ingredients _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/MultipassCheckoutButton.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/components/MultipassCheckoutButton.tsx) | Checkout button that generates multipass tokens for seamless cart-to-checkout | -| [app/lib/multipass/multipass.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts) | Core multipass encryption and token generation utilities | -| [app/lib/multipass/multipassify.server.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts) | Server-side multipass token creation with ESM-compatible snake_case | -| [app/lib/multipass/types.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/types.ts) | TypeScript types for multipass customer data and tokens | -| [app/routes/account_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) | Customer account activation flow with email confirmation | -| [app/routes/account_.login.multipass.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx) | Multipass login handler that processes encrypted tokens | -| [app/routes/account_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx) | Password recovery form for requesting reset emails | -| [app/routes/account_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx) | Customer registration form with Storefront API | -| [app/routes/account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) | Password reset confirmation with token validation | +| File | Description | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [app/components/MultipassCheckoutButton.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/components/MultipassCheckoutButton.tsx) | Checkout button that generates multipass tokens for seamless cart-to-checkout | +| [app/lib/multipass/multipass.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts) | Core multipass encryption and token generation utilities | +| [app/lib/multipass/multipassify.server.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts) | Server-side multipass token creation with ESM-compatible snake_case | +| [app/lib/multipass/types.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/types.ts) | TypeScript types for multipass customer data and tokens | +| [app/routes/account\_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) | Customer account activation flow with email confirmation | +| [app/routes/account\_.login.multipass.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx) | Multipass login handler that processes encrypted tokens | +| [app/routes/account\_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx) | Password recovery form for requesting reset emails | +| [app/routes/account\_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx) | Customer registration form with Storefront API | +| [app/routes/account\_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) | Password reset confirmation with token validation | ## Steps @@ -56,24 +57,24 @@ Update README with multipass authentication documentation
    -~~~diff +````diff index c584e5370..e8e03b96c 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,13 +1,15 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Multipass - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) +[Learn about Multipass](https://shopify.dev/docs/api/multipass) - + ## What's included - --- Remix + +-- React Router +### Core Hydrogen Stack +- React Router - Hydrogen @@ -82,7 +83,7 @@ index c584e5370..e8e03b96c 100644 @@ -18,11 +20,18 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - TypeScript and JavaScript flavors - Minimal setup of components and routes - + +### Multipass Authentication (Shopify Plus) +- Customer session persistence through checkout +- Storefront API-based authentication (not Customer Account API) @@ -90,21 +91,21 @@ index c584e5370..e8e03b96c 100644 +- Automatic fallback for non-Plus stores + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- Shopify store (Shopify Plus for Multipass features) - + ```bash npm create @shopify/hydrogen@latest @@ -40,6 +49,88 @@ npm run build npm run dev ``` - + -## Setup for using Customer Account API (`/account` section) +## Multipass Setup (Shopify Plus only) - + -Follow step 1 and 2 of +### Requirements + @@ -145,7 +146,7 @@ index c584e5370..e8e03b96c 100644 + +### Account Routes +- `/account/login` - Customer login -+- `/account/register` - New customer registration ++- `/account/register` - New customer registration +- `/account/logout` - Logout +- `/account/recover` - Password recovery +- `/account/reset/:id/:token` - Password reset @@ -188,8 +189,8 @@ index c584e5370..e8e03b96c 100644 +- [Hydrogen Documentation](https://shopify.dev/custom-storefronts/hydrogen) +- [Multipass Documentation](https://shopify.dev/docs/api/multipass) +- [Storefront API Authentication](https://shopify.dev/docs/api/storefront/authentication) -+- [Remix Documentation](https://remix.run/docs) -~~~ ++- [React Router Documentation](https://reactrouter.com/) +````
    @@ -201,7 +202,7 @@ Add checkout button component that generates multipass tokens
    -~~~tsx +```tsx import React, {useCallback} from 'react'; import {multipass} from '~/lib/multipass/multipass'; @@ -240,7 +241,7 @@ export function MultipassCheckoutButton(props: MultipassCheckoutButtonProps) { return ; } -~~~ +```
    @@ -250,14 +251,14 @@ Add multipass checkout button to cart summary #### File: [app/components/CartSummary.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/CartSummary.tsx) -~~~diff +```diff index 15e8a3c96..7a4380dc6 100644 --- a/templates/skeleton/app/components/CartSummary.tsx +++ b/templates/skeleton/app/components/CartSummary.tsx @@ -4,6 +4,9 @@ import {CartForm, Money, type OptimisticCart} from '@shopify/hydrogen'; import {useEffect, useId, useRef, useState} from 'react'; import {useFetcher} from 'react-router'; - + +// @description Import MultipassCheckoutButton for Shopify Plus multipass checkout +import {MultipassCheckoutButton} from '~/components/MultipassCheckoutButton'; + @@ -265,7 +266,7 @@ index 15e8a3c96..7a4380dc6 100644 cart: OptimisticCart; layout: CartLayout; @@ -51,9 +54,10 @@ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) { - + return ( ); -~~~ +``` ### Step 4: app/lib/multipass/multipass.ts @@ -287,7 +288,7 @@ Core multipass encryption and token generation utilities
    -~~~ts +```ts import type { MultipassResponse, MultipassOptions, @@ -362,7 +363,7 @@ export async function multipass( return {url: null, token: null, error: message}; } } -~~~ +```
    @@ -374,7 +375,7 @@ Add session validation and token refresh logic
    -~~~diff +```diff index df87425c5..a2c2acc8d 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -398,7 +399,7 @@ index df87425c5..a2c2acc8d 100644 @@ -110,7 +121,14 @@ async function loadCriticalData({context}: Route.LoaderArgs) { // Add other queries here, so that they are loaded in parallel ]); - + - return {header}; + // @description Validate customer authentication for multipass + const customerAccessToken = await context.session.get('customerAccessToken'); @@ -409,7 +410,7 @@ index df87425c5..a2c2acc8d 100644 + + return {header, isLoggedIn: Promise.resolve(isLoggedIn)}; } - + /** @@ -207,3 +225,24 @@ export function ErrorBoundary() {
    @@ -436,7 +437,7 @@ index df87425c5..a2c2acc8d 100644 + + return true; +} -~~~ +```
    @@ -448,24 +449,27 @@ Server-side multipass token generation with ESM-compatible snake_case
    -~~~ts +```ts import CryptoJS from 'crypto-js'; import type {MultipassCustomer} from './types'; // Simple snake_case converter for ESM/Worker runtime function toSnakeCase(str: string): string { - return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''); + return str + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); } function snakecaseKeys(obj: any): any { if (typeof obj !== 'object' || obj === null) { return obj; } - + if (Array.isArray(obj)) { return obj.map(snakecaseKeys); } - + const result: any = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { @@ -624,7 +628,7 @@ export class Multipassify { return customer; } } -~~~ +```
    @@ -634,14 +638,14 @@ Convert catch-all account route to use Storefront API #### File: [app/routes/account.$.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.$.tsx) -~~~diff +```diff index f718f607a..6a8a24520 100644 --- a/templates/skeleton/app/routes/account.$.tsx +++ b/templates/skeleton/app/routes/account.$.tsx @@ -1,9 +1,9 @@ import {redirect} from 'react-router'; import type {Route} from './+types/account.$'; - + -// fallback wild card for all unauthenticated routes in account section export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); @@ -652,7 +656,7 @@ index f718f607a..6a8a24520 100644 + } + return redirect('/account/login'); } -~~~ +``` ### Step 8: app/lib/multipass/types.ts @@ -662,7 +666,7 @@ TypeScript types for multipass functionality
    -~~~ts +```ts /* multipass and multipassify types */ @@ -732,19 +736,19 @@ export interface MultipassTokenResponseType { }; error: string | null; } -~~~ +```
    -### Step 9: app/routes/account_.activate.$id.$activationToken.tsx +### Step 9: app/routes/account\_.activate.$id.$activationToken.tsx Add customer account activation route -#### File: [account_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) +#### File: [account\_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx)
    -~~~tsx +```tsx import {Form, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.activate.$id.$activationToken'; @@ -897,7 +901,7 @@ const CUSTOMER_ACTIVATE_MUTATION = `#graphql } } ` as const; -~~~ +```
    @@ -909,7 +913,7 @@ Convert addresses management to use Storefront API
    -~~~diff +```diff index e0f97e10f..d36d1d914 100644 --- a/templates/skeleton/app/routes/account.addresses.tsx +++ b/templates/skeleton/app/routes/account.addresses.tsx @@ -937,12 +941,12 @@ index e0f97e10f..d36d1d914 100644 - DELETE_ADDRESS_MUTATION, - CREATE_ADDRESS_MUTATION, -} from '~/graphql/customer-account/CustomerAddressMutations'; - + export type ActionResponse = { addressId?: string | null; @@ -32,13 +24,16 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -953,17 +957,17 @@ index e0f97e10f..d36d1d914 100644 + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {storefront, session} = context; - + try { const form = await request.formData(); @@ -50,31 +45,26 @@ export async function action({request, context}: Route.ActionArgs) { 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) { @@ -978,7 +982,7 @@ index e0f97e10f..d36d1d914 100644 + return data({error: {[addressId]: 'Unauthorized'}}, {status: 401}); } + const {accessToken} = customerAccessToken; - + const defaultAddress = form.has('defaultAddress') ? String(form.get('defaultAddress')) === 'on' - : false; @@ -1001,7 +1005,7 @@ index e0f97e10f..d36d1d914 100644 + 'province', 'zip', ]; - + @@ -89,143 +79,119 @@ export async function action({request, context}: Route.ActionArgs) { case 'POST': { // handle new address creation @@ -1018,14 +1022,14 @@ index e0f97e10f..d36d1d914 100644 + variables: {customerAccessToken: accessToken, address}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressCreate?.customerUserErrors?.length) { + const error = customerAddressCreate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressCreate?.userErrors?.length) { - throw new Error(data?.customerAddressCreate?.userErrors[0].message); - } @@ -1085,7 +1089,7 @@ index e0f97e10f..d36d1d914 100644 + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'PUT': { // handle address updates try { @@ -1103,7 +1107,7 @@ index e0f97e10f..d36d1d914 100644 }, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + const updatedAddress = customerAddressUpdate?.customerAddress; @@ -1112,7 +1116,7 @@ index e0f97e10f..d36d1d914 100644 + const error = customerAddressUpdate.customerUserErrors[0]; + throw new Error(error.message); } - + - if (data?.customerAddressUpdate?.userErrors?.length) { - throw new Error(data?.customerAddressUpdate?.userErrors[0].message); - } @@ -1162,7 +1166,7 @@ index e0f97e10f..d36d1d914 100644 + return data({error: {[addressId]: error}}, {status: 400}); } } - + case 'DELETE': { // handles address deletion try { @@ -1177,7 +1181,7 @@ index e0f97e10f..d36d1d914 100644 + variables: {customerAccessToken: accessToken, id: addressId}, }, ); - + - if (errors?.length) { - throw new Error(errors[0].message); + if (customerAddressDelete?.customerUserErrors?.length) { @@ -1213,7 +1217,7 @@ index e0f97e10f..d36d1d914 100644 + return data({error: {[addressId]: error}}, {status: 400}); } } - + @@ -291,21 +257,17 @@ function NewAddressForm() { address2: '', city: '', @@ -1230,7 +1234,7 @@ index e0f97e10f..d36d1d914 100644 zip: '', - } as CustomerAddressInput; + } as AddressFragment; - + return ( - @@ -360,26 +321,26 @@ function ExistingAddresses({ } - + export function AddressForm({ - addressId, address, @@ -1440,21 +1444,25 @@ index e0f97e10f..d36d1d914 100644 + } + } +` as const; -~~~ +```
    -### Step 11: app/routes/account_.login.multipass.tsx +### Step 11: app/routes/account\_.login.multipass.tsx Add multipass login handler route -#### File: [account_.login.multipass.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx) +#### File: [account\_.login.multipass.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx)
    -~~~tsx +```tsx import {data as remixData, redirect} from 'react-router'; -import type {LoaderFunctionArgs, ActionFunctionArgs, HeadersFunction} from 'react-router'; +import type { + LoaderFunctionArgs, + ActionFunctionArgs, + HeadersFunction, +} from 'react-router'; import {Multipassify} from '~/lib/multipass/multipassify.server'; import type { CustomerInfoType, @@ -1462,8 +1470,7 @@ import type { NotLoggedInResponseType, } from '~/lib/multipass/types'; -export const headers: HeadersFunction = ({actionHeaders}) => - actionHeaders; +export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; /* Redirect document GET requests to the login page (housekeeping) @@ -1745,8 +1752,7 @@ const CUSTOMER_INFO_QUERY = `#graphql } } `; - -~~~ +```
    @@ -1758,7 +1764,7 @@ Convert order details to use Storefront API
    -~~~diff +```diff index 3c2430385..77bd99344 100644 --- a/templates/skeleton/app/routes/account.orders.$id.tsx +++ b/templates/skeleton/app/routes/account.orders.$id.tsx @@ -1775,11 +1781,11 @@ index 3c2430385..77bd99344 100644 -import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery'; + DiscountApplicationFragment, +} from 'storefrontapi.generated'; - - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Order ${data?.order?.name}`}]; + + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; - + export async function loader({params, context}: Route.LoaderArgs) { - const {customerAccount} = context; + const {session, storefront} = context; @@ -1787,7 +1793,7 @@ index 3c2430385..77bd99344 100644 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, { @@ -1797,29 +1803,29 @@ index 3c2430385..77bd99344 100644 - }, - }); + const customerAccessToken = await session.get('customerAccessToken'); - + - if (errors?.length || !data?.order) { - throw new Error('Order not found'); + if (!customerAccessToken) { + return redirect('/account/login'); } - + - const {order} = data; + const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, { + variables: {orderId}, + }); - + - // Extract line items directly from nodes array - const lineItems = order.lineItems.nodes; + if (!order || !('lineItems' in order)) { + throw new Response('Order not found', {status: 404}); + } - + - // Extract discount applications directly from nodes array - const discountApplications = order.discountApplications.nodes; + const lineItems = flattenConnection(order.lineItems) as OrderLineItemFullFragment[]; + const discountApplications = flattenConnection(order.discountApplications) as DiscountApplicationFragment[]; - + - // Get fulfillment status from first fulfillment node - const fulfillmentStatus = order.fulfillments.nodes[0]?.status ?? 'N/A'; - @@ -1828,17 +1834,17 @@ index 3c2430385..77bd99344 100644 - - // Type guard for MoneyV2 discount + const firstDiscount = discountApplications[0]; -+ ++ const discountValue = - firstDiscount?.__typename === 'MoneyV2' - ? (firstDiscount as Extract< - typeof firstDiscount, - {__typename: 'MoneyV2'} - >) -+ firstDiscount?.value?.__typename === 'MoneyV2' -+ ? firstDiscount.value ++ firstDiscount?.value?.__typename === 'MoneyV2' ++ ? firstDiscount.value : null; - + - // Type guard for percentage discount const discountPercentage = - firstDiscount?.__typename === 'PricingPercentageValue' @@ -1851,7 +1857,7 @@ index 3c2430385..77bd99344 100644 + firstDiscount?.value?.__typename === 'PricingPercentageValue' + ? firstDiscount.value.percentage : null; - + return { @@ -69,25 +52,16 @@ export async function loader({params, context}: Route.LoaderArgs) { lineItems, @@ -1860,7 +1866,7 @@ index 3c2430385..77bd99344 100644 - fulfillmentStatus, }; } - + export default function OrderRoute() { - const { - order, @@ -1968,7 +1974,7 @@ index 3c2430385..77bd99344 100644

    @@ -196,27 +170,144 @@ export default function OrderRoute() { - + function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { return ( - @@ -2122,19 +2128,19 @@ index 3c2430385..77bd99344 100644 + } +` as const; \ No newline at end of file -~~~ +```
    -### Step 13: app/routes/account_.recover.tsx +### Step 13: app/routes/account\_.recover.tsx Add password recovery route -#### File: [account_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx) +#### File: [account\_.recover.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx)
    -~~~tsx +```tsx import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.recover'; @@ -2259,19 +2265,19 @@ const CUSTOMER_RECOVER_MUTATION = `#graphql } } ` as const; -~~~ +```
    -### Step 14: app/routes/account.orders._index.tsx +### Step 14: app/routes/account.orders.\_index.tsx Convert orders list to use Storefront API -#### File: [app/routes/account.orders._index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.orders._index.tsx) +#### File: [app/routes/account.orders.\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.orders._index.tsx)
    -~~~diff +```diff index a9f479ea7..d097338ef 100644 --- a/templates/skeleton/app/routes/account.orders._index.tsx +++ b/templates/skeleton/app/routes/account.orders._index.tsx @@ -2303,7 +2309,7 @@ index a9f479ea7..d097338ef 100644 -} from 'customer-accountapi.generated'; +} from 'storefrontapi.generated'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; - + -type OrdersLoaderData = { - customer: CustomerOrdersFragment; - filters: OrderFilterParams; @@ -2312,14 +2318,14 @@ index a9f479ea7..d097338ef 100644 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 {session, storefront} = context; - + - const url = new URL(request.url); - const filters = parseOrderFilters(url.searchParams); - const query = buildOrderSearchQuery(filters); @@ -2338,7 +2344,7 @@ index a9f479ea7..d097338ef 100644 + if (!customerAccessToken?.accessToken) { + return redirect('/account/login'); } - + - return {customer: data.customer, filters}; + try { + const paginationVariables = getPaginationVariables(request, { @@ -2367,18 +2373,18 @@ index a9f479ea7..d097338ef 100644 + return data({error}, {status: 400}); + } } - + export default function Orders() { - const {customer, filters} = useLoaderData(); + const data = useLoaderData(); -+ ++ + if ('error' in data) { + return
    Error: {String(data.error)}
    ; + } -+ ++ + const {customer} = data; const {orders} = customer; - + return (
    - @@ -2387,7 +2393,7 @@ index a9f479ea7..d097338ef 100644
    ); } - + -function OrdersTable({ - orders, - filters, @@ -2412,7 +2418,7 @@ index a9f479ea7..d097338ef 100644 ); } - + -function EmptyOrders({hasFilters = false}: {hasFilters?: boolean}) { +function EmptyOrders() { return ( @@ -2442,7 +2448,7 @@ index a9f479ea7..d097338ef 100644 ); } - + -function OrderSearchForm({ - currentFilters, -}: { @@ -2622,19 +2628,19 @@ index a9f479ea7..d097338ef 100644 + } +` as const; \ No newline at end of file -~~~ +```
    -### Step 15: app/routes/account_.register.tsx +### Step 15: app/routes/account\_.register.tsx Add customer registration route -#### File: [account_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx) +#### File: [account\_.register.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx)
    -~~~tsx +```tsx import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.register'; import type {CustomerCreateMutation} from 'storefrontapi.generated'; @@ -2646,7 +2652,8 @@ type ActionResponse = { | null; }; -export const headers: Route.HeadersFunction = ({actionHeaders}) => actionHeaders; +export const headers: Route.HeadersFunction = ({actionHeaders}) => + actionHeaders; export async function loader({context}: Route.LoaderArgs) { const customerAccessToken = await context.session.get('customerAccessToken'); @@ -2838,7 +2845,7 @@ const REGISTER_LOGIN_MUTATION = `#graphql } } ` as const; -~~~ +```
    @@ -2850,7 +2857,7 @@ Convert customer profile management from Customer Account API to Storefront API
    -~~~diff +```diff index 6f10065fb..7b8c77d3a 100644 --- a/templates/skeleton/app/routes/account.profile.tsx +++ b/templates/skeleton/app/routes/account.profile.tsx @@ -2870,10 +2877,10 @@ index 6f10065fb..7b8c77d3a 100644 + redirect, } from 'react-router'; import type {Route} from './+types/account.profile'; - + @@ -20,62 +20,79 @@ export const meta: Route.MetaFunction = () => { }; - + export async function loader({context}: Route.LoaderArgs) { - await context.customerAccount.handleAuthStatus(); - @@ -2883,21 +2890,21 @@ index 6f10065fb..7b8c77d3a 100644 + } return {}; } - + export async function action({request, context}: Route.ActionArgs) { - const {customerAccount} = context; + const {session, storefront} = context; - + if (request.method !== 'PUT') { return data({error: 'Method not allowed'}, {status: 405}); } - + const form = await request.formData(); + const customerAccessToken = await session.get('customerAccessToken'); + if (!customerAccessToken) { + return data({error: 'Unauthorized'}, {status: 401}); + } - + try { + const password = getPassword(form); const customer: CustomerUpdateInput = {}; @@ -2920,7 +2927,7 @@ index 6f10065fb..7b8c77d3a 100644 customer[key as (typeof validInputKeys)[number]] = value; } } - + + if (password) { + customer.password = password; + } @@ -2940,7 +2947,7 @@ index 6f10065fb..7b8c77d3a 100644 }, - ); + }); - + - if (errors?.length) { - throw new Error(errors[0].message); + // check for mutation errors @@ -2950,7 +2957,7 @@ index 6f10065fb..7b8c77d3a 100644 + {status: 400}, + ); } - + - if (!data?.customerUpdate?.customer) { - throw new Error('Customer profile update failed.'); + // update session with the updated access token @@ -2960,7 +2967,7 @@ index 6f10065fb..7b8c77d3a 100644 + updated.customerUpdate?.customerAccessToken, + ); } - + - return { - error: null, - customer: data?.customerUpdate?.customer, @@ -2976,7 +2983,7 @@ index 6f10065fb..7b8c77d3a 100644 + return data({error: error.message, customer: null}, {status: 400}); } } - + @@ -114,6 +131,64 @@ export default function AccountProfile() { defaultValue={customer.lastName ?? ''} minLength={2} @@ -3098,19 +3105,19 @@ index 6f10065fb..7b8c77d3a 100644 + } + } +` as const; -~~~ +```
    -### Step 17: app/routes/account_.reset.$id.$resetToken.tsx +### Step 17: app/routes/account\_.reset.$id.$resetToken.tsx Add password reset confirmation route -#### File: [account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) +#### File: [account\_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx)
    -~~~tsx +```tsx import {data, Form, redirect, useActionData} from 'react-router'; import type {Route} from './+types/account_.reset.$id.$resetToken'; @@ -3243,7 +3250,7 @@ const CUSTOMER_RESET_MUTATION = `#graphql } } ` as const; -~~~ +```
    @@ -3255,7 +3262,7 @@ Convert account layout to use Storefront API customer data
    -~~~diff +```diff index 0429e7ebe..c5e01b287 100644 --- a/templates/skeleton/app/routes/account.tsx +++ b/templates/skeleton/app/routes/account.tsx @@ -3272,11 +3279,11 @@ index 0429e7ebe..c5e01b287 100644 import type {Route} from './+types/account'; -import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import type {CustomerFragment} from 'storefrontapi.generated'; - + export function shouldRevalidate() { return true; } - + -export async function loader({context}: Route.LoaderArgs) { - const {customerAccount} = context; - const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, { @@ -3286,7 +3293,7 @@ index 0429e7ebe..c5e01b287 100644 - }); +export const headers: Route.HeadersFunction = ({loaderHeaders}) => + loaderHeaders; - + - if (errors?.length || !data?.customer) { - throw new Error('Customer not found'); +export async function loader({request, context}: Route.LoaderArgs) { @@ -3319,7 +3326,7 @@ index 0429e7ebe..c5e01b287 100644 + return redirect('/account/orders'); + } } - + - return remixData( - {customer: data.customer}, - { @@ -3372,7 +3379,7 @@ index 0429e7ebe..c5e01b287 100644 + ); } - + -export default function AccountLayout() { - const {customer} = useLoaderData(); - @@ -3448,26 +3455,26 @@ index 0429e7ebe..c5e01b287 100644 + } + ${CUSTOMER_FRAGMENT} +` as const; -~~~ +```
    -### Step 19: app/routes/account_.login.tsx +### Step 19: app/routes/account\_.login.tsx Replace Customer Account API login with form-based Storefront API login -#### File: [app/routes/account_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.login.tsx) +#### File: [app/routes/account\_.login.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.login.tsx)
    -~~~diff +```diff index e89c8e0a2..cf1415c9d 100644 --- a/templates/skeleton/app/routes/account_.login.tsx +++ b/templates/skeleton/app/routes/account_.login.tsx @@ -1,17 +1,133 @@ +import {Form, Link, useActionData, data, redirect} from 'react-router'; import type {Route} from './+types/account_.login'; - + -export async function loader({request, context}: Route.LoaderArgs) { - const url = new URL(request.url); - const acrValues = url.searchParams.get('acr_values') || undefined; @@ -3477,7 +3484,7 @@ index e89c8e0a2..cf1415c9d 100644 +type ActionResponse = { + error: string | null; +}; - + - return context.customerAccount.login({ - countryCode: context.storefront.i18n.country, - acrValues, @@ -3611,17 +3618,17 @@ index e89c8e0a2..cf1415c9d 100644 + } + } +` as const; -~~~ +```
    -### Step 20: app/routes/account_.logout.tsx +### Step 20: app/routes/account\_.logout.tsx Implement session-based logout -#### File: [app/routes/account_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.logout.tsx) +#### File: [app/routes/account\_.logout.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account_.logout.tsx) -~~~diff +```diff index 5e67cc857..d88e717d5 100644 --- a/templates/skeleton/app/routes/account_.logout.tsx +++ b/templates/skeleton/app/routes/account_.logout.tsx @@ -3629,7 +3636,7 @@ index 5e67cc857..d88e717d5 100644 -import {redirect} from 'react-router'; +import {data, redirect} from 'react-router'; import type {Route} from './+types/account_.logout'; - + -// if we don't implement this, /account/logout will get caught by account.$.tsx to do login +export const meta: Route.MetaFunction = () => { + return [{title: 'Logout'}]; @@ -3649,7 +3656,7 @@ index 5e67cc857..d88e717d5 100644 + return redirect('/'); } - + -export async function action({context}: Route.ActionArgs) { - return context.customerAccount.logout(); -} @@ -3657,7 +3664,7 @@ index 5e67cc857..d88e717d5 100644 + return null; +} \ No newline at end of file -~~~ +``` ### Step 21: env.d.ts @@ -3665,12 +3672,12 @@ Add multipass secret environment variable type #### File: [env.d.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/env.d.ts) -~~~diff +```diff index 4b5e361ec..7dcc69f2e 100644 --- a/templates/skeleton/env.d.ts +++ b/templates/skeleton/env.d.ts @@ -5,3 +5,9 @@ - + // Enhance TypeScript's built-in typings. import '@total-typescript/ts-reset'; + @@ -3679,7 +3686,7 @@ index 4b5e361ec..7dcc69f2e 100644 + PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET?: string; + } +} -~~~ +``` ### Step 22: app/routes/cart.tsx @@ -3687,26 +3694,26 @@ Add multipass URL generation for checkout #### File: [app/routes/cart.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/cart.tsx) -~~~diff +```diff index 983f6505d..a09b66d5f 100644 --- a/templates/skeleton/app/routes/cart.tsx +++ b/templates/skeleton/app/routes/cart.tsx @@ -11,9 +11,13 @@ export const meta: Route.MetaFunction = () => { export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; - + export async function action({request, context}: Route.ActionArgs) { - const {cart} = context; + // @description Get session for multipass customer token persistence + const {session, cart} = context; - + - const formData = await request.formData(); + const [formData, customerAccessToken] = await Promise.all([ + request.formData(), + session.get('customerAccessToken'), + ]); - + const {action, inputs} = CartForm.getFormInput(formData); - + @@ -66,6 +70,8 @@ export async function action({request, context}: Route.ActionArgs) { case CartForm.ACTIONS.BuyerIdentityUpdate: { result = await cart.updateBuyerIdentity({ @@ -3716,7 +3723,7 @@ index 983f6505d..a09b66d5f 100644 }); break; } -~~~ +``` ### Step 23: package.json @@ -3724,7 +3731,7 @@ Add crypto dependencies for multipass token generation #### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) -~~~diff +```diff index 0bb332639..da854ab73 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json @@ -3744,7 +3751,7 @@ index 0bb332639..da854ab73 100644 "@types/eslint": "^9.6.1", "@types/react": "catalog:", "@types/react-dom": "catalog:", -~~~ +``` ### Step 24: vite.config.ts @@ -3752,7 +3759,7 @@ Configure Vite for crypto polyfills #### File: [vite.config.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/vite.config.ts) -~~~diff +```diff index d19b14dc4..550d63a11 100644 --- a/templates/skeleton/vite.config.ts +++ b/templates/skeleton/vite.config.ts @@ -3764,11 +3771,11 @@ index d19b14dc4..550d63a11 100644 ], }, }, -~~~ +``` ## Deleted Files -- [templates/skeleton/app/routes/account_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) +- [templates/skeleton/app/routes/account\_.authorize.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/templates/skeleton/app/routes/account_.authorize.tsx) ## Next steps @@ -3776,4 +3783,4 @@ index d19b14dc4..550d63a11 100644 - Test the login flow by visiting /account/login - Verify the Multipass checkout button appears in the cart - Configure external authentication systems to generate Multipass tokens -- Customize the authentication forms and error messages as needed \ No newline at end of file +- Customize the authentication forms and error messages as needed diff --git a/cookbook/recipes/multipass/patches/README.md.1764cd.patch b/cookbook/recipes/multipass/patches/README.md.1764cd.patch index d2fe8bc1d1..3f62a210a4 100644 --- a/cookbook/recipes/multipass/patches/README.md.1764cd.patch +++ b/cookbook/recipes/multipass/patches/README.md.1764cd.patch @@ -5,16 +5,16 @@ index c584e5370..e8e03b96c 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Multipass --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) +[Learn about Multipass](https://shopify.dev/docs/api/multipass) ## What's included --- Remix +-- React Router +### Core Hydrogen Stack +- React Router - Hydrogen @@ -34,7 +34,7 @@ index c584e5370..e8e03b96c 100644 **Requirements:** - - Node.js version 18.0.0 or higher + - Node.js version 22.22.0 or higher +- Shopify store (Shopify Plus for Multipass features) ```bash @@ -129,4 +129,4 @@ index c584e5370..e8e03b96c 100644 +- [Hydrogen Documentation](https://shopify.dev/custom-storefronts/hydrogen) +- [Multipass Documentation](https://shopify.dev/docs/api/multipass) +- [Storefront API Authentication](https://shopify.dev/docs/api/storefront/authentication) -+- [Remix Documentation](https://remix.run/docs) ++- [React Router Documentation](https://reactrouter.com/) diff --git a/cookbook/recipes/multipass/patches/account.orders.$id.tsx.767824.patch b/cookbook/recipes/multipass/patches/account.orders.$id.tsx.767824.patch index 4047ac742b..dbee863b72 100644 --- a/cookbook/recipes/multipass/patches/account.orders.$id.tsx.767824.patch +++ b/cookbook/recipes/multipass/patches/account.orders.$id.tsx.767824.patch @@ -15,8 +15,8 @@ index 3c2430385..77bd99344 100644 + DiscountApplicationFragment, +} from 'storefrontapi.generated'; - export const meta: Route.MetaFunction = ({data}) => { - return [{title: `Order ${data?.order?.name}`}]; + export const meta: Route.MetaFunction = ({loaderData}) => { + return [{title: `Order ${loaderData?.order?.name}`}]; }; export async function loader({params, context}: Route.LoaderArgs) { diff --git a/cookbook/recipes/multipass/recipe.yaml b/cookbook/recipes/multipass/recipe.yaml index abbe33db9c..12916fa664 100644 --- a/cookbook/recipes/multipass/recipe.yaml +++ b/cookbook/recipes/multipass/recipe.yaml @@ -27,7 +27,7 @@ notes: requirements: | - Shopify Plus subscription for Multipass functionality - PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET environment variable must be set - - React Router 7.9.x or higher + - React Router framework mode ingredients: - path: templates/skeleton/app/components/MultipassCheckoutButton.tsx description: Checkout button that generates multipass tokens for seamless @@ -52,160 +52,161 @@ deletedFiles: - templates/skeleton/app/routes/account_.authorize.tsx steps: - type: PATCH - step: "1" + step: '1' name: README.md description: Update README with multipass authentication documentation diffs: - file: README.md patchFile: README.md.1764cd.patch - type: NEW_FILE - step: "2" + step: '2' name: app/components/MultipassCheckoutButton.tsx description: Add checkout button component that generates multipass tokens ingredients: - path: templates/skeleton/app/components/MultipassCheckoutButton.tsx - type: PATCH - step: "3" + step: '3' name: app/components/CartSummary.tsx description: Add multipass checkout button to cart summary diffs: - file: app/components/CartSummary.tsx patchFile: CartSummary.tsx.c6d827.patch - type: NEW_FILE - step: "4" + step: '4' name: app/lib/multipass/multipass.ts description: Core multipass encryption and token generation utilities ingredients: - path: templates/skeleton/app/lib/multipass/multipass.ts - type: PATCH - step: "5" + step: '5' name: app/root.tsx description: Add session validation and token refresh logic diffs: - file: app/root.tsx patchFile: root.tsx.8f1e92.patch - type: NEW_FILE - step: "6" + step: '6' name: app/lib/multipass/multipassify.server.ts description: Server-side multipass token generation with ESM-compatible snake_case ingredients: - path: templates/skeleton/app/lib/multipass/multipassify.server.ts - type: PATCH - step: "7" + step: '7' name: app/routes/account.$.tsx description: Convert catch-all account route to use Storefront API diffs: - file: app/routes/account.$.tsx patchFile: account.$.tsx.8a1da5.patch - type: NEW_FILE - step: "8" + step: '8' name: app/lib/multipass/types.ts description: TypeScript types for multipass functionality ingredients: - path: templates/skeleton/app/lib/multipass/types.ts - type: NEW_FILE - step: "9" + step: '9' name: app/routes/account_.activate.$id.$activationToken.tsx description: Add customer account activation route ingredients: - path: templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx - type: PATCH - step: "10" + step: '10' name: app/routes/account.addresses.tsx description: Convert addresses management to use Storefront API diffs: - file: app/routes/account.addresses.tsx patchFile: account.addresses.tsx.ab9d7b.patch - type: NEW_FILE - step: "11" + step: '11' name: app/routes/account_.login.multipass.tsx description: Add multipass login handler route ingredients: - path: templates/skeleton/app/routes/account_.login.multipass.tsx - type: PATCH - step: "12" + step: '12' name: app/routes/account.orders.$id.tsx description: Convert order details to use Storefront API diffs: - file: app/routes/account.orders.$id.tsx patchFile: account.orders.$id.tsx.767824.patch - type: NEW_FILE - step: "13" + step: '13' name: app/routes/account_.recover.tsx description: Add password recovery route ingredients: - path: templates/skeleton/app/routes/account_.recover.tsx - type: PATCH - step: "14" + step: '14' name: app/routes/account.orders._index.tsx description: Convert orders list to use Storefront API diffs: - file: app/routes/account.orders._index.tsx patchFile: account.orders._index.tsx.8d7c8a.patch - type: NEW_FILE - step: "15" + step: '15' name: app/routes/account_.register.tsx description: Add customer registration route ingredients: - path: templates/skeleton/app/routes/account_.register.tsx - type: PATCH - step: "16" + step: '16' name: app/routes/account.profile.tsx - description: Convert customer profile management from Customer Account API to + description: + Convert customer profile management from Customer Account API to Storefront API diffs: - file: app/routes/account.profile.tsx patchFile: account.profile.tsx.0d4ef1.patch - type: NEW_FILE - step: "17" + step: '17' name: app/routes/account_.reset.$id.$resetToken.tsx description: Add password reset confirmation route ingredients: - path: templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx - type: PATCH - step: "18" + step: '18' name: app/routes/account.tsx description: Convert account layout to use Storefront API customer data diffs: - file: app/routes/account.tsx patchFile: account.tsx.d6599a.patch - type: PATCH - step: "19" + step: '19' name: app/routes/account_.login.tsx description: Replace Customer Account API login with form-based Storefront API login diffs: - file: app/routes/account_.login.tsx patchFile: account_.login.tsx.a8f71d.patch - type: PATCH - step: "20" + step: '20' name: app/routes/account_.logout.tsx description: Implement session-based logout diffs: - file: app/routes/account_.logout.tsx patchFile: account_.logout.tsx.2930de.patch - type: PATCH - step: "21" + step: '21' name: env.d.ts description: Add multipass secret environment variable type diffs: - file: env.d.ts patchFile: env.d.ts.2e612c.patch - type: PATCH - step: "22" + step: '22' name: app/routes/cart.tsx description: Add multipass URL generation for checkout diffs: - file: app/routes/cart.tsx patchFile: cart.tsx.825f67.patch - type: PATCH - step: "23" + step: '23' name: package.json description: Add crypto dependencies for multipass token generation diffs: - file: package.json patchFile: package.json.8e0ff5.patch - type: PATCH - step: "24" + step: '24' name: vite.config.ts description: Configure Vite for crypto polyfills diffs: @@ -226,14 +227,17 @@ llms: - How can I maintain customer login state across checkout? - How do I integrate external authentication with Shopify Plus? troubleshooting: - - issue: "ReferenceError: require is not defined (snakecase-keys error)" - solution: The recipe includes a custom ESM-compatible snake_case implementation. + - issue: 'ReferenceError: require is not defined (snakecase-keys error)' + solution: + The recipe includes a custom ESM-compatible snake_case implementation. Ensure you're using the updated multipassify.server.ts file that doesn't import snakecase-keys - issue: PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET is undefined - solution: Add the Multipass secret to your environment variables. You can find + solution: + Add the Multipass secret to your environment variables. You can find this in your Shopify Plus admin under Settings > Checkout > Multipass - - issue: "TypeScript error: Property 'PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET' does + - issue: + "TypeScript error: Property 'PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET' does not exist on type 'Env'" solution: The recipe adds this type definition to env.d.ts. Run 'npm run typecheck' after applying all patches @@ -242,6 +246,7 @@ llms: Storefront API. Check that account_.login.tsx uses the form-based login, not customerAccount.login() - issue: Multipass checkout button not appearing - solution: Verify that CartSummary.tsx imports and uses MultipassCheckoutButton + solution: + Verify that CartSummary.tsx imports and uses MultipassCheckoutButton component, and that the cart.tsx route has been patched commit: 1040066d20b52667756fd1ebffd8607602a735b4 diff --git a/cookbook/recipes/partytown/README.md b/cookbook/recipes/partytown/README.md index 3bd408a068..753f4eac8d 100644 --- a/cookbook/recipes/partytown/README.md +++ b/cookbook/recipes/partytown/README.md @@ -1,10 +1,11 @@ # Partytown + Google Tag Manager in Hydrogen -This recipe integrates Partytown with your Hydrogen storefront to run Google Tag Manager -and other third-party scripts in a web worker, keeping the main thread free for critical +This recipe integrates Partytown with your Hydrogen storefront to run Google Tag Manager +and other third-party scripts in a web worker, keeping the main thread free for critical rendering tasks. Key features: + - Moves GTM and analytics scripts off the main thread - Improves Core Web Vitals scores - Maintains full GTM functionality @@ -18,17 +19,17 @@ Key features: - Google Tag Manager container ID (remember to set your `GTM_CONTAINER_ID` or `GTM_ID` environment variable) - Basic understanding of web workers and CSP -- Node.js 18.0.0 or higher +- Node.js 22.22.0 or higher ## Ingredients _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/PartytownGoogleTagManager.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/components/PartytownGoogleTagManager.tsx) | Component that loads GTM scripts in a web worker via Partytown | -| [app/routes/reverse-proxy.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts) | Reverse proxy route for third-party scripts requiring CORS headers | -| [app/utils/partytown/maybeProxyRequest.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/utils/partytown/maybeProxyRequest.ts) | URL resolver to control which scripts should be reverse-proxied | +| File | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| [app/components/PartytownGoogleTagManager.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/components/PartytownGoogleTagManager.tsx) | Component that loads GTM scripts in a web worker via Partytown | +| [app/routes/reverse-proxy.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/routes/reverse-proxy.ts) | Reverse proxy route for third-party scripts requiring CORS headers | +| [app/utils/partytown/maybeProxyRequest.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/utils/partytown/maybeProxyRequest.ts) | URL resolver to control which scripts should be reverse-proxied | | [app/utils/partytown/partytownAtomicHeaders.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/partytown/ingredients/templates/skeleton/app/utils/partytown/partytownAtomicHeaders.ts) | Helper utility to enable Partytown atomic mode for better performance | ## Steps @@ -39,7 +40,7 @@ Add `public/~partytown` to ignore Partytown library files. #### File: [.gitignore](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/.gitignore) -~~~diff +```diff index 4a0c4ce52..b47aa7338 100644 --- a/templates/skeleton/.gitignore +++ b/templates/skeleton/.gitignore @@ -51,7 +52,7 @@ index 4a0c4ce52..b47aa7338 100644 /.mf .env .shopify -~~~ +``` ### Step 2: Create GTM web worker component @@ -61,7 +62,7 @@ Add a GTM component that loads scripts in a web worker.
    -~~~tsx +```tsx import {useEffect, useRef} from 'react'; /** @@ -131,7 +132,7 @@ export function PartytownGoogleTagManager(props: { ); } -~~~ +```
    @@ -143,39 +144,39 @@ Document Partytown setup and configuration instructions.
    -~~~diff +````diff index c584e5370..1ac3a34cb 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,6 +1,6 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton + Partytown + Google Tag Manager - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with [Partytown](https://partytown.builder.io/) for performance-oriented lazy-loading of [Google Tag Manager](https://support.google.com/tagmanager). - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with [Partytown](https://partytown.builder.io/) for performance-oriented lazy-loading of [Google Tag Manager](https://support.google.com/tagmanager). + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -17,12 +17,15 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - GraphQL generator - TypeScript and JavaScript flavors - Minimal setup of components and routes +- **Partytown** - Relocates resource intensive scripts off the main thread into a web worker +- **Google Tag Manager** - Integration with CSP support - + ## Getting started - + **Requirements:** - - - Node.js version 18.0.0 or higher + + - Node.js version 22.22.0 or higher +- [Google Tag Manager ID](https://support.google.com/tagmanager/answer/6103696?hl=en) (optional) - + ```bash npm create @shopify/hydrogen@latest @@ -40,6 +43,62 @@ npm run build npm run dev ``` - + +## Partytown + Google Tag Manager Setup + +### Key files @@ -226,7 +227,7 @@ index c584e5370..1ac3a34cb 100644 +- Improved user experience + ## Setup for using Customer Account API (`/account` section) - + Follow step 1 and 2 of + +## Resources @@ -236,7 +237,7 @@ index c584e5370..1ac3a34cb 100644 +- [Google Tag Manager setup](https://support.google.com/tagmanager/answer/6103696) +- [Introducing Partytown](https://dev.to/adamdbradley/introducing-partytown-run-third-party-scripts-from-a-web-worker-2cnp) \ No newline at end of file -~~~ +````
    @@ -248,7 +249,7 @@ Reverse the proxy route for third-party scripts requiring CORS headers.
    -~~~ts +```ts // Reverse proxies partytown libs that require CORS. Used by Partytown resolveUrl //@see: https://developers.cloudflare.com/workers/examples/cors-header-proxy/ @@ -453,7 +454,7 @@ async function handleRequest(request: Route.LoaderArgs['request']) { } } } -~~~ +```
    @@ -463,7 +464,7 @@ Configure the CSP headers for GTM and Google Analytics domains. #### File: [app/entry.server.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/entry.server.tsx) -~~~diff +```diff index 6f5c4abfc..a2443e77b 100644 --- a/templates/skeleton/app/entry.server.tsx +++ b/templates/skeleton/app/entry.server.tsx @@ -485,9 +486,9 @@ index 6f5c4abfc..a2443e77b 100644 + 'www.google-analytics.com', + ], }); - + const body = await renderToReadableStream( -~~~ +``` ### Step 6: Add URL resolver for proxying @@ -497,7 +498,7 @@ Add a URL resolver to control which scripts should be reverse-proxied.
    -~~~ts +```ts /** * Partytown will call this function to resolve any URLs * Many third-party scripts already provide the correct CORS headers, but not all do. For services that do not add the correct headers, then a reverse proxy to another domain must be used in order to provide the CORS headers. @@ -532,7 +533,7 @@ export function maybeProxyRequest(url: URL, location: Location, type: string) { return proxyUrl; } -~~~ +```
    @@ -544,7 +545,7 @@ Initialize Partytown and GTM in the root layout.
    -~~~diff +```diff index df87425c5..a2b8986a6 100644 --- a/templates/skeleton/app/root.tsx +++ b/templates/skeleton/app/root.tsx @@ -562,9 +563,9 @@ index df87425c5..a2b8986a6 100644 +import {PartytownGoogleTagManager} from '~/components/PartytownGoogleTagManager'; +import {Partytown} from '@qwik.dev/partytown/react'; +import {maybeProxyRequest} from '~/utils/partytown/maybeProxyRequest'; - + export type RootLoader = typeof loader; - + @@ -90,6 +94,10 @@ export async function loader(args: Route.LoaderArgs) { country: args.context.storefront.i18n.country, language: args.context.storefront.i18n.language, @@ -575,11 +576,11 @@ index df87425c5..a2b8986a6 100644 + args.context.env.GTM_ID || args.context.env.GTM_CONTAINER_ID, }; } - + @@ -163,6 +171,38 @@ export function Layout({children}: {children?: React.ReactNode}) { ); } - + +function PartyTownScripts({gtmContainerId}: {gtmContainerId: string}) { + const nonce = useNonce(); + return ( @@ -614,7 +615,7 @@ index df87425c5..a2b8986a6 100644 + export default function App() { const data = useRouteLoaderData('root'); - + @@ -177,6 +217,7 @@ export default function App() { consent={data.consent} > @@ -623,7 +624,7 @@ index df87425c5..a2b8986a6 100644 -~~~ +```
    @@ -635,7 +636,7 @@ Add a helper utility to enable Partytown atomic mode for better performance.
    -~~~ts +```ts /* * Helper utility to enable PartyTown atomic mode * @see: https://partytown.builder.io/atomics @@ -646,7 +647,7 @@ export function partytownAtomicHeaders() { 'Cross-Origin-Opener-Policy': 'same-origin', }; } -~~~ +```
    @@ -656,7 +657,7 @@ Add a Partytown dependency and npm script for copying library files. #### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) -~~~diff +```diff index 0bb332639..529084e73 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json @@ -677,7 +678,7 @@ index 0bb332639..529084e73 100644 "@shopify/hydrogen": "workspace:*", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", -~~~ +``` ### Step 10: Configure Vite for Partytown @@ -685,7 +686,7 @@ Configure Vite to exclude Partytown library from build optimization. #### File: [vite.config.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/vite.config.ts) -~~~diff +```diff index d19b14dc4..38670eb6a 100644 --- a/templates/skeleton/vite.config.ts +++ b/templates/skeleton/vite.config.ts @@ -697,37 +698,43 @@ index d19b14dc4..38670eb6a 100644 ], }, }, -~~~ +``` ## Next steps After applying this recipe: 1. Install dependencies: - ```bash - npm install - ``` + +```bash +npm install +``` + 2. Copy Partytown library files: - ```bash - npm run partytown - ``` + +```bash +npm run partytown +``` 3. Add your GTM container ID to `.env`: - ```bash - GTM_CONTAINER_ID=GTM-XXXXXXX - ``` + +```bash +GTM_CONTAINER_ID=GTM-XXXXXXX +``` 4. For TypeScript projects, update `env.d.ts`: - ```typescript - interface Env extends HydrogenEnv { - GTM_CONTAINER_ID?: `GTM-${string}`; - GTM_ID?: `GTM-${string}`; - } - ``` + +```typescript +interface Env extends HydrogenEnv { + GTM_CONTAINER_ID?: `GTM-${string}`; + GTM_ID?: `GTM-${string}`; +} +``` 5. Test your implementation: - ```bash - npm run dev - ``` -Visit your site and check the Network tab to verify GTM is loading via the web worker. \ No newline at end of file +```bash +npm run dev +``` + +Visit your site and check the Network tab to verify GTM is loading via the web worker. diff --git a/cookbook/recipes/partytown/patches/README.md.1764cd.patch b/cookbook/recipes/partytown/patches/README.md.1764cd.patch index e3a70b8ef6..ff57d02357 100644 --- a/cookbook/recipes/partytown/patches/README.md.1764cd.patch +++ b/cookbook/recipes/partytown/patches/README.md.1764cd.patch @@ -5,11 +5,11 @@ index c584e5370..1ac3a34cb 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton + Partytown + Google Tag Manager --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with [Partytown](https://partytown.builder.io/) for performance-oriented lazy-loading of [Google Tag Manager](https://support.google.com/tagmanager). +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with [Partytown](https://partytown.builder.io/) for performance-oriented lazy-loading of [Google Tag Manager](https://support.google.com/tagmanager). [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -17,12 +17,15 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov - GraphQL generator - TypeScript and JavaScript flavors @@ -21,7 +21,7 @@ index c584e5370..1ac3a34cb 100644 **Requirements:** - - Node.js version 18.0.0 or higher + - Node.js version 22.22.0 or higher +- [Google Tag Manager ID](https://support.google.com/tagmanager/answer/6103696?hl=en) (optional) ```bash diff --git a/cookbook/recipes/partytown/recipe.yaml b/cookbook/recipes/partytown/recipe.yaml index 1b73bb84ed..afdce368fc 100644 --- a/cookbook/recipes/partytown/recipe.yaml +++ b/cookbook/recipes/partytown/recipe.yaml @@ -20,7 +20,7 @@ notes: requirements: | - Google Tag Manager container ID (remember to set your `GTM_CONTAINER_ID` or `GTM_ID` environment variable) - Basic understanding of web workers and CSP - - Node.js 18.0.0 or higher + - Node.js 22.22.0 or higher ingredients: - path: templates/skeleton/app/components/PartytownGoogleTagManager.tsx description: Component that loads GTM scripts in a web worker via Partytown @@ -33,67 +33,67 @@ ingredients: deletedFiles: [] steps: - type: PATCH - step: "1" + step: '1' name: Ignore Partytown library files description: Add `public/~partytown` to ignore Partytown library files. diffs: - file: .gitignore patchFile: .gitignore.e7c222.patch - type: NEW_FILE - step: "2" + step: '2' name: Create GTM web worker component description: Add a GTM component that loads scripts in a web worker. ingredients: - path: templates/skeleton/app/components/PartytownGoogleTagManager.tsx - type: PATCH - step: "3" + step: '3' name: Document Partytown setup description: Document Partytown setup and configuration instructions. diffs: - file: README.md patchFile: README.md.1764cd.patch - type: NEW_FILE - step: "4" + step: '4' name: Add CORS reverse proxy description: Reverse the proxy route for third-party scripts requiring CORS headers. ingredients: - path: templates/skeleton/app/routes/reverse-proxy.ts - type: PATCH - step: "5" + step: '5' name: Configure CSP headers description: Configure the CSP headers for GTM and Google Analytics domains. diffs: - file: app/entry.server.tsx patchFile: entry.server.tsx.a8386e.patch - type: NEW_FILE - step: "6" + step: '6' name: Add URL resolver for proxying description: Add a URL resolver to control which scripts should be reverse-proxied. ingredients: - path: templates/skeleton/app/utils/partytown/maybeProxyRequest.ts - type: PATCH - step: "7" + step: '7' name: Initialize Partytown and GTM description: Initialize Partytown and GTM in the root layout. diffs: - file: app/root.tsx patchFile: root.tsx.8f1e92.patch - type: NEW_FILE - step: "8" + step: '8' name: Enable atomic mode description: Add a helper utility to enable Partytown atomic mode for better performance. ingredients: - path: templates/skeleton/app/utils/partytown/partytownAtomicHeaders.ts - type: PATCH - step: "9" + step: '9' name: Install Partytown description: Add a Partytown dependency and npm script for copying library files. diffs: - file: package.json patchFile: package.json.8e0ff5.patch - type: PATCH - step: "10" + step: '10' name: Configure Vite for Partytown description: Configure Vite to exclude Partytown library from build optimization. diffs: diff --git a/cookbook/recipes/subscriptions/README.md b/cookbook/recipes/subscriptions/README.md index ee1e361775..336c93e023 100644 --- a/cookbook/recipes/subscriptions/README.md +++ b/cookbook/recipes/subscriptions/README.md @@ -18,14 +18,14 @@ To implement subscriptions in your own store, you need to install a subscription _New files added to the template by this recipe._ -| File | Description | -| --- | --- | -| [app/components/SellingPlanSelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/components/SellingPlanSelector.tsx) | Displays the available subscription options on product pages. | -| [app/graphql/customer-account/CustomerSubscriptionsMutations.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsMutations.ts) | Mutations for managing customer subscriptions. | -| [app/graphql/customer-account/CustomerSubscriptionsQuery.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsQuery.ts) | Queries for managing customer subscriptions. | -| [app/routes/account.subscriptions.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/routes/account.subscriptions.tsx) | Subscriptions management page. | -| [app/styles/account-subscriptions.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/styles/account-subscriptions.css) | Subscriptions management page styles. | -| [app/styles/selling-plan.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/styles/selling-plan.css) | Styles the `SellingPlanSelector` component. | +| File | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| [app/components/SellingPlanSelector.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/components/SellingPlanSelector.tsx) | Displays the available subscription options on product pages. | +| [app/graphql/customer-account/CustomerSubscriptionsMutations.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsMutations.ts) | Mutations for managing customer subscriptions. | +| [app/graphql/customer-account/CustomerSubscriptionsQuery.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsQuery.ts) | Queries for managing customer subscriptions. | +| [app/routes/account.subscriptions.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/routes/account.subscriptions.tsx) | Subscriptions management page. | +| [app/styles/account-subscriptions.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/styles/account-subscriptions.css) | Subscriptions management page styles. | +| [app/styles/selling-plan.css](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/styles/selling-plan.css) | Styles the `SellingPlanSelector` component. | ## Steps @@ -34,7 +34,7 @@ _New files added to the template by this recipe._ 1. Install the [Shopify Subscriptions app](https://apps.shopify.com/shopify-subscriptions). 2. In your Shopify admin, [use the Subscriptions app](https://admin.shopify.com/apps/subscriptions-remix/app) to create one or more subscription plans. 3. On the [Products](https://admin.shopify.com/products) page, open any products that will be sold as subscriptions and add the relevant subscription plans in the **Purchase options** section. -The Hydrogen demo storefront comes pre-configured with an example subscription product with the handle `shopify-wax`. + The Hydrogen demo storefront comes pre-configured with an example subscription product with the handle `shopify-wax`. ### Step 2: Update CartLineItem to display subscription details @@ -44,7 +44,7 @@ The Hydrogen demo storefront comes pre-configured with an example subscription p #### File: [app/components/CartLineItem.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/components/CartLineItem.tsx) -~~~diff +```diff index 63a0ecd9d..748dfef74 100644 --- a/templates/skeleton/app/components/CartLineItem.tsx +++ b/templates/skeleton/app/components/CartLineItem.tsx @@ -71,11 +71,11 @@ index 63a0ecd9d..748dfef74 100644 {selectedOptions.map((option) => (
  • -~~~ +``` ### Step 3: Show subscription options on product pages -In this step we'll implement the ability to display subscription options on product pages, alongside the existing one-off purchase options. +In this step we'll implement the ability to display subscription options on product pages, alongside the existing one-off purchase options. #### Step 3.1: Create a SellingPlanSelector component @@ -85,7 +85,7 @@ Create a new `SellingPlanSelector` component that displays the available subscri
    -~~~tsx +````tsx import type { ProductFragment, SellingPlanGroupFragment, @@ -191,8 +191,7 @@ export function SellingPlanSelector({ ], ); } - -~~~ +````
    @@ -204,7 +203,7 @@ Add styles for the `SellingPlanSelector` component.
    -~~~css +```css .selling-plan-group { margin-bottom: 1rem; } @@ -238,8 +237,7 @@ Add styles for the `SellingPlanSelector` component. .selling-plan.unselected { border-color: #fafafa; /* Equivalent to 'border-neutral-50' */ } - -~~~ +```
    @@ -253,7 +251,7 @@ Add styles for the `SellingPlanSelector` component.
    -~~~diff +```diff index 47c8f3056..bea5bccee 100644 --- a/templates/skeleton/app/components/ProductForm.tsx +++ b/templates/skeleton/app/components/ProductForm.tsx @@ -270,7 +268,7 @@ index 47c8f3056..bea5bccee 100644 + SellingPlanSelector, + type SellingPlanGroup, +} from '~/components/SellingPlanSelector'; - + export function ProductForm({ productOptions, selectedVariant, @@ -371,7 +369,7 @@ index 47c8f3056..bea5bccee 100644 + + ); +} -~~~ +```
    @@ -384,7 +382,7 @@ index 47c8f3056..bea5bccee 100644
    -~~~diff +```diff index 76f924e87..cf93f2123 100644 --- a/templates/skeleton/app/components/ProductPrice.tsx +++ b/templates/skeleton/app/components/ProductPrice.tsx @@ -396,7 +394,7 @@ index 76f924e87..cf93f2123 100644 +} from 'storefrontapi.generated'; import {Money} from '@shopify/hydrogen'; import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; - + export function ProductPrice({ price, compareAtPrice, @@ -495,7 +493,7 @@ index 76f924e87..cf93f2123 100644 + + ); +} -~~~ +```
    @@ -509,7 +507,7 @@ index 76f924e87..cf93f2123 100644
    -~~~diff +```diff index 99730d418..e98f27938 100644 --- a/templates/skeleton/app/routes/products.$handle.tsx +++ b/templates/skeleton/app/routes/products.$handle.tsx @@ -523,20 +521,20 @@ index 99730d418..e98f27938 100644 @@ -13,6 +14,12 @@ import {ProductImage} from '~/components/ProductImage'; import {ProductForm} from '~/components/ProductForm'; import {redirectIfHandleIsLocalized} from '~/lib/redirect'; - + +import sellingPlanStyle from '~/styles/selling-plan.css?url'; + +export const links: Route.LinksFunction = () => [ + {rel: 'stylesheet', href: sellingPlanStyle}, +]; + - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { return [ - {title: `Hydrogen | ${data?.product.title ?? ''}`}, -@@ -59,8 +66,34 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { + {title: `Hydrogen | ${loaderData?.product.title ?? ''}`}, +@@ -59,8 +66,34 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); - + redirectIfHandleIsLocalized(url, {handle, data: product}); + + // Initialize the selectedSellingPlan to null + let selectedSellingPlan = null; + @@ -567,23 +565,23 @@ index 99730d418..e98f27938 100644 + selectedSellingPlan, }; } - + @@ -77,7 +110,7 @@ function loadDeferredData({context, params}: Route.LoaderArgs) { } - + export default function Product() { - const {product} = useLoaderData(); + const {product, selectedSellingPlan} = useLoaderData(); - + // Optimistically selects a variant with given available variant information const selectedVariant = useOptimisticVariant( @@ -95,7 +128,7 @@ export default function Product() { selectedOrFirstAvailableVariant: selectedVariant, }); - + - const {title, descriptionHtml} = product; + const {title, descriptionHtml, sellingPlanGroups} = product; - + return (
    @@ -105,11 +138,15 @@ export default function Product() { @@ -615,7 +613,7 @@ index 99730d418..e98f27938 100644 + } } ` as const; - + +const SELLING_PLAN_FRAGMENT = `#graphql + fragment SellingPlanMoney on MoneyV2 { + amount @@ -705,7 +703,7 @@ index 99730d418..e98f27938 100644 + ${SELLING_PLAN_GROUP_FRAGMENT} ${PRODUCT_VARIANT_FRAGMENT} ` as const; -~~~ +```
    @@ -719,7 +717,7 @@ Add `sellingPlanAllocation` field with the plan name to both the standard cart l ##### File: [app/lib/fragments.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/lib/fragments.ts) -~~~diff +```diff index f6b6b2a36..f3f9ecff2 100644 --- a/templates/skeleton/app/lib/fragments.ts +++ b/templates/skeleton/app/lib/fragments.ts @@ -747,7 +745,7 @@ index f6b6b2a36..f3f9ecff2 100644 } fragment CartApiQuery on Cart { updatedAt -~~~ +``` ### Step 5: Add subscription management to the account page @@ -761,7 +759,7 @@ Create GraphQL queries that retrieve the subscription info from the customer acc
    -~~~ts +```ts // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer const SUBSCRIPTION_CONTRACT_FRAGMENT = `#graphql @@ -821,8 +819,7 @@ export const SUBSCRIPTIONS_CONTRACTS_QUERY = `#graphql } ${SUBSCRIPTION_CONTRACT_FRAGMENT} ` as const; - -~~~ +```
    @@ -834,7 +831,7 @@ Create a GraqhQL mutation to cancel an existing subscription.
    -~~~ts +```ts // NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer export const SUBSCRIPTION_CANCEL_MUTATION = `#graphql @@ -850,20 +847,19 @@ export const SUBSCRIPTION_CANCEL_MUTATION = `#graphql } } ` as const; - -~~~ +```
    #### Step 5.3: Add an account subscriptions page -Create a new account subpage that lets customers manage their existing subscriptions based on the new GraphQL queries and mutations. +Create a new account subpage that lets customers manage their existing subscriptions based on the new GraphQL queries and mutations. ##### File: [account.subscriptions.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/subscriptions/ingredients/templates/skeleton/app/routes/account.subscriptions.tsx)
    -~~~tsx +```tsx import type { SubscriptionBillingPolicyFragment, SubscriptionDiscountFragmentFragment, @@ -1060,8 +1056,7 @@ function formatDiscountValue( return 'Discount applied'; } - -~~~ +```
    @@ -1071,7 +1066,7 @@ Add a `Subscriptions` link to the account menu so customers can easily access th ##### File: [app/routes/account.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/account.tsx) -~~~diff +```diff index 0429e7ebe..6bd5ac6bd 100644 --- a/templates/skeleton/app/routes/account.tsx +++ b/templates/skeleton/app/routes/account.tsx @@ -1085,7 +1080,7 @@ index 0429e7ebe..6bd5ac6bd 100644 ); -~~~ +``` #### Step 5.5: Add styles for the Subscriptions page @@ -1095,7 +1090,7 @@ Add styles for the Subscriptions page.
    -~~~css +```css .account-subscriptions { display: flex; flex-direction: column; @@ -1146,60 +1141,11 @@ Add styles for the Subscriptions page. font-size: 0.875rem; margin-right: 0.5rem; } - -~~~ +```
    -### Step 7: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - ## Next steps - Test your implementation by going to your store and adding a subscription-based product to the cart. Make sure that the product's subscription details appear on the product page and in the cart. -- (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for subscription-based products appear in your Shopify admin. \ No newline at end of file +- (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for subscription-based products appear in your Shopify admin. diff --git a/cookbook/recipes/subscriptions/patches/package.json.8e0ff5.patch b/cookbook/recipes/subscriptions/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/subscriptions/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/subscriptions/patches/products.$handle.tsx.30ba8d.patch b/cookbook/recipes/subscriptions/patches/products.$handle.tsx.30ba8d.patch index e6768a2d31..bec655b1df 100644 --- a/cookbook/recipes/subscriptions/patches/products.$handle.tsx.30ba8d.patch +++ b/cookbook/recipes/subscriptions/patches/products.$handle.tsx.30ba8d.patch @@ -18,12 +18,12 @@ index 99730d418..e98f27938 100644 + {rel: 'stylesheet', href: sellingPlanStyle}, +]; + - export const meta: Route.MetaFunction = ({data}) => { + export const meta: Route.MetaFunction = ({loaderData}) => { return [ - {title: `Hydrogen | ${data?.product.title ?? ''}`}, -@@ -59,8 +66,34 @@ async function loadCriticalData({context, params, request}: Route.LoaderArgs) { + {title: `Hydrogen | ${loaderData?.product.title ?? ''}`}, +@@ -59,8 +66,34 @@ async function loadCriticalData({context, params, request, url}: Route.LoaderArgs) { // The API handle might be localized, so redirect to the localized handle - redirectIfHandleIsLocalized(request, {handle, data: product}); + redirectIfHandleIsLocalized(url, {handle, data: product}); + // Initialize the selectedSellingPlan to null + let selectedSellingPlan = null; diff --git a/cookbook/recipes/subscriptions/recipe.yaml b/cookbook/recipes/subscriptions/recipe.yaml index be0b9b7382..163eca665c 100644 --- a/cookbook/recipes/subscriptions/recipe.yaml +++ b/cookbook/recipes/subscriptions/recipe.yaml @@ -32,7 +32,7 @@ ingredients: deletedFiles: [] steps: - type: INFO - step: "1" + step: '1' name: Set up the Shopify Subscriptions app description: | 1. Install the [Shopify Subscriptions app](https://apps.shopify.com/shopify-subscriptions). @@ -40,7 +40,7 @@ steps: 3. On the [Products](https://admin.shopify.com/products) page, open any products that will be sold as subscriptions and add the relevant subscription plans in the **Purchase options** section. The Hydrogen demo storefront comes pre-configured with an example subscription product with the handle `shopify-wax`. - type: PATCH - step: "2" + step: '2' name: Update CartLineItem to display subscription details description: | 1. Update import paths to use absolute paths for better consistency. @@ -50,26 +50,26 @@ steps: - file: app/components/CartLineItem.tsx patchFile: CartLineItem.tsx.c12ba7.patch - type: INFO - step: "3" + step: '3' name: Show subscription options on product pages description: | In this step we'll implement the ability to display subscription options on product pages, alongside the existing one-off purchase options. - type: NEW_FILE - step: "3.1" + step: '3.1' name: Create a SellingPlanSelector component description: | Create a new `SellingPlanSelector` component that displays the available subscription options for a product. ingredients: - path: templates/skeleton/app/components/SellingPlanSelector.tsx - type: NEW_FILE - step: "3.2" + step: '3.2' name: Add styles for the SellingPlanSelector component description: | Add styles for the `SellingPlanSelector` component. ingredients: - path: templates/skeleton/app/styles/selling-plan.css - type: PATCH - step: "3.3" + step: '3.3' name: Update ProductForm to support subscriptions description: | 1. Add conditional rendering to display subscription options alongside the standard variant selectors. @@ -79,7 +79,7 @@ steps: - file: app/components/ProductForm.tsx patchFile: ProductForm.tsx.aa8a24.patch - type: PATCH - step: "3.4" + step: '3.4' name: Update ProductPrice to display subscription pricing description: | 1. Add a `SellingPlanPrice` function to calculate adjusted prices based on subscription plan type (fixed amount, fixed price, or percentage). @@ -88,7 +88,7 @@ steps: - file: app/components/ProductPrice.tsx patchFile: ProductPrice.tsx.bce165.patch - type: PATCH - step: "3.5" + step: '3.5' name: Update the product page to display subscription options description: | 1. Add the `SellingPlanSelector` component to display subscription options on product pages. @@ -98,12 +98,12 @@ steps: - file: app/routes/products.$handle.tsx patchFile: products.$handle.tsx.30ba8d.patch - type: INFO - step: "4" + step: '4' name: Show subscription details in the cart description: | In this step we'll implement support for showing subscription info in the cart's line items. - type: PATCH - step: "4.1" + step: '4.1' name: Add selling plan data to cart queries description: | Add `sellingPlanAllocation` field with the plan name to both the standard cart line and componentizable cart line GraphQL fragments. This ensures subscription details are fetched when querying cart data. @@ -111,33 +111,33 @@ steps: - file: app/lib/fragments.ts patchFile: fragments.ts.e564c1.patch - type: INFO - step: "5" + step: '5' name: Add subscription management to the account page description: | In this step we'll implement support for subscription management through an account subpage that lists existing subscription contracts. - type: NEW_FILE - step: "5.1" + step: '5.1' name: Add queries to retrieve customer subscriptions description: | Create GraphQL queries that retrieve the subscription info from the customer account client. ingredients: - path: templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsQuery.ts - type: NEW_FILE - step: "5.2" + step: '5.2' name: Add mutations to cancel customer subscriptions description: | Create a GraqhQL mutation to cancel an existing subscription. ingredients: - path: templates/skeleton/app/graphql/customer-account/CustomerSubscriptionsMutations.ts - type: NEW_FILE - step: "5.3" + step: '5.3' name: Add an account subscriptions page description: | Create a new account subpage that lets customers manage their existing subscriptions based on the new GraphQL queries and mutations. ingredients: - path: templates/skeleton/app/routes/account.subscriptions.tsx - type: PATCH - step: "5.4" + step: '5.4' name: Add a link to the Subscriptions page in the account menu description: | Add a `Subscriptions` link to the account menu so customers can easily access their subscription management page. @@ -145,19 +145,12 @@ steps: - file: app/routes/account.tsx patchFile: account.tsx.d6599a.patch - type: NEW_FILE - step: "5.5" + step: '5.5' name: Add styles for the Subscriptions page description: | Add styles for the Subscriptions page. ingredients: - path: templates/skeleton/app/styles/account-subscriptions.css - - type: PATCH - step: "7" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch nextSteps: | - Test your implementation by going to your store and adding a subscription-based product to the cart. Make sure that the product's subscription details appear on the product page and in the cart. - (Optional) [Place a test order](https://help.shopify.com/en/manual/checkout-settings/test-orders) to see how orders for subscription-based products appear in your Shopify admin. @@ -168,12 +161,15 @@ llms: - How do I display subscription details on applicable line items in the cart? troubleshooting: - issue: I'm getting an error when I try to add a subscription to my storefront. - solution: Make sure you've installed the Shopify Subscriptions app and set up + solution: + Make sure you've installed the Shopify Subscriptions app and set up selling plans for subscription products in your Shopify admin. - issue: I'm not seeing the subscription options on my product pages. - solution: Make sure you've installed the Shopify Subscriptions app and set up + solution: + Make sure you've installed the Shopify Subscriptions app and set up selling plans for subscription products in your Shopify admin. - issue: I'm not seeing the subscription details on my cart line items. - solution: Make sure you've installed the Shopify Subscriptions app and set up + solution: + Make sure you've installed the Shopify Subscriptions app and set up selling plans for subscription products in your Shopify admin. commit: 1040066d20b52667756fd1ebffd8607602a735b4 diff --git a/cookbook/recipes/third-party-api/README.md b/cookbook/recipes/third-party-api/README.md index 4f3042d913..4c5196c129 100644 --- a/cookbook/recipes/third-party-api/README.md +++ b/cookbook/recipes/third-party-api/README.md @@ -1,21 +1,22 @@ # Third-party API queries and caching in Hydrogen -This recipe integrates third-party GraphQL APIs into your Hydrogen storefront -with Oxygen's powerful sub-request caching system. Using the Rick & Morty API as an example, +This recipe integrates third-party GraphQL APIs into your Hydrogen storefront +with Oxygen's powerful sub-request caching system. Using the Rick & Morty API as an example, you'll learn how to: -1. **Create a cached GraphQL client** - Build a reusable client factory that minifies queries, +1. **Create a cached GraphQL client** - Build a reusable client factory that minifies queries, handles error handling, and integrates with Oxygen's caching infrastructure. -2. **Integrate with Hydrogen's context** - Add the third-party client to the global context +2. **Integrate with Hydrogen's context** - Add the third-party client to the global context system, making it available in all routes and actions throughout your application. -3. **Query external APIs efficiently** - Fetch data from third-party sources in parallel +3. **Query external APIs efficiently** - Fetch data from third-party sources in parallel with Shopify API calls, leveraging Oxygen's caching to minimize latency and API calls. ## Use cases This pattern is perfect for integrating: + - **CMS platforms** (Contentful, Sanity, Strapi) - **Review systems** (Yotpo, Judge.me, Reviews.io) - **Analytics services** (custom dashboards, reporting APIs) @@ -49,8 +50,8 @@ This pattern is perfect for integrating: _New files added to the template by this recipe._ -| File | Description | -| --- | --- | +| File | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | [app/lib/createRickAndMortyClient.server.ts](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/cookbook/recipes/third-party-api/ingredients/templates/skeleton/app/lib/createRickAndMortyClient.server.ts) | A GraphQL client factory for third-party APIs with Oxygen caching support | ## Steps @@ -63,29 +64,29 @@ Add documentation explaining how to integrate external GraphQL APIs with Oxygen
    -~~~diff +````diff index c584e5370..4c8dceadf 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -1,6 +1,6 @@ -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Third-party API Integration - --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, plus an example of integrating third-party GraphQL APIs with Oxygen caching. - + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, plus an example of integrating third-party GraphQL APIs with Oxygen caching. + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -40,6 +40,46 @@ npm run build npm run dev ``` - + +## Third-party API Integration + +This example demonstrates how to query third-party GraphQL APIs with Oxygen's sub-request caching. The example uses the public [Rick & Morty API](https://rickandmortyapi.com/documentation/#graphql) to show how to: + +1. Create a cached GraphQL client for third-party APIs -+2. Integrate the client into Hydrogen's context ++2. Integrate the client into Hydrogen's context +3. Query and display data from external APIs alongside Shopify data + +### Key files for third-party API integration @@ -121,9 +122,9 @@ index c584e5370..4c8dceadf 100644 +This pattern can be adapted for any third-party API integration including CMS systems, review platforms, analytics services, or custom backend APIs. + ## Setup for using Customer Account API (`/account` section) - + Follow step 1 and 2 of -~~~ +````
    @@ -136,13 +137,15 @@ This client handles query minification, error handling, and cache key generation
    -~~~ts +```ts import { createWithCache, CacheLong, type CachingStrategy, } from '@shopify/hydrogen'; +export const OPERATION_NAME_PATTERN = /^(query|mutation)\s\w+/; + export function createRickAndMortyClient({ cache, waitUntil, @@ -177,7 +180,7 @@ export function createRickAndMortyClient({ shouldCacheResponse: (body) => !body?.error, cacheKey: ['r&m', body], displayName: - 'Rick & Morty - ' + query.match(/^(query|mutation)\s\w+/)?.[0], + 'Rick & Morty - ' + query.match(OPERATION_NAME_PATTERN)?.[0], }, ); @@ -193,13 +196,13 @@ export function createRickAndMortyClient({ }; } -function minifyQuery(string: T) { +export function minifyQuery(string: T) { return string .replace(/\s*#.*$/gm, '') // Remove GQL comments .replace(/\s+/gm, ' ') // Minify spaces .trim() as T; } -~~~ +```
    @@ -212,15 +215,16 @@ in all routes. Also update TypeScript declarations for proper type support.
    -~~~diff +```diff index 692d5ae17..0635384ad 100644 --- a/templates/skeleton/app/lib/context.ts +++ b/templates/skeleton/app/lib/context.ts -@@ -1,25 +1,10 @@ - import {createHydrogenContext} from '@shopify/hydrogen'; +@@ -2,29 +2,10 @@ import {AppSession} from '~/lib/session'; import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; -- + import type {CartApiQueryFragment} from 'storefrontapi.generated'; ++import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server'; + -// Define the additional context object -const additionalContext = { - // Additional context for custom properties, CMS clients, 3P SDKs, etc. @@ -235,19 +239,22 @@ index 692d5ae17..0635384ad 100644 - -declare global { - interface HydrogenAdditionalContext extends AdditionalContextType {} +- +- // Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so +- // that context.cart.get() and all cart mutations return the extended cart type. +- interface HydrogenCustomCartFragment extends CartApiQueryFragment {} -} -+import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server'; - +- /** -- * Creates Hydrogen context for React Router 7.9.x -+ * Creates Hydrogen context for React Router 7.9.x with third-party API support +- * Creates the Hydrogen context used by React Router loaders and actions. ++ * Creates the Hydrogen context used by React Router loaders and actions with third-party API support. * Returns HydrogenRouterContextProvider with hybrid access patterns * */ export async function createHydrogenRouterContext( -@@ -40,6 +25,19 @@ export async function createHydrogenRouterContext( +@@ -45,6 +26,19 @@ AppSession.init(request, [env.SESSION_SECRET]), ]); - + + // @description Create a Rick and Morty client for third-party GraphQL queries with Oxygen caching + const rickAndMorty = createRickAndMortyClient({ + cache, @@ -264,8 +271,8 @@ index 692d5ae17..0635384ad 100644 const hydrogenContext = createHydrogenContext( { env, -@@ -58,3 +56,12 @@ export async function createHydrogenRouterContext( - +@@ -63,3 +57,13 @@ + return hydrogenContext; } + @@ -276,9 +283,9 @@ index 692d5ae17..0635384ad 100644 + +declare global { + interface HydrogenAdditionalContext extends AdditionalContextType {} ++ interface HydrogenCustomCartFragment extends CartApiQueryFragment {} +} -\ No newline at end of file -~~~ +```
    @@ -287,11 +294,11 @@ index 692d5ae17..0635384ad 100644 Update the homepage to fetch data from the third-party API and display it alongside Shopify data. This demonstrates parallel data fetching and proper caching strategies. -#### File: [app/routes/_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/_index.tsx) +#### File: [app/routes/\_index.tsx](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/app/routes/_index.tsx)
    -~~~diff +```diff index 237edc911..d65e12537 100644 --- a/templates/skeleton/app/routes/_index.tsx +++ b/templates/skeleton/app/routes/_index.tsx @@ -318,7 +325,7 @@ index 237edc911..d65e12537 100644 + cache: CacheShort(), + }), ]); - + return { isShopLinked: Boolean(context.env.PUBLIC_STORE_DOMAIN), featuredCollection: collections.nodes[0], @@ -326,7 +333,7 @@ index 237edc911..d65e12537 100644 + characters, }; } - + @@ -63,12 +69,50 @@ export default function Homepage() { return (
    @@ -338,7 +345,7 @@ index 237edc911..d65e12537 100644
    ); } - + +// @description Component to display Rick & Morty characters fetched from third-party API +function ThirdPartyApiExample({characters}: {characters: any}) { + return ( @@ -381,7 +388,7 @@ index 237edc911..d65e12537 100644 @@ -147,6 +191,18 @@ const FEATURED_COLLECTION_QUERY = `#graphql } ` as const; - + +// @description GraphQL query for Rick & Morty characters from third-party API +const CHARACTERS_QUERY = `#graphql:rickAndMorty + query Characters { @@ -397,76 +404,29 @@ index 237edc911..d65e12537 100644 const RECOMMENDED_PRODUCTS_QUERY = `#graphql fragment RecommendedProduct on Product { id -~~~ +```
    -### Step 4: package.json - - - -#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json) - -~~~diff -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.14.0", - "react-router-dom": "7.14.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.14.0", - "@react-router/fs-routes": "7.14.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", -~~~ - ## Next steps After applying this recipe, you can: 1. **Customize the caching strategy** - Adjust cache durations based on your data freshness requirements: + ```ts // Short cache for frequently changing data - cache: CacheShort() - + cache: CacheShort(); + // Long cache for stable data - cache: CacheLong() - + cache: CacheLong(); + // Custom cache duration cache: CacheCustom({ mode: 'must-revalidate', maxAge: 60, // 1 minute staleWhileRevalidate: 60 * 5, // 5 minutes - }) + }); ``` 2. **Add more third-party APIs** - Create additional clients following the same pattern: @@ -475,6 +435,7 @@ After applying this recipe, you can: - Update TypeScript types in the global declaration 3. **Implement error handling** - Add try-catch blocks and fallback UI: + ```ts try { const data = await context.yourApi.query(QUERY); @@ -493,4 +454,4 @@ After applying this recipe, you can: 5. **Replace the example** - Swap out the Rick & Morty API with your actual third-party service: - Update the API endpoint in the client - Modify queries to match your API schema - - Update components to display your data \ No newline at end of file + - Update components to display your data diff --git a/cookbook/recipes/third-party-api/patches/README.md.1764cd.patch b/cookbook/recipes/third-party-api/patches/README.md.1764cd.patch index 197ed1be63..1f3effe0a6 100644 --- a/cookbook/recipes/third-party-api/patches/README.md.1764cd.patch +++ b/cookbook/recipes/third-party-api/patches/README.md.1764cd.patch @@ -5,11 +5,11 @@ index c584e5370..4c8dceadf 100644 -# Hydrogen template: Skeleton +# Hydrogen template: Skeleton with Third-party API Integration --Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. -+Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, plus an example of integrating third-party GraphQL APIs with Oxygen caching. +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [React Router](https://reactrouter.com/), the full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, plus an example of integrating third-party GraphQL APIs with Oxygen caching. [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) - [Get familiar with Remix](https://remix.run/docs/en/v1) + [Get familiar with React Router](https://reactrouter.com/) @@ -40,6 +40,46 @@ npm run build npm run dev ``` diff --git a/cookbook/recipes/third-party-api/patches/context.ts.c15041.patch b/cookbook/recipes/third-party-api/patches/context.ts.c15041.patch index 15a33ea0fb..5f3e2a8c45 100644 --- a/cookbook/recipes/third-party-api/patches/context.ts.c15041.patch +++ b/cookbook/recipes/third-party-api/patches/context.ts.c15041.patch @@ -28,8 +28,8 @@ index 692d5ae17..0635384ad 100644 -} - /** -- * Creates Hydrogen context for React Router 7.9.x -+ * Creates Hydrogen context for React Router 7.9.x with third-party API support +- * Creates the Hydrogen context used by React Router loaders and actions. ++ * Creates the Hydrogen context used by React Router loaders and actions with third-party API support. * Returns HydrogenRouterContextProvider with hybrid access patterns * */ export async function createHydrogenRouterContext( diff --git a/cookbook/recipes/third-party-api/patches/package.json.8e0ff5.patch b/cookbook/recipes/third-party-api/patches/package.json.8e0ff5.patch deleted file mode 100644 index 84500718fd..0000000000 --- a/cookbook/recipes/third-party-api/patches/package.json.8e0ff5.patch +++ /dev/null @@ -1,39 +0,0 @@ -index 0bb332639..651bbfffa 100644 ---- a/templates/skeleton/package.json -+++ b/templates/skeleton/package.json -@@ -14,12 +14,12 @@ - }, - "prettier": "@shopify/prettier-config", - "dependencies": { -- "@shopify/hydrogen": "workspace:*", -+ "@shopify/hydrogen": "2026.4.0", - "graphql": "^16.10.0", - "graphql-tag": "^2.12.6", - "isbot": "^5.1.22", -- "react": "catalog:", -- "react-dom": "catalog:", -+ "react": "^18.3.1", -+ "react-dom": "^18.3.1", - "react-router": "7.16.0", - "react-router-dom": "7.16.0" - }, -@@ -31,14 +31,14 @@ - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", - "@shopify/cli": "3.93.2", -- "@shopify/hydrogen-codegen": "workspace:*", -- "@shopify/mini-oxygen": "workspace:*", -+ "@shopify/hydrogen-codegen": "0.3.3", -+ "@shopify/mini-oxygen": "4.0.2", - "@shopify/oxygen-workers-types": "^4.1.6", -- "@shopify/prettier-config": "catalog:", -+ "@shopify/prettier-config": "^1.1.2", - "@total-typescript/ts-reset": "^0.6.1", - "@types/eslint": "^9.6.1", -- "@types/react": "catalog:", -- "@types/react-dom": "catalog:", -+ "@types/react": "^18.3.28", -+ "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "eslint": "^9.18.0", diff --git a/cookbook/recipes/third-party-api/recipe.yaml b/cookbook/recipes/third-party-api/recipe.yaml index 2422ff346b..4f9d3aa048 100644 --- a/cookbook/recipes/third-party-api/recipe.yaml +++ b/cookbook/recipes/third-party-api/recipe.yaml @@ -53,15 +53,16 @@ ingredients: deletedFiles: [] steps: - type: PATCH - step: "1" + step: '1' name: Document third-party API integration - description: Add documentation explaining how to integrate external GraphQL APIs + description: + Add documentation explaining how to integrate external GraphQL APIs with Oxygen caching. diffs: - file: README.md patchFile: README.md.1764cd.patch - type: NEW_FILE - step: "2" + step: '2' name: Create the third-party API client description: | Create a new GraphQL client factory that integrates with Oxygen's caching system. @@ -69,7 +70,7 @@ steps: ingredients: - path: templates/skeleton/app/lib/createRickAndMortyClient.server.ts - type: PATCH - step: "3" + step: '3' name: Add the client to Hydrogen context description: | Import the Rick and Morty client and add it to the Hydrogen context so it's available @@ -78,7 +79,7 @@ steps: - file: app/lib/context.ts patchFile: context.ts.c15041.patch - type: PATCH - step: "4" + step: '4' name: Query and display third-party data description: | Update the homepage to fetch data from the third-party API and display it alongside @@ -86,13 +87,6 @@ steps: diffs: - file: app/routes/_index.tsx patchFile: _index.tsx.8ba83c.patch - - type: PATCH - step: "4" - name: package.json - description: null - diffs: - - file: package.json - patchFile: package.json.8e0ff5.patch nextSteps: | After applying this recipe, you can: diff --git a/docs/preview/package.json b/docs/preview/package.json index 26de872bae..ab3a484b0b 100644 --- a/docs/preview/package.json +++ b/docs/preview/package.json @@ -8,20 +8,19 @@ "start": "react-router-serve build/server/index.js" }, "dependencies": { - "@react-router/node": "~7.16.0", - "@react-router/serve": "~7.16.0", + "@react-router/node": "8.0.1", + "@react-router/serve": "8.0.1", "he": "^1.2.0", "isbot": "^5.1.21", "marked": "^9.1.0", "react": "catalog:", "react-dom": "catalog:", - "react-router": "~7.16.0", - "react-router-dom": "~7.16.0", + "react-router": "8.0.1", "react-syntax-highlighter": "^15.5.0" }, "devDependencies": { - "@react-router/dev": "~7.16.0", - "@react-router/fs-routes": "~7.16.0", + "@react-router/dev": "8.0.1", + "@react-router/fs-routes": "8.0.1", "@tailwindcss/vite": "^4.0.14", "@types/he": "^1.2.1", "@types/react": "catalog:", @@ -31,6 +30,6 @@ "vite": "^8.0.1" }, "engines": { - "node": "^22 || ^24" + "node": "^22.22.0 || ^24" } } diff --git a/docs/shopify-dev/analytics-setup/ts/app/routes/collections.$handle.tsx b/docs/shopify-dev/analytics-setup/ts/app/routes/collections.$handle.tsx index 725ed6ba1d..337ae65bca 100644 --- a/docs/shopify-dev/analytics-setup/ts/app/routes/collections.$handle.tsx +++ b/docs/shopify-dev/analytics-setup/ts/app/routes/collections.$handle.tsx @@ -15,8 +15,10 @@ import type {ProductItemFragment} from 'storefrontapi.generated'; import {useVariantUrl} from '~/lib/variants'; import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; -export const meta: MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}]; +export const meta: MetaFunction = ({loaderData}) => { + return [ + {title: `Hydrogen | ${loaderData?.collection.title ?? ''} Collection`}, + ]; }; export async function loader(args: LoaderFunctionArgs) { diff --git a/docs/shopify-dev/analytics-setup/ts/app/routes/products.$handle.tsx b/docs/shopify-dev/analytics-setup/ts/app/routes/products.$handle.tsx index 4843b7f906..8b4bd20528 100644 --- a/docs/shopify-dev/analytics-setup/ts/app/routes/products.$handle.tsx +++ b/docs/shopify-dev/analytics-setup/ts/app/routes/products.$handle.tsx @@ -1,4 +1,8 @@ -import {type LoaderFunctionArgs, useLoaderData, type MetaFunction} from 'react-router'; +import { + type LoaderFunctionArgs, + useLoaderData, + type MetaFunction, +} from 'react-router'; import { getSelectedProductOptions, // [START import] @@ -13,8 +17,8 @@ import {ProductPrice} from '~/components/ProductPrice'; import {ProductImage} from '~/components/ProductImage'; import {ProductForm} from '~/components/ProductForm'; -export const meta: MetaFunction = ({data}) => { - return [{title: `Hydrogen | ${data?.product.title ?? ''}`}]; +export const meta: MetaFunction = ({loaderData}) => { + return [{title: `Hydrogen | ${loaderData?.product.title ?? ''}`}]; }; export async function loader(args: LoaderFunctionArgs) { diff --git a/package.json b/package.json index 9afddb40f2..4205789c56 100644 --- a/package.json +++ b/package.json @@ -62,11 +62,10 @@ "dependencies": { "@shopify/cli-hydrogen": "workspace:*", "nested-error-stacks": "^2.1.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-intersection-observer": "^8.32.0", - "react-router": "7.16.0", - "react-router-dom": "7.16.0", + "react-router": "8.0.1", "uuid": "^11.1.0" }, "devDependencies": { @@ -77,8 +76,8 @@ "@eslint/js": "9.19.0", "@playwright/test": "^1.57.0", "@qwik.dev/partytown": "^0.11.2", - "@react-router/dev": "7.16.0", - "@react-router/fs-routes": "7.16.0", + "@react-router/dev": "8.0.1", + "@react-router/fs-routes": "8.0.1", "@shopify/cli": "3.91.1", "@total-typescript/ts-reset": "^0.6.1", "@types/eslint": "9.6.1", @@ -113,7 +112,7 @@ "@shopify/prettier-config": "catalog:" }, "engines": { - "node": "^22 || ^24", + "node": "^22.22.0 || ^24", "pnpm": ">=10.16.1" }, "lint-staged": { diff --git a/packages/cli/package.json b/packages/cli/package.json index d9628afaf8..74860d694a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,7 +21,7 @@ "test:watch": "cross-env SHOPIFY_UNIT_TEST=1 LANG=en-US.UTF-8 vitest --test-timeout=60000" }, "devDependencies": { - "@react-router/dev": "7.16.0", + "@react-router/dev": "8.0.1", "@types/diff": "^5.0.2", "@types/gunzip-maybe": "^1.4.0", "@types/node": "catalog:", @@ -63,11 +63,11 @@ }, "peerDependencies": { "@graphql-codegen/cli": "^5.0.2", - "@react-router/dev": "~7.16.0", + "@react-router/dev": "~8.0.1", "@shopify/hydrogen-codegen": "workspace:*", "@shopify/mini-oxygen": "workspace:*", "graphql-config": "^5.0.3", - "vite": "^5.1.0 || ^6.2.0 || ^7.0.0 || ^8.0.0" + "vite": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@graphql-codegen/cli": { @@ -106,7 +106,7 @@ "oclif.manifest.json" ], "engines": { - "node": "^20 || ^22 || ^24" + "node": "^22.22.0 || ^24" }, "oclif": { "commands": "dist/commands", diff --git a/packages/cli/src/commands/hydrogen/build.ts b/packages/cli/src/commands/hydrogen/build.ts index 8fc0d63f46..6b18c7d3e3 100644 --- a/packages/cli/src/commands/hydrogen/build.ts +++ b/packages/cli/src/commands/hydrogen/build.ts @@ -1,6 +1,6 @@ import {Flags} from '@oclif/core'; import Command from '@shopify/cli-kit/node/base-command'; -import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'; +import {resolvePath, joinPath, dirname} from '@shopify/cli-kit/node/path'; import { outputWarn, collectLog, @@ -158,6 +158,7 @@ export async function runBuild({ } const serverMinify = userViteConfig.build?.minify ?? true; + const serverBundleDir = dirname(serverOutFile); const commonConfig = { root, mode: process.env.NODE_ENV, @@ -277,8 +278,8 @@ export async function runBuild({ if (!watch) { await Promise.all([ removeFile(joinPath(clientOutDir, '.vite')), - removeFile(joinPath(serverOutDir, '.vite')), - removeFile(joinPath(serverOutDir, 'assets')), + removeFile(joinPath(serverBundleDir, '.vite')), + removeFile(joinPath(serverBundleDir, 'assets')), ]); } @@ -297,11 +298,11 @@ export async function runBuild({ if (!watch && process.env.NODE_ENV !== 'development') { if (bundleStats) { const bundleAnalysisPath = - 'file://' + joinPath(serverOutDir, BUNDLE_ANALYZER_HTML_FILE); + 'file://' + joinPath(serverBundleDir, BUNDLE_ANALYZER_HTML_FILE); outputInfo( outputContent`${ - (await getBundleAnalysisSummary(serverOutDir)) || '\n' + (await getBundleAnalysisSummary(serverBundleDir)) || '\n' }\n │\n └─── ${outputToken.link( 'Complete analysis: ' + bundleAnalysisPath, bundleAnalysisPath, diff --git a/packages/cli/src/commands/hydrogen/upgrade.test.ts b/packages/cli/src/commands/hydrogen/upgrade.test.ts index f9fbff5fae..fe3d6ce905 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.test.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.test.ts @@ -169,6 +169,29 @@ const REACT_ROUTER_RELEASE = { date: '2025-04-24', } satisfies Release; +const REACT_ROUTER_8_RELEASE = { + title: 'React Router 8 support', + version: '2026.5.0', + hash: '-', + commit: 'https://github.com/Shopify/hydrogen/pull/0000', + pr: 'https://github.com/Shopify/hydrogen/pull/0000', + dependencies: { + '@shopify/hydrogen': '2026.5.0', + react: '19.2.7', + 'react-dom': '19.2.7', + 'react-router': '8.0.1', + }, + devDependencies: { + '@react-router/dev': '8.0.1', + '@react-router/fs-routes': '8.0.1', + }, + removeDependencies: ['react-router-dom'], + dependenciesMeta: {}, + fixes: [], + features: [], + date: '2026-06-17', +} satisfies Release; + /** * Creates a temporary directory in OS temp (not in repo) */ @@ -1845,6 +1868,34 @@ describe('upgrade', async () => { expect(args).toEqual(result); }); + it('does not install react-router-dom when upgrading to React Router 8', async () => { + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON_WITH_REACT_ROUTER.dependencies, + react: '18.3.1', + 'react-dom': '18.3.1', + 'react-router': '7.16.0', + 'react-router-dom': '7.16.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON_WITH_REACT_ROUTER.devDependencies, + '@react-router/dev': '7.16.0', + '@react-router/fs-routes': '7.16.0', + }; + + const args = buildUpgradeCommandArgs({ + selectedRelease: REACT_ROUTER_8_RELEASE, + currentDependencies, + }); + + expect(args).toEqual([ + '@shopify/hydrogen@2026.5.0', + 'react@19.2.7', + 'react-dom@19.2.7', + 'react-router@8.0.1', + '@react-router/dev@8.0.1', + '@react-router/fs-routes@8.0.1', + ]); + expect(args).not.toContain('react-router-dom@8.0.1'); + }); + it('does not install an optional dependency that was not installed', async () => { const {releases} = await getChangelog(); diff --git a/packages/cli/src/commands/hydrogen/upgrade.ts b/packages/cli/src/commands/hydrogen/upgrade.ts index 397e377744..1e620b38bc 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.ts @@ -74,6 +74,20 @@ type ChangeLog = { version: string; }; +const reactRouterDomPackageName = 'react-router-dom'; +const reactRouterDomRemovalMajorVersion = 8; +const reactRouterPackagesWithoutDom = [ + 'react-router', + '@react-router/dev', + '@react-router/fs-routes', +] as const; +const reactRouterPackagesWithDom = [ + 'react-router', + reactRouterDomPackageName, + '@react-router/dev', + '@react-router/fs-routes', +] as const; + export type CumulativeRelease = { features: Array; fixes: Array; @@ -1172,23 +1186,24 @@ function appendReactRouterDependencies({ }) { const command: string[] = []; const targetVersion = getAbsoluteVersion(selectedReactRouter[1]); - - // Standard React Router packages that should be kept in sync - const reactRouterPackages = [ - 'react-router', - 'react-router-dom', - '@react-router/dev', - '@react-router/fs-routes', - ]; + const packages = shouldInstallReactRouterDom(targetVersion) + ? reactRouterPackagesWithDom + : reactRouterPackagesWithoutDom; // Always install/upgrade all React Router packages to ensure consistency - for (const packageName of reactRouterPackages) { + for (const packageName of packages) { command.push(`${packageName}@${targetVersion}`); } return command; } +function shouldInstallReactRouterDom(targetVersion: string) { + if (targetVersion === 'next') return false; + + return semver.major(targetVersion) < reactRouterDomRemovalMajorVersion; +} + /** * Gets the absolute version from a pinned or unpinned version */ diff --git a/packages/cli/src/lib/setups/i18n/replacers.test.ts b/packages/cli/src/lib/setups/i18n/replacers.test.ts index b4130dea93..6104ec22a4 100644 --- a/packages/cli/src/lib/setups/i18n/replacers.test.ts +++ b/packages/cli/src/lib/setups/i18n/replacers.test.ts @@ -62,7 +62,6 @@ describe('i18n replacers', () => { import type { CartApiQueryFragment } from "storefrontapi.generated"; import { getLocaleFromRequest } from "~/lib/i18n"; - // Define the additional context object const additionalContext = { // Additional context for custom properties, CMS clients, 3P SDKs, etc. // These will be available as both context.propertyName and context.get(propertyContext) @@ -71,21 +70,19 @@ describe('i18n replacers', () => { // reviews: await createReviewsClient(env), } as const; - // Automatically augment HydrogenAdditionalContext with the additional context type type AdditionalContextType = typeof additionalContext; declare global { interface HydrogenAdditionalContext extends AdditionalContextType {} // Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so - // that context.cart.get() and all cart mutations return the extended cart type. + // that cart APIs return the extended cart type. interface HydrogenCustomCartFragment extends CartApiQueryFragment {} } /** - * Creates Hydrogen context for React Router 7.9.x - * Returns HydrogenRouterContextProvider with hybrid access patterns - * */ + * Creates the Hydrogen context used by React Router loaders and actions. + */ export async function createHydrogenRouterContext( request: Request, env: Env, diff --git a/packages/cli/src/lib/setups/routes/generate.ts b/packages/cli/src/lib/setups/routes/generate.ts index a008a4273a..48beb82cf2 100644 --- a/packages/cli/src/lib/setups/routes/generate.ts +++ b/packages/cli/src/lib/setups/routes/generate.ts @@ -395,7 +395,7 @@ async function findRouteDependencies( relativePath(dirname(filePath), appDirectory) || '.', ); - // Skip attempting to resolve React Router 7.9.x generated type imports + // Skip attempting to resolve React Router generated type imports // These are virtual imports that will be generated post-scaffolding if (match.includes('/+types/')) { continue; diff --git a/packages/cli/src/lib/vite-config.ts b/packages/cli/src/lib/vite-config.ts index 761a477cc1..34ce4fcca8 100644 --- a/packages/cli/src/lib/vite-config.ts +++ b/packages/cli/src/lib/vite-config.ts @@ -77,19 +77,28 @@ export async function getViteConfig( const {appDirectory, serverBuildFile, routes} = getReactRouterOrRemixConfigFromVite(resolvedViteConfig); - const serverOutDir = resolvedViteConfig.build.outDir; + const serverOutDir = resolvePath( + resolvedViteConfig.root, + resolvedViteConfig.build.outDir, + ); const clientOutDir = serverOutDir.replace(/server$/, 'client'); const rollupOutput = resolvedViteConfig.build.rollupOptions.output; const {entryFileNames} = (Array.isArray(rollupOutput) ? rollupOutput[0] : rollupOutput) ?? {}; + const defaultServerBuildFile = serverOutDir.endsWith('server') + ? 'index.js' + : joinPath('server', 'index.js'); - const serverOutFile = joinPath( - serverOutDir, + const serverEntryFileName = typeof entryFileNames === 'string' ? entryFileNames - : (serverBuildFile ?? 'index.js'), - ); + : (serverBuildFile ?? defaultServerBuildFile); + const serverEntryFilePath = + serverOutDir.endsWith('server') || serverEntryFileName.includes('/') + ? serverEntryFileName + : joinPath('server', serverEntryFileName); + const serverOutFile = joinPath(serverOutDir, serverEntryFilePath); const ssrEntry = ssrEntryFlag ?? resolvedViteConfig.build.ssr; const resolvedSsrEntry = resolvePath( diff --git a/packages/hydrogen-react/package.json b/packages/hydrogen-react/package.json index 76ae0cc1e2..02858f1f91 100644 --- a/packages/hydrogen-react/package.json +++ b/packages/hydrogen-react/package.json @@ -133,7 +133,8 @@ "@ladle/react": "^5.0.1", "@shopify/generate-docs": "catalog:", "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^14.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/hydrogen-react/src/AddToCartButton.tsx b/packages/hydrogen-react/src/AddToCartButton.tsx index f9198d85d3..9d3a5dc9e7 100644 --- a/packages/hydrogen-react/src/AddToCartButton.tsx +++ b/packages/hydrogen-react/src/AddToCartButton.tsx @@ -8,6 +8,7 @@ import { } from './BaseButton.js'; import * as React from 'react'; import {CartLineParentInput} from './storefront-api-types.js'; +import type {JSX} from 'react'; export interface AddToCartButtonPropsBase { /** An array of cart line attributes that belong to the item being added to the cart. */ diff --git a/packages/hydrogen-react/src/BaseButton.tsx b/packages/hydrogen-react/src/BaseButton.tsx index 1c9f8d130a..0db285abcd 100644 --- a/packages/hydrogen-react/src/BaseButton.tsx +++ b/packages/hydrogen-react/src/BaseButton.tsx @@ -1,4 +1,5 @@ import {ReactNode, Ref, useCallback} from 'react'; +import type {JSX} from 'react'; export interface CustomBaseButtonProps { /** Provide a React element or component to render as the underlying button. Note: for accessibility compliance, almost always you should use a `button` element, or a component that renders an underlying button. */ diff --git a/packages/hydrogen-react/src/BuyNowButton.tsx b/packages/hydrogen-react/src/BuyNowButton.tsx index eb3b7d546c..5e1b46c48b 100644 --- a/packages/hydrogen-react/src/BuyNowButton.tsx +++ b/packages/hydrogen-react/src/BuyNowButton.tsx @@ -5,6 +5,7 @@ import { type BaseButtonProps, type CustomBaseButtonProps, } from './BaseButton.js'; +import type {JSX} from 'react'; interface BuyNowButtonPropsBase { /** The item quantity. Defaults to 1. */ diff --git a/packages/hydrogen-react/src/CartCheckoutButton.tsx b/packages/hydrogen-react/src/CartCheckoutButton.tsx index 7fa12ae797..96258c4ae4 100644 --- a/packages/hydrogen-react/src/CartCheckoutButton.tsx +++ b/packages/hydrogen-react/src/CartCheckoutButton.tsx @@ -5,6 +5,7 @@ import { type BaseButtonProps, type CustomBaseButtonProps, } from './BaseButton.js'; +import type {JSX} from 'react'; type ChildrenProps = { /** A `ReactNode` element. */ diff --git a/packages/hydrogen-react/src/CartCost.tsx b/packages/hydrogen-react/src/CartCost.tsx index 2497208e16..caea5b0636 100644 --- a/packages/hydrogen-react/src/CartCost.tsx +++ b/packages/hydrogen-react/src/CartCost.tsx @@ -1,5 +1,6 @@ import {Money, type MoneyPropsBase} from './Money.js'; import {useCart} from './CartProvider.js'; +import type {JSX} from 'react'; interface CartCostPropsBase { /** A string type that defines the type of cost needed. Valid values: `total`, `subtotal`, `tax`, or `duty`. */ diff --git a/packages/hydrogen-react/src/CartLineProvider.tsx b/packages/hydrogen-react/src/CartLineProvider.tsx index c392e43b87..8a16deba6a 100644 --- a/packages/hydrogen-react/src/CartLineProvider.tsx +++ b/packages/hydrogen-react/src/CartLineProvider.tsx @@ -4,6 +4,7 @@ import { type CartLine, } from './storefront-api-types.js'; import type {PartialDeep} from 'type-fest'; +import type {JSX} from 'react'; type CartLinePartialDeep = PartialDeep< CartLine | ComponentizableCartLine, diff --git a/packages/hydrogen-react/src/CartLineQuantity.tsx b/packages/hydrogen-react/src/CartLineQuantity.tsx index 4490c7b074..98e5c978c2 100644 --- a/packages/hydrogen-react/src/CartLineQuantity.tsx +++ b/packages/hydrogen-react/src/CartLineQuantity.tsx @@ -1,5 +1,6 @@ import type {ComponentPropsWithoutRef, ElementType} from 'react'; import {useCartLine} from './CartLineProvider.js'; +import type {JSX} from 'react'; /** @publicDocs */ interface CartLineQuantityBaseProps< diff --git a/packages/hydrogen-react/src/CartLineQuantityAdjustButton.tsx b/packages/hydrogen-react/src/CartLineQuantityAdjustButton.tsx index 64a7bd6d5c..742d0b7d32 100644 --- a/packages/hydrogen-react/src/CartLineQuantityAdjustButton.tsx +++ b/packages/hydrogen-react/src/CartLineQuantityAdjustButton.tsx @@ -3,6 +3,7 @@ import {useCart} from './CartProvider.js'; import {useCartLine} from './CartLineProvider.js'; import {BaseButton, type BaseButtonProps} from './BaseButton.js'; import type {CartLineUpdateInput} from './storefront-api-types.js'; +import type {JSX} from 'react'; interface CartLineQuantityAdjustButtonBaseProps { /** The adjustment for a cart line's quantity. Valid values: `increase` (default), `decrease`, or `remove`. */ diff --git a/packages/hydrogen-react/src/CartProvider.tsx b/packages/hydrogen-react/src/CartProvider.tsx index d7bd3ae06d..52d8f265a4 100644 --- a/packages/hydrogen-react/src/CartProvider.tsx +++ b/packages/hydrogen-react/src/CartProvider.tsx @@ -32,6 +32,7 @@ import {CART_ID_STORAGE_KEY} from './cart-constants.js'; import {PartialDeep} from 'type-fest'; import {defaultCartFragment} from './cart-queries.js'; import {useShop} from './ShopifyProvider.js'; +import type {JSX} from 'react'; export const CartContext = createContext(null); diff --git a/packages/hydrogen-react/src/ExternalVideo.tsx b/packages/hydrogen-react/src/ExternalVideo.tsx index b6d98827f3..4716f2b414 100644 --- a/packages/hydrogen-react/src/ExternalVideo.tsx +++ b/packages/hydrogen-react/src/ExternalVideo.tsx @@ -1,6 +1,7 @@ import type {ExternalVideo as ExternalVideoType} from './storefront-api-types.js'; import type {Entries, PartialDeep} from 'type-fest'; import {forwardRef, IframeHTMLAttributes} from 'react'; +import type {JSX} from 'react'; /** * Takes in the same props as a native `