Important: This documentation covers Yarn 1 (Classic).
For Yarn 2+ docs and migration guide, see yarnpkg.com.

Package detail

@shopify/cli

Shopify647.9kMIT3.78.2TypeScript support: included

A CLI tool to build for the Shopify platform

shopify, shopify-cli, shopify-partners

readme

Shopify CLI

Twitter Followers License badge

With the Shopify command line interface (Shopify CLI 3.0), you can:

  • initialize, build, dev, and deploy Shopify apps, extensions, functions and themes
  • build custom storefronts and manage their hosting

Learn more in the commands docs.

 

Before you begin

Install the latest version of Node.js and npm (or another package manager of your choice).

 

Developing apps with Shopify CLI

When you’re building a Shopify app, you can initialize your project using your preferred package manager. A single command will install all the dependencies you need — including Shopify CLI itself.

Initialize your project using one of the following commands:

  • npm init @shopify/app@latest (installed by default with Node)
  • pnpm create @shopify/create-app@latest
  • yarn create @shopify/app
  • bun create @shopify/app@latest (experimental)

Learn more in the docs: Create an app

 

Developing themes with Shopify CLI

To work with themes, the CLI needs to be installed globally with:

  • npm install -g @shopify/cli @shopify/theme

You can also use do it through Homebrew on macOS: brew tap shopify/shopify && brew install shopify-cli

Learn more in the docs: Shopify CLI for themes

 

Developing Hydrogen custom storefronts with Shopify CLI

The Hydrogen code lives here: https://github.com/Shopify/hydrogen/tree/main/packages/cli

Learn more in the docs: Shopify CLI for Hydrogen storefronts

 

Help 🖐

If you encounter issues using the CLI or have feedback you'd like to share with us, below are some options:

Contribute 👩🏽‍💻

If you'd like to contribute to the project, check out the contributors docs and the steps to get started.

 

References

changelog

skeleton

2025.1.6

Patch Changes

  • Moved the Layout component back into root.tsx to avoid issues with styled errors. (#2829) by @ruggishop

    1. If you have a separate app/layout.tsx file, delete it and move its default exported component into your root.tsx. For example:

      // /app/root.tsx
      export function Layout({children}: {children?: React.ReactNode}) {
        const nonce = useNonce();
        const data = useRouteLoaderData<RootLoader>('root');
      
        return (
          <html lang="en">
          ...
        );
      }

2025.1.5

Patch Changes

2025.1.4

Patch Changes

  • Updates the @shopify/cli, @shopify/cli-kit and @shopify/plugin-cloudflare dependencies to 3.77.1. (#2816) by @seanparsons

2025.1.3

Patch Changes

  • Bump Remix to 2.16.1 and vite to 6.2.0 (#2784) by @wizardlyhel

  • Update skeleton and create-hydrogen cli to 3.75.4 (#2769) by @juanpprieto

  • Fixing typescript compile (#2787) by @balazsbajorics

    In tsconfig.json:

         "types": [
           "@shopify/oxygen-workers-types",
    -      "@remix-run/node",
    +      "@remix-run/server-runtime",
           "vite/client"
         ],
  • Updates @shopify/cli-kit, @shopify/cli and @shopify/plugin-cloudflare to 3.77.0. (#2810) by @seanparsons

  • Support for the Remix future flag v3_routeConfig. (#2722) by @seanparsons

    Please refer to the Remix documentation for more details on v3_routeConfig future flag: https://remix.run/docs/en/main/start/future-flags#v3_routeconfig

    1. Update your vite.config.ts.

      export default defineConfig({
        plugins: [
          hydrogen(),
          oxygen(),
          remix({
      -      presets: [hydrogen.preset()],
      +      presets: [hydrogen.v3preset()],
          future: {
            v3_fetcherPersist: true,
            v3_relativeSplatPath: true,
            v3_throwAbortReason: true,
            v3_lazyRouteDiscovery: true,
            v3_singleFetch: true,
      +      v3_routeConfig: true,
          },
        }),
        tsconfigPaths(),
      ],
    2. Update your package.json and install the new packages. Make sure to match the Remix version along with other Remix npm packages and ensure the versions are 2.16.1 or above:

        "devDependencies": {
          "@remix-run/dev": "^2.16.1",
      +    "@remix-run/fs-routes": "^2.16.1",
      +    "@remix-run/route-config": "^2.16.1",
    3. Move the Layout component export from root.tsx into its own file. Make sure to supply an <Outlet> so Remix knows where to inject your route content.

      // /app/layout.tsx
      import {Outlet} from '@remix-run/react';
      
      export default function Layout() {
        const nonce = useNonce();
        const data = useRouteLoaderData<RootLoader>('root');
      
        return (
          <html lang="en">
            ...
            <Outlet />
            ...
          </html>
        );
      }
      
      // Remember to remove the Layout export from your root.tsx
    4. Add a routes.ts file. This is your new Remix route configuration file.

      import { flatRoutes } from "@remix-run/fs-routes";
      import { layout, type RouteConfig } from "@remix-run/route-config";
      import { hydrogenRoutes } from "@shopify/hydrogen";
      
      export default hydrogenRoutes([
        // Your entire app reading from routes folder using Layout from layout.tsx
        layout("./layout.tsx", await flatRoutes()),
      ]) satisfies RouteConfig;
  • Updated dependencies [0425e50d, 74ef1ba7]:

2025.1.2

Patch Changes

2025.1.1

Patch Changes

  • Upgrade eslint to version 9 and unify eslint config across all packages (with the exception of the skeleton, which still keeps its own config) (#2716) by @liady

  • Bump remix version (#2740) by @wizardlyhel

  • Turn on Remix v3_singleFetch future flag (#2708) by @wizardlyhel

    Remix single fetch migration quick guide: https://remix.run/docs/en/main/start/future-flags#v3_singlefetch Remix single fetch migration guide: https://remix.run/docs/en/main/guides/single-fetch

    Note: If you have any routes that appends (or looks for) a search param named _data, make sure to rename it to something else.

    1. In your vite.config.ts, add the single fetch future flag.

      +  declare module "@remix-run/server-runtime" {
      +    interface Future {
      +     v3_singleFetch: true;
      +    }
      +  }
      
        export default defineConfig({
          plugins: [
            hydrogen(),
            oxygen(),
            remix({
              presets: [hydrogen.preset()],
              future: {
                v3_fetcherPersist: true,
                v3_relativeSplatPath: true,
                v3_throwAbortReason: true,
                v3_lazyRouteDiscovery: true,
      +         v3_singleFetch: true,
              },
            }),
            tsconfigPaths(),
          ],
    2. In your entry.server.tsx, add nonce to the <RemixServer>.

      const body = await renderToReadableStream(
        <NonceProvider>
          <RemixServer
            context={remixContext}
            url={request.url}
      +     nonce={nonce}
          />
        </NonceProvider>,
    3. Update the shouldRevalidate function in root.tsx.

      Defaulting to no revalidation for root loader data to improve performance. When using this feature, you risk your UI getting out of sync with your server. Use with caution. If you are uncomfortable with this optimization, update the return false; to return defaultShouldRevalidate; instead.

      For more details see: https://remix.run/docs/en/main/route/should-revalidate

      export const shouldRevalidate: ShouldRevalidateFunction = ({
        formMethod,
        currentUrl,
        nextUrl,
      -  defaultShouldRevalidate,
      }) => {
        // revalidate when a mutation is performed e.g add to cart, login...
        if (formMethod && formMethod !== 'GET') return true;
      
        // revalidate when manually revalidating via useRevalidator
        if (currentUrl.toString() === nextUrl.toString()) return true;
      
      -  return defaultShouldRevalidate;
      +  return false;
      };
    4. Update cart.tsx to add a headers export and update to data import usage.

        import {
      -  json,
      +  data,
          type LoaderFunctionArgs,
          type ActionFunctionArgs,
          type HeadersFunction
        } from '@shopify/remix-oxygen';
      + export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders;
      
        export async function action({request, context}: ActionFunctionArgs) {
          ...
      -   return json(
      +   return data(
            {
              cart: cartResult,
              errors,
              warnings,
              analytics: {
                cartId,
              },
            },
            {status, headers},
          );
        }
      
        export async function loader({context}: LoaderFunctionArgs) {
          const {cart} = context;
      -    return json(await cart.get());
      +    return await cart.get();
        }
    5. Deprecate json and defer import usage from @shopify/remix-oxygen.

      Remove json()/defer() in favor of raw objects.

      Single Fetch supports JSON objects and Promises out of the box, so you can return the raw data from your loader/action functions:

      - import {json} from "@shopify/remix-oxygen";
      
        export async function loader({}: LoaderFunctionArgs) {
          let tasks = await fetchTasks();
      -   return json(tasks);
      +   return tasks;
        }
      - import {defer} from "@shopify/remix-oxygen";
      
        export async function loader({}: LoaderFunctionArgs) {
          let lazyStuff = fetchLazyStuff();
          let tasks = await fetchTasks();
      -   return defer({ tasks, lazyStuff });
      +   return { tasks, lazyStuff };
        }

      If you were using the second parameter of json/defer to set a custom status or headers on your response, you can continue doing so via the new data API:

      -  import {json} from "@shopify/remix-oxygen";
      +  import {data, type HeadersFunction} from "@shopify/remix-oxygen";
      
      +  /**
      +   * If your loader or action is returning a response with headers,
      +   * make sure to export a headers function that merges your headers
      +   * on your route. Otherwise, your headers may be lost.
      +   * Remix doc: https://remix.run/docs/en/main/route/headers
      +   **/
      +  export const headers: HeadersFunction = ({loaderHeaders}) => loaderHeaders;
      
        export async function loader({}: LoaderFunctionArgs) {
          let tasks = await fetchTasks();
      -    return json(tasks, {
      +    return data(tasks, {
            headers: {
              "Cache-Control": "public, max-age=604800"
            }
          });
        }
    6. If you are using legacy customer account flow or multipass, there are a couple more files that requires updating:

      In root.tsx and routes/account.tsx, add a headers export for loaderHeaders.

      + export const headers: HeadersFunction = ({loaderHeaders}) => loaderHeaders;

      In routes/account_.register.tsx, add a headers export for actionHeaders.

      + export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders;
    7. If you are using multipass, in routes/account_.login.multipass.tsx

      a. export a headers export

      + export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders;

      b. Update all json response wrapper to remixData

      import {
      - json,
      + data as remixData,
      } from '@shopify/remix-oxygen';
      
      -  return json(
      +  return remixData(
          ...
        );
  • Updated dependencies [3af2e453, 6bff6b62, cd65685c, 8c717570, 4e81bd1b, 3ea25820]:

2025.1.0

Patch Changes

  • Bump vite, Remix versions and tailwind v4 alpha to beta (#2696) by @wizardlyhel

  • Workaround for "Error: failed to execute 'insertBefore' on 'Node'" that sometimes happen during development. (#2701) by @wizardlyhel

    // root.tsx
    
    /**
     * The main and reset stylesheets are added in the Layout component
     * to prevent a bug in development HMR updates.
     *
     * This avoids the "failed to execute 'insertBefore' on 'Node'" error
     * that occurs after editing and navigating to another page.
     *
     * It's a temporary fix until the issue is resolved.
     * https://github.com/remix-run/remix/issues/9242
     */
    export function links() {
      return [
    -    {rel: 'stylesheet', href: resetStyles},
    -    {rel: 'stylesheet', href: appStyles},
        {
          rel: 'preconnect',
          href: 'https://cdn.shopify.com',
        },
        {
          rel: 'preconnect',
          href: 'https://shop.app',
        },
        {rel: 'icon', type: 'image/svg+xml', href: favicon},
      ];
    }
    
    ...
    
    export function Layout({children}: {children?: React.ReactNode}) {
      const nonce = useNonce();
      const data = useRouteLoaderData<RootLoader>('root');
    
      return (
        <html lang="en">
          <head>
            <meta charSet="utf-8" />
            <meta name="viewport" content="width=device-width,initial-scale=1" />
    +        <link rel="stylesheet" href={resetStyles}></link>
    +        <link rel="stylesheet" href={appStyles}></link>
    
  • Turn on future flag v3_lazyRouteDiscovery (#2702) by @wizardlyhel

    In your vite.config.ts, add the following line:

    export default defineConfig({
      plugins: [
        hydrogen(),
        oxygen(),
        remix({
          presets: [hydrogen.preset()],
          future: {
            v3_fetcherPersist: true,
            v3_relativeSplatPath: true,
            v3_throwAbortReason: true,
    +        v3_lazyRouteDiscovery: true,
          },
        }),
        tsconfigPaths(),
      ],

    Test your app by running npm run dev and nothing should break

  • Fix image size warnings on collections page (#2703) by @wizardlyhel

  • Bump cli version (#2732) by @wizardlyhel

  • Bump SFAPI to 2025-01 (#2715) by @rbshop

  • Updated dependencies [fdab06f5, ae6d71f0, 650d57b3, 064de138]:

2024.10.4

Patch Changes

2024.10.3

Patch Changes

2024.10.2

Patch Changes

  • Remove initial redirect from product display page (#2643) by @scottdixon

  • Optional updates for the product route and product form to handle combined listing and 2000 variant limit. (#2659) by @wizardlyhel

    1. Update your SFAPI product query to bring in the new query fields:
    const PRODUCT_FRAGMENT = `#graphql
      fragment Product on Product {
        id
        title
        vendor
        handle
        descriptionHtml
        description
    +    encodedVariantExistence
    +    encodedVariantAvailability
        options {
          name
          optionValues {
            name
    +        firstSelectableVariant {
    +          ...ProductVariant
    +        }
    +        swatch {
    +          color
    +          image {
    +            previewImage {
    +              url
    +            }
    +          }
    +        }
          }
        }
    -    selectedVariant: selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
    +    selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
    +      ...ProductVariant
    +    }
    +    adjacentVariants (selectedOptions: $selectedOptions) {
    +      ...ProductVariant
    +    }
    -    variants(first: 1) {
    -      nodes {
    -        ...ProductVariant
    -      }
    -    }
        seo {
          description
          title
        }
      }
      ${PRODUCT_VARIANT_FRAGMENT}
    ` as const;
    1. Update loadDeferredData function. We no longer need to load in all the variants. You can also remove VARIANTS_QUERY variable.
    function loadDeferredData({context, params}: LoaderFunctionArgs) {
    +  // Put any API calls that is not critical to be available on first page render
    +  // For example: product reviews, product recommendations, social feeds.
    -  // In order to show which variants are available in the UI, we need to query
    -  // all of them. But there might be a *lot*, so instead separate the variants
    -  // into it's own separate query that is deferred. So there's a brief moment
    -  // where variant options might show as available when they're not, but after
    -  // this deferred query resolves, the UI will update.
    -  const variants = context.storefront
    -    .query(VARIANTS_QUERY, {
    -      variables: {handle: params.handle!},
    -    })
    -    .catch((error) => {
    -      // Log query errors, but don't throw them so the page can still render
    -      console.error(error);
    -      return null;
    -    });
    
    +  return {}
    -  return {
    -    variants,
    -  };
    }
    1. Remove the redirect logic in the loadCriticalData function and completely remove redirectToFirstVariant function
    async function loadCriticalData({
      context,
      params,
      request,
    }: LoaderFunctionArgs) {
      const {handle} = params;
      const {storefront} = context;
      if (!handle) {
        throw new Error('Expected product handle to be defined');
      }
      const [{product}] = await Promise.all([
        storefront.query(PRODUCT_QUERY, {
          variables: {handle, selectedOptions: getSelectedProductOptions(request)},
        }),
        // Add other queries here, so that they are loaded in parallel
      ]);
    
      if (!product?.id) {
        throw new Response(null, {status: 404});
      }
    
    -  const firstVariant = product.variants.nodes[0];
    -  const firstVariantIsDefault = Boolean(
    -    firstVariant.selectedOptions.find(
    -      (option: SelectedOption) =>
    -        option.name === 'Title' && option.value === 'Default Title',
    -    ),
    -  );
    
    -  if (firstVariantIsDefault) {
    -    product.selectedVariant = firstVariant;
    -  } else {
    -    // if no selected variant was returned from the selected options,
    -    // we redirect to the first variant's url with it's selected options applied
    -    if (!product.selectedVariant) {
    -      throw redirectToFirstVariant({product, request});
    -    }
    -  }
    
      return {
        product,
      };
    }
    
    ...
    
    -  function redirectToFirstVariant({
    -    product,
    -    request,
    -  }: {
    -    product: ProductFragment;
    -    request: Request;
    -  }) {
    -    ...
    -  }
    1. Update the Product component to use the new data fields.
    import {
      getSelectedProductOptions,
      Analytics,
      useOptimisticVariant,
    +  getAdjacentAndFirstAvailableVariants,
    } from '@shopify/hydrogen';
    
    export default function Product() {
    +  const {product} = useLoaderData<typeof loader>();
    -  const {product, variants} = useLoaderData<typeof loader>();
    
    +  // Optimistically selects a variant with given available variant information
    +  const selectedVariant = useOptimisticVariant(
    +    product.selectedOrFirstAvailableVariant,
    +    getAdjacentAndFirstAvailableVariants(product),
    +  );
    -  const selectedVariant = useOptimisticVariant(
    -    product.selectedVariant,
    -    variants,
    -  );
    1. Handle missing search query param in url from selecting a first variant
    import {
      getSelectedProductOptions,
      Analytics,
      useOptimisticVariant,
      getAdjacentAndFirstAvailableVariants,
    +  useSelectedOptionInUrlParam,
    } from '@shopify/hydrogen';
    
    export default function Product() {
      const {product} = useLoaderData<typeof loader>();
    
      // Optimistically selects a variant with given available variant information
      const selectedVariant = useOptimisticVariant(
        product.selectedOrFirstAvailableVariant,
        getAdjacentAndFirstAvailableVariants(product),
      );
    
    +  // Sets the search param to the selected variant without navigation
    +  // only when no search params are set in the url
    +  useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
    1. Get the product options array using getProductOptions
    import {
      getSelectedProductOptions,
      Analytics,
      useOptimisticVariant,
    +  getProductOptions,
      getAdjacentAndFirstAvailableVariants,
      useSelectedOptionInUrlParam,
    } from '@shopify/hydrogen';
    
    export default function Product() {
      const {product} = useLoaderData<typeof loader>();
    
      // Optimistically selects a variant with given available variant information
      const selectedVariant = useOptimisticVariant(
        product.selectedOrFirstAvailableVariant,
        getAdjacentAndFirstAvailableVariants(product),
      );
    
      // Sets the search param to the selected variant without navigation
      // only when no search params are set in the url
      useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
    
    +  // Get the product options array
    +  const productOptions = getProductOptions({
    +    ...product,
    +    selectedOrFirstAvailableVariant: selectedVariant,
    +  });
    1. Remove the Await and Suspense from the ProductForm. We no longer have any queries that we need to wait for.
    export default function Product() {
    
      ...
    
      return (
        ...
    +        <ProductForm
    +          productOptions={productOptions}
    +          selectedVariant={selectedVariant}
    +        />
    -        <Suspense
    -          fallback={
    -            <ProductForm
    -              product={product}
    -              selectedVariant={selectedVariant}
    -              variants={[]}
    -            />
    -          }
    -        >
    -          <Await
    -            errorElement="There was a problem loading product variants"
    -            resolve={variants}
    -          >
    -            {(data) => (
    -              <ProductForm
    -                product={product}
    -                selectedVariant={selectedVariant}
    -                variants={data?.product?.variants.nodes || []}
    -              />
    -            )}
    -          </Await>
    -        </Suspense>
    1. Update the ProductForm component.
    import { Link, useNavigate } from "@remix-run/react";
    import { type MappedProductOptions } from "@shopify/hydrogen";
    import type {
      Maybe,
      ProductOptionValueSwatch,
    } from "@shopify/hydrogen/storefront-api-types";
    import { AddToCartButton } from "./AddToCartButton";
    import { useAside } from "./Aside";
    import type { ProductFragment } from "storefrontapi.generated";
    
    export function ProductForm({
      productOptions,
      selectedVariant,
    }: {
      productOptions: MappedProductOptions[];
      selectedVariant: ProductFragment["selectedOrFirstAvailableVariant"];
    }) {
      const navigate = useNavigate();
      const { open } = useAside();
      return (
        <div className="product-form">
          {productOptions.map((option) => (
            <div className="product-options" key={option.name}>
              <h5>{option.name}</h5>
              <div className="product-options-grid">
                {option.optionValues.map((value) => {
                  const {
                    name,
                    handle,
                    variantUriQuery,
                    selected,
                    available,
                    exists,
                    isDifferentProduct,
                    swatch,
                  } = value;
    
                  if (isDifferentProduct) {
                    // SEO
                    // When the variant is a combined listing child product
                    // that leads to a different url, we need to render it
                    // as an anchor tag
                    return (
                      <Link
                        className="product-options-item"
                        key={option.name + name}
                        prefetch="intent"
                        preventScrollReset
                        replace
                        to={`/products/${handle}?${variantUriQuery}`}
                        style={{
                          border: selected
                            ? "1px solid black"
                            : "1px solid transparent",
                          opacity: available ? 1 : 0.3,
                        }}
                      >
                        <ProductOptionSwatch swatch={swatch} name={name} />
                      </Link>
                    );
                  } else {
                    // SEO
                    // When the variant is an update to the search param,
                    // render it as a button with javascript navigating to
                    // the variant so that SEO bots do not index these as
                    // duplicated links
                    return (
                      <button
                        type="button"
                        className={`product-options-item${
                          exists && !selected ? " link" : ""
                        }`}
                        key={option.name + name}
                        style={{
                          border: selected
                            ? "1px solid black"
                            : "1px solid transparent",
                          opacity: available ? 1 : 0.3,
                        }}
                        disabled={!exists}
                        onClick={() => {
                          if (!selected) {
                            navigate(`?${variantUriQuery}`, {
                              replace: true,
                            });
                          }
                        }}
                      >
                        <ProductOptionSwatch swatch={swatch} name={name} />
                      </button>
                    );
                  }
                })}
              </div>
              <br />
            </div>
          ))}
          <AddToCartButton
            disabled={!selectedVariant || !selectedVariant.availableForSale}
            onClick={() => {
              open("cart");
            }}
            lines={
              selectedVariant
                ? [
                    {
                      merchandiseId: selectedVariant.id,
                      quantity: 1,
                      selectedVariant,
                    },
                  ]
                : []
            }
          >
            {selectedVariant?.availableForSale ? "Add to cart" : "Sold out"}
          </AddToCartButton>
        </div>
      );
    }
    
    function ProductOptionSwatch({
      swatch,
      name,
    }: {
      swatch?: Maybe<ProductOptionValueSwatch> | undefined;
      name: string;
    }) {
      const image = swatch?.image?.previewImage?.url;
      const color = swatch?.color;
    
      if (!image && !color) return name;
    
      return (
        <div
          aria-label={name}
          className="product-option-label-swatch"
          style={{
            backgroundColor: color || "transparent",
          }}
        >
          {!!image && <img src={image} alt={name} />}
        </div>
      );
    }
    1. Update app.css
    +  /*
    +  * --------------------------------------------------
    +  * Non anchor links
    +  * --------------------------------------------------
    +  */
    +  .link:hover {
    +    text-decoration: underline;
    +    cursor: pointer;
    +  }
    
    ...
    
    -  .product-options-item {
    +  .product-options-item,
    +  .product-options-item:disabled {
    +    padding: 0.25rem 0.5rem;
    +    background-color: transparent;
    +    font-size: 1rem;
    +    font-family: inherit;
    +  }
    
    +  .product-option-label-swatch {
    +    width: 1.25rem;
    +    height: 1.25rem;
    +    margin: 0.25rem 0;
    +  }
    
    +  .product-option-label-swatch img {
    +    width: 100%;
    +  }
    1. Update lib/variants.ts

    Make useVariantUrl and getVariantUrl flexible to supplying a selected option param

    export function useVariantUrl(
      handle: string,
    -  selectedOptions: SelectedOption[],
    +  selectedOptions?: SelectedOption[],
    ) {
      const {pathname} = useLocation();
    
      return useMemo(() => {
        return getVariantUrl({
          handle,
          pathname,
          searchParams: new URLSearchParams(),
          selectedOptions,
        });
      }, [handle, selectedOptions, pathname]);
    }
    export function getVariantUrl({
      handle,
      pathname,
      searchParams,
      selectedOptions,
    }: {
      handle: string;
      pathname: string;
      searchParams: URLSearchParams;
    -  selectedOptions: SelectedOption[];
    +  selectedOptions?: SelectedOption[],
    }) {
      const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
      const isLocalePathname = match && match.length > 0;
      const path = isLocalePathname
        ? `${match![0]}products/${handle}`
        : `/products/${handle}`;
    
    -  selectedOptions.forEach((option) => {
    +  selectedOptions?.forEach((option) => {
        searchParams.set(option.name, option.value);
      });
    1. Update routes/collections.$handle.tsx

    We no longer need to query for the variants since product route can efficiently obtain the first available variants. Update the code to reflect that:

    const PRODUCT_ITEM_FRAGMENT = `#graphql
      fragment MoneyProductItem on MoneyV2 {
        amount
        currencyCode
      }
      fragment ProductItem on Product {
        id
        handle
        title
        featuredImage {
          id
          altText
          url
          width
          height
        }
        priceRange {
          minVariantPrice {
            ...MoneyProductItem
          }
          maxVariantPrice {
            ...MoneyProductItem
          }
        }
    -    variants(first: 1) {
    -      nodes {
    -        selectedOptions {
    -          name
    -          value
    -        }
    -      }
    -    }
      }
    ` as const;

    and remove the variant reference

    function ProductItem({
      product,
      loading,
    }: {
      product: ProductItemFragment;
      loading?: 'eager' | 'lazy';
    }) {
    -  const variant = product.variants.nodes[0];
    -  const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
    +  const variantUrl = useVariantUrl(product.handle);
      return (
    1. Update routes/collections.all.tsx

    Same reasoning as collections.$handle.tsx

    const PRODUCT_ITEM_FRAGMENT = `#graphql
      fragment MoneyProductItem on MoneyV2 {
        amount
        currencyCode
      }
      fragment ProductItem on Product {
        id
        handle
        title
        featuredImage {
          id
          altText
          url
          width
          height
        }
        priceRange {
          minVariantPrice {
            ...MoneyProductItem
          }
          maxVariantPrice {
            ...MoneyProductItem
          }
        }
    -    variants(first: 1) {
    -      nodes {
    -        selectedOptions {
    -          name
    -          value
    -        }
    -      }
    -    }
      }
    ` as const;

    and remove the variant reference

    function ProductItem({
      product,
      loading,
    }: {
      product: ProductItemFragment;
      loading?: 'eager' | 'lazy';
    }) {
    -  const variant = product.variants.nodes[0];
    -  const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
    +  const variantUrl = useVariantUrl(product.handle);
      return (
    1. Update routes/search.tsx

    Instead of using the first variant, use selectedOrFirstAvailableVariant

    const SEARCH_PRODUCT_FRAGMENT = `#graphql
      fragment SearchProduct on Product {
        __typename
        handle
        id
        publishedAt
        title
        trackingParameters
        vendor
    -    variants(first: 1) {
    -      nodes {
    +    selectedOrFirstAvailableVariant(
    +      selectedOptions: []
    +      ignoreUnknownOptions: true
    +      caseInsensitiveMatch: true
    +    ) {
            id
            image {
              url
              altText
              width
              height
            }
            price {
              amount
              currencyCode
            }
            compareAtPrice {
              amount
              currencyCode
            }
            selectedOptions {
              name
              value
            }
            product {
              handle
              title
            }
         }
    -    }
      }
    ` as const;
    const PREDICTIVE_SEARCH_PRODUCT_FRAGMENT = `#graphql
      fragment PredictiveProduct on Product {
        __typename
        id
        title
        handle
        trackingParameters
    -    variants(first: 1) {
    -      nodes {
    +    selectedOrFirstAvailableVariant(
    +      selectedOptions: []
    +      ignoreUnknownOptions: true
    +      caseInsensitiveMatch: true
    +    ) {
            id
            image {
              url
              altText
              width
              height
            }
            price {
              amount
              currencyCode
            }
         }
    -    }
      }
    1. Update components/SearchResults.tsx
    function SearchResultsProducts({
      term,
      products,
    }: PartialSearchResult<'products'>) {
      if (!products?.nodes.length) {
        return null;
      }
    
      return (
        <div className="search-result">
          <h2>Products</h2>
          <Pagination connection={products}>
            {({nodes, isLoading, NextLink, PreviousLink}) => {
              const ItemsMarkup = nodes.map((product) => {
                const productUrl = urlWithTrackingParams({
                  baseUrl: `/products/${product.handle}`,
                  trackingParams: product.trackingParameters,
                  term,
                });
    
    +            const price = product?.selectedOrFirstAvailableVariant?.price;
    +            const image = product?.selectedOrFirstAvailableVariant?.image;
    
                return (
                  <div className="search-results-item" key={product.id}>
                    <Link prefetch="intent" to={productUrl}>
    -                  {product.variants.nodes[0].image && (
    +                  {image && (
                        <Image
    -                      data={product.variants.nodes[0].image}
    +                      data={image}
                          alt={product.title}
                          width={50}
                        />
                      )}
                      <div>
                        <p>{product.title}</p>
                        <small>
    -                      <Money data={product.variants.nodes[0].price} />
    +                      {price &&
    +                        <Money data={price} />
    +                      }
                        </small>
                      </div>
                    </Link>
                  </div>
                );
              });
    1. Update components/SearchResultsPredictive.tsx
    function SearchResultsPredictiveProducts({
      term,
      products,
      closeSearch,
    }: PartialPredictiveSearchResult<'products'>) {
      if (!products.length) return null;
    
      return (
        <div className="predictive-search-result" key="products">
          <h5>Products</h5>
          <ul>
            {products.map((product) => {
              const productUrl = urlWithTrackingParams({
                baseUrl: `/products/${product.handle}`,
                trackingParams: product.trackingParameters,
                term: term.current,
              });
    
    +          const price = product?.selectedOrFirstAvailableVariant?.price;
    -          const image = product?.variants?.nodes?.[0].image;
    +          const image = product?.selectedOrFirstAvailableVariant?.image;
              return (
                <li className="predictive-search-result-item" key={product.id}>
                  <Link to={productUrl} onClick={closeSearch}>
                    {image && (
                      <Image
                        alt={image.altText ?? ''}
                        src={image.url}
                        width={50}
                        height={50}
                      />
                    )}
                    <div>
                      <p>{product.title}</p>
                      <small>
    -                    {product?.variants?.nodes?.[0].price && (
    +                    {price && (
    -                      <Money data={product.variants.nodes[0].price} />
    +                      <Money data={price} />
                        )}
                      </small>
                    </div>
                  </Link>
                </li>
              );
            })}
          </ul>
        </div>
      );
    }
  • Update Aside to have an accessible close button label (#2639) by @lb-

  • Fix cart route so that it works with no-js (#2665) by @wizardlyhel

  • Bump Shopify cli version (#2667) by @wizardlyhel

  • Updated dependencies [8f64915e, a57d5267, 91d60fd2, 23a80f3e]:

2024.10.1

Patch Changes

2024.10.0

Patch Changes

  • Stabilize getSitemap, getSitemapIndex and implement on skeleton (#2589) by @juanpprieto

    1. Update the getSitemapIndex at /app/routes/[sitemap.xml].tsx
    - import {unstable__getSitemapIndex as getSitemapIndex} from '@shopify/hydrogen';
    + import {getSitemapIndex} from '@shopify/hydrogen';
    1. Update the getSitemap at /app/routes/sitemap.$type.$page[.xml].tsx
    - import {unstable__getSitemap as getSitemap} from '@shopify/hydrogen';
    + import {getSitemap} from '@shopify/hydrogen';

    For a reference implementation please see the skeleton template sitemap routes

  • [Breaking change] (#2588) by @wizardlyhel

    Set up Customer Privacy without the Shopify's cookie banner by default.

    If you are using Shopify's cookie banner to handle user consent in your app, you need to set withPrivacyBanner: true to the consent config. Without this update, the Shopify cookie banner will not appear.

      return defer({
        ...
        consent: {
          checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN,
          storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN,
    +      withPrivacyBanner: true,
          // localize the privacy banner
          country: args.context.storefront.i18n.country,
          language: args.context.storefront.i18n.language,
        },
      });
  • Update to 2024-10 SFAPI (#2570) by @wizardlyhel

  • [Breaking change] (#2546) by @frandiox

    Update createWithCache to make it harder to accidentally cache undesired results. request is now mandatory prop when initializing createWithCache.

    // server.ts
    export default {
      async fetch(
        request: Request,
        env: Env,
        executionContext: ExecutionContext,
      ): Promise<Response> {
        try {
          // ...
    -     const withCache = createWithCache({cache, waitUntil});
    +     const withCache = createWithCache({cache, waitUntil, request});

    createWithCache now returns an object with two utility functions: withCache.run and withCache.fetch. Both have a new prop shouldCacheResult that must be defined.

    The original withCache callback function is now withCache.run. This is useful to run multiple fetch calls and merge their responses, or run any arbitrary code. It caches anything you return, but you can throw if you don't want to cache anything.

      const withCache = createWithCache({cache, waitUntil, request});
    
      const fetchMyCMS = (query) => {
    -    return withCache(['my-cms', query], CacheLong(), async (params) => {
    +    return withCache.run({
    +      cacheKey: ['my-cms', query],
    +      cacheStrategy: CacheLong(),
    +      // Cache if there are no data errors or a specific data that make this result not suited for caching
    +      shouldCacheResult: (result) => !result?.errors,
    +    }, async(params) => {
          const response = await fetch('my-cms.com/api', {
            method: 'POST',
            body: query,
          });
          if (!response.ok) throw new Error(response.statusText);
          const {data, error} = await response.json();
          if (error || !data) throw new Error(error ?? 'Missing data');
          params.addDebugData({displayName: 'My CMS query', response});
          return data;
        });
      };

    New withCache.fetch is for caching simple fetch requests. This method caches the responses if they are OK responses, and you can pass shouldCacheResponse, cacheKey, etc. to modify behavior. data is the consumed body of the response (we need to consume to cache it).

    const withCache = createWithCache({ cache, waitUntil, request });
    
    const { data, response } = await withCache.fetch<{ data: T; error: string }>(
      "my-cms.com/api",
      {
        method: "POST",
        headers: { "Content-type": "application/json" },
        body,
      },
      {
        cacheStrategy: CacheLong(),
        // Cache if there are no data errors or a specific data that make this result not suited for caching
        shouldCacheResponse: (result) => !result?.error,
        cacheKey: ["my-cms", body],
        displayName: "My CMS query",
      },
    );
  • [Breaking change] (#2585) by @wizardlyhel

    Deprecate usages of product.options.values and use product.options.optionValues instead.

    1. Update your product graphql query to use the new optionValues field.
      const PRODUCT_FRAGMENT = `#graphql
        fragment Product on Product {
          id
          title
          options {
            name
    -        values
    +        optionValues {
    +          name
    +        }
          }
    1. Update your <VariantSelector> to use the new optionValues field.
      <VariantSelector
        handle={product.handle}
    -    options={product.options.filter((option) => option.values.length > 1)}
    +    options={product.options.filter((option) => option.optionValues.length > 1)}
        variants={variants}
      >
  • Updated dependencies [d97cd56e, 809c9f3d, 8c89f298, a253ef97, 84a66b1e, 227035e7, ac12293c, c7c9f2eb, 76cd4f9b, 8337e534]:

2024.7.10

Patch Changes

2024.7.9

Patch Changes

2024.7.8

Patch Changes

2024.7.7

Patch Changes

2024.7.6

Patch Changes

  • Update Shopify CLI and cli-kit dependencies to 3.66.1 (#2512) by @frandiox

  • createCartHandler supplies updateGiftCardCodes method (#2298) by @wizardlyhel

  • Fix menu links in side panel not working on mobile devices (#2450) by @wizardlyhel

    // /app/components/Header.tsx
    
    export function HeaderMenu({
      menu,
      primaryDomainUrl,
      viewport,
      publicStoreDomain,
    }: {
      menu: HeaderProps['header']['menu'];
      primaryDomainUrl: HeaderProps['header']['shop']['primaryDomain']['url'];
      viewport: Viewport;
      publicStoreDomain: HeaderProps['publicStoreDomain'];
    }) {
      const className = `header-menu-${viewport}`;
    +  const {close} = useAside();
    
    -  function closeAside(event: React.MouseEvent<HTMLAnchorElement>) {
    -    if (viewport === 'mobile') {
    -      event.preventDefault();
    -      window.location.href = event.currentTarget.href;
    -    }
    -  }
    
      return (
        <nav className={className} role="navigation">
          {viewport === 'mobile' && (
            <NavLink
              end
    -          onClick={closeAside}
    +          onClick={close}
              prefetch="intent"
              style={activeLinkStyle}
              to="/"
            >
              Home
            </NavLink>
          )}
          {(menu || FALLBACK_HEADER_MENU).items.map((item) => {
            if (!item.url) return null;
    
            // if the url is internal, we strip the domain
            const url =
              item.url.includes('myshopify.com') ||
              item.url.includes(publicStoreDomain) ||
              item.url.includes(primaryDomainUrl)
                ? new URL(item.url).pathname
                : item.url;
            return (
              <NavLink
                className="header-menu-item"
                end
                key={item.id}
    -            onClick={closeAside}
    +            onClick={close}
                prefetch="intent"
                style={activeLinkStyle}
                to={url}
              >
                {item.title}
              </NavLink>
            );
          })}
        </nav>
      );
    }
  • Add localization support to consent privacy banner (#2457) by @juanpprieto

  • Updated dependencies [d633e49a, 1b217cd6, d929b561, 664a09d5, 0c1e511d, eefa8203]:

2024.7.5

Patch Changes

2024.7.4

Patch Changes

  • Search & Predictive Search improvements (#2363) by @juanpprieto

    1. Create a app/lib/context file and use createHydrogenContext in it. (#2333) by @michenly
    // in app/lib/context
    
    import {createHydrogenContext} from '@shopify/hydrogen';
    
    export async function createAppLoadContext(
      request: Request,
      env: Env,
      executionContext: ExecutionContext,
    ) {
        const hydrogenContext = createHydrogenContext({
          env,
          request,
          cache,
          waitUntil,
          session,
          i18n: {language: 'EN', country: 'US'},
          cart: {
            queryFragment: CART_QUERY_FRAGMENT,
          },
          // ensure to overwrite any options that is not using the default values from your server.ts
        });
    
      return {
        ...hydrogenContext,
        // declare additional Remix loader context
      };
    }
    
    1. Use createAppLoadContext method in server.ts Ensure to overwrite any options that is not using the default values in createHydrogenContext.
    // in server.ts
    
    - import {
    -   createCartHandler,
    -   createStorefrontClient,
    -   createCustomerAccountClient,
    - } from '@shopify/hydrogen';
    + import {createAppLoadContext} from '~/lib/context';
    
    export default {
      async fetch(
        request: Request,
        env: Env,
        executionContext: ExecutionContext,
      ): Promise<Response> {
    
    -   const {storefront} = createStorefrontClient(
    -     ...
    -   );
    
    -   const customerAccount = createCustomerAccountClient(
    -     ...
    -   );
    
    -   const cart = createCartHandler(
    -     ...
    -   );
    
    +   const appLoadContext = await createAppLoadContext(
    +      request,
    +      env,
    +      executionContext,
    +   );
    
        /**
          * Create a Remix request handler and pass
          * Hydrogen's Storefront client to the loader context.
          */
        const handleRequest = createRequestHandler({
          build: remixBuild,
          mode: process.env.NODE_ENV,
    -      getLoadContext: (): AppLoadContext => ({
    -        session,
    -        storefront,
    -        customerAccount,
    -        cart,
    -        env,
    -        waitUntil,
    -      }),
    +      getLoadContext: () => appLoadContext,
        });
      }
    1. Use infer type for AppLoadContext in env.d.ts
    // in env.d.ts
    
    + import type {createAppLoadContext} from '~/lib/context';
    
    + interface AppLoadContext extends Awaited<ReturnType<typeof createAppLoadContext>> {
    - interface AppLoadContext {
    -  env: Env;
    -  cart: HydrogenCart;
    -  storefront: Storefront;
    -  customerAccount: CustomerAccount;
    -  session: AppSession;
    -  waitUntil: ExecutionContext['waitUntil'];
    }
    
  • Use type HydrogenEnv for all the env.d.ts (#2333) by @michenly

    // in env.d.ts
    
    + import type {HydrogenEnv} from '@shopify/hydrogen';
    
    + interface Env extends HydrogenEnv {}
    - interface Env {
    -   SESSION_SECRET: string;
    -  PUBLIC_STOREFRONT_API_TOKEN: string;
    -  PRIVATE_STOREFRONT_API_TOKEN: string;
    -  PUBLIC_STORE_DOMAIN: string;
    -  PUBLIC_STOREFRONT_ID: string;
    -  PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string;
    -  PUBLIC_CUSTOMER_ACCOUNT_API_URL: string;
    -  PUBLIC_CHECKOUT_DOMAIN: string;
    - }
    
  • Add a hydration check for google web cache. This prevents an infinite redirect when viewing the cached version of a hydrogen site on Google. (#2334) by @blittle

    Update your entry.server.jsx file to include this check:

    + if (!window.location.origin.includes("webcache.googleusercontent.com")) {
       startTransition(() => {
         hydrateRoot(
           document,
           <StrictMode>
             <RemixBrowser />
           </StrictMode>
         );
       });
    + }
  • Updated dependencies [a2d9acf9, c0d7d917, b09e9a4c, c204eacf, bf4e3d3c, 20a8e63b, 6e5d8ea7, 7c4f67a6, dfb9be77, 31ea19e8]:

2024.7.3

Patch Changes

2024.7.2

Patch Changes

2024.7.1

Patch Changes

  • Update @shopify/oxygen-workers-types to fix issues on Windows. (#2252) by @michenly

  • [Breaking change] (#2113) by @blittle

    Previously the VariantSelector component would filter out options that only had one value. This is undesireable for some apps. We've removed that filter, if you'd like to retain the existing functionality, simply filter the options prop before it is passed to the VariantSelector component:

     <VariantSelector
       handle={product.handle}
    +  options={product.options.filter((option) => option.values.length > 1)}
    -  options={product.options}
       variants={variants}>
     </VariantSelector>

    Fixes #1198

  • Update remix to v2.10.1 (#2290) by @michenly

  • Update root to use Remix's Layout Export pattern and eliminate the use of useLoaderData in root. (#2292) by @michenly

    The diff below showcase how you can make this refactor in existing application.

    import {
      Outlet,
    -  useLoaderData,
    +  useRouteLoaderData,
    } from '@remix-run/react';
    -import {Layout} from '~/components/Layout';
    +import {PageLayout} from '~/components/PageLayout';
    
    -export default function App() {
    +export function Layout({children}: {children?: React.ReactNode}) {
      const nonce = useNonce();
    -  const data = useLoaderData<typeof loader>();
    +  const data = useRouteLoaderData<typeof loader>('root');
    
      return (
        <html>
        ...
          <body>
    -        <Layout {...data}>
    -          <Outlet />
    -        </Layout>
    +        {data? (
    +          <PageLayout {...data}>{children}</PageLayout>
    +         ) : (
    +          children
    +        )}
          </body>
        </html>
      );
    }
    
    +export default function App() {
    +  return <Outlet />;
    +}
    
    export function ErrorBoundary() {
    - const rootData = useLoaderData<typeof loader>();
    
      return (
    -    <html>
    -    ...
    -      <body>
    -        <Layout {...rootData}>
    -          <div className="route-error">
    -            <h1>Error</h1>
    -            ...
    -          </div>
    -        </Layout>
    -      </body>
    -    </html>
    +    <div className="route-error">
    +      <h1>Error</h1>
    +      ...
    +    </div>
      );
    }
    
  • Refactor the cart and product form components (#2132) by @blittle

  • Remove manual setting of session in headers and recommend setting it in server after response is created. (#2137) by @michenly

    Step 1: Add isPending implementation in session

    // in app/lib/session.ts
    export class AppSession implements HydrogenSession {
    +  public isPending = false;
    
      get unset() {
    +    this.isPending = true;
        return this.#session.unset;
      }
    
      get set() {
    +    this.isPending = true;
        return this.#session.set;
      }
    
      commit() {
    +    this.isPending = false;
        return this.#sessionStorage.commitSession(this.#session);
      }
    }

    Step 2: update response header if session.isPending is true

    // in server.ts
    export default {
      async fetch(request: Request): Promise<Response> {
        try {
          const response = await handleRequest(request);
    
    +      if (session.isPending) {
    +        response.headers.set('Set-Cookie', await session.commit());
    +      }
    
          return response;
        } catch (error) {
          ...
        }
      },
    };

    Step 3: remove setting cookie with session.commit() in routes

    // in route files
    export async function loader({context}: LoaderFunctionArgs) {
      return json({},
    -    {
    -      headers: {
    -        'Set-Cookie': await context.session.commit(),
    -      },
        },
      );
    }
  • Moved @shopify/cli from dependencies to devDependencies. (#2312) by @frandiox

  • The @shopify/cli package now bundles the @shopify/cli-hydrogen plugin. Therefore, you can now remove the latter from your local dependencies: (#2306) by @frandiox

        "@shopify/cli": "3.64.0",
    -   "@shopify/cli-hydrogen": "^8.1.1",
        "@shopify/hydrogen": "2024.7.0",
  • Updated dependencies [a0e84d76, 426bb390, 4337200c, 710625c7, 8b9c726d, 10a419bf, 6a6278bb, 66236ca6, dcbd0bbf, a5e03e2a, c2690653, 54c2f7ad, 4337200c, e96b332b, f3065371, 6cd5554b, 9eb60d73, e432533e, de3f70be, 83cb96f4]:

2024.4.5

Patch Changes

2024.4.4

Patch Changes

1.0.10

Patch Changes

  • Update @shopify/cli dependency to avoid React version mismatches in your project: (#2059) by @frandiox

      "dependencies": {
        ...
    -   "@shopify/cli": "3.58.0",
    +   "@shopify/cli": "3.59.2",
        ...
      }
  • Updated dependencies [d2bc720b]:

1.0.9

Patch Changes

1.0.8

Patch Changes

  • Stop inlining the favicon in base64 to avoid issues with the Content-Security-Policy. In vite.config.js: (#2006) by @frandiox

    export default defineConfig({
      plugins: [
        ...
      ],
    + build: {
    +   assetsInlineLimit: 0,
    + },
    });
  • To improve HMR in Vite, move the useRootLoaderData function from app/root.tsx to a separate file like app/lib/root-data.ts. This change avoids circular imports: (#2014) by @frandiox

    // app/lib/root-data.ts
    import { useMatches } from "@remix-run/react";
    import type { SerializeFrom } from "@shopify/remix-oxygen";
    import type { loader } from "~/root";
    
    /**
     * Access the result of the root loader from a React component.
     */
    export const useRootLoaderData = () => {
      const [root] = useMatches();
      return root?.data as SerializeFrom<typeof loader>;
    };

    Import this hook from ~/lib/root-data instead of ~/root in your components.

  • Updated dependencies [b4dfda32, ffa57bdb, ac4e1670, 0af624d5, 9723eaf3, e842f68c]:

1.0.7

Patch Changes

  • Update internal libraries for parsing .env files. (#1946) by @aswamy

    Please update the @shopify/cli dependency in your app to avoid duplicated subdependencies:

    "dependencies": {
    -   "@shopify/cli": "3.56.3",
    +   "@shopify/cli": "3.58.0",
    }
  • Add Adds magic Catalog route (#1967) by @juanpprieto

  • Update Vite plugin imports, and how their options are passed to Remix: (#1935) by @frandiox

    -import {hydrogen, oxygen} from '@shopify/cli-hydrogen/experimental-vite';
    +import {hydrogen} from '@shopify/hydrogen/vite';
    +import {oxygen} from '@shopify/mini-oxygen/vite';
    import {vitePlugin as remix} from '@remix-run/dev';
    
    export default defineConfig({
        hydrogen(),
        oxygen(),
        remix({
    -     buildDirectory: 'dist',
    +     presets: [hydrogen.preset()],
          future: {
  • Add @shopify/mini-oxygen as a dev dependency for local development: (#1891) by @frandiox

      "devDependencies": {
        "@remix-run/dev": "^2.8.0",
        "@remix-run/eslint-config": "^2.8.0",
    +   "@shopify/mini-oxygen": "^3.0.0",
        "@shopify/oxygen-workers-types": "^4.0.0",
        ...
      },
  • Add the customer-account push command to the Hydrogen CLI. This allows you to push the current --dev-origin URL to the Shopify admin to enable secure connection to the Customer Account API for local development. (#1804) by @michenly

  • Fix types returned by the session object. (#1869) by @frandiox

    In remix.env.d.ts or env.d.ts, add the following types:

    import type {
      // ...
      HydrogenCart,
    + HydrogenSessionData,
    } from '@shopify/hydrogen';
    
    // ...
    
    declare module '@shopify/remix-oxygen' {
      // ...
    
    + interface SessionData extends HydrogenSessionData {}
    }
  • Codegen dependencies must be now listed explicitly in package.json: (#1962) by @frandiox

    {
      "devDependencies": {
    +   "@graphql-codegen/cli": "5.0.2",
        "@remix-run/dev": "^2.8.0",
        "@remix-run/eslint-config": "^2.8.0",
    +   "@shopify/hydrogen-codegen": "^0.3.0",
        "@shopify/mini-oxygen": "^2.2.5",
        "@shopify/oxygen-workers-types": "^4.0.0",
        ...
      }
    }
  • Updated dependencies [4eaec272, 14bb5df1, 646b78d4, 87072950, 5f1295fe, 3c8a7313, ca1dcbb7, 11879b17, f4d6e5b0, 788d86b3, ebaf5529, da95bb1c, 5bb43304, 140e4768, 062d6be7, b3323e59, ab0df5a5, ebaf5529, ebaf5529, 9e899218, a209019f, d007b7bc, a5511cd7, 4afedb4d, 34fbae23, e3baaba5, 99d72f7a, 9351f9f5]:

1.0.6

Patch Changes

1.0.5

Patch Changes

  • Update the @shopify/cli dependency: (#1786) by @frandiox

    - "@shopify/cli": "3.52.0",
    + "@shopify/cli": "3.56.3",
  • Update Remix and associated packages to 2.8.0. (#1781) by @frandiox

    "dependencies": {
    -  "@remix-run/react": "^2.6.0",
    -  "@remix-run/server-runtime": "^2.6.0",
    +  "@remix-run/react": "^2.8.0",
    +  "@remix-run/server-runtime": "^2.8.0",
        //...
      },
      "devDependencies": {
    -   "@remix-run/dev": "^2.6.0",
    -   "@remix-run/eslint-config": "^2.6.0",
    +  "@remix-run/dev": "^2.8.0",
    +  "@remix-run/eslint-config": "^2.8.0",
        //...
      },
  • Updated dependencies [ced1d4cb, fc013401, e641255e, d7e04cb6, eedd9c49]:

1.0.4

Patch Changes

  • This is an important fix to a bug with 404 routes and path-based i18n projects where some unknown routes would not properly render a 404. This fixes all new projects, but to fix existing projects, add a ($locale).tsx route with the following contents: (#1732) by @blittle

    import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
    
    export async function loader({ params, context }: LoaderFunctionArgs) {
      const { language, country } = context.storefront.i18n;
    
      if (
        params.locale &&
        params.locale.toLowerCase() !== `${language}-${country}`.toLowerCase()
      ) {
        // If the locale URL param is defined, yet we still are still at the default locale
        // then the the locale param must be invalid, send to the 404 page
        throw new Response(null, { status: 404 });
      }
    
      return null;
    }
  • Add defensive null checks to the default cart implementation in the starter template (#1746) by @blittle

  • 🐛 Fix issue where customer login does not persist to checkout (#1719) by @michenly

    ✨ Add customerAccount option to createCartHandler. Where a ?logged_in=true will be added to the checkoutUrl for cart query if a customer is logged in.

  • Updated dependencies [faeba9f8, 6d585026, fcecfb23, 28864d6f, c0ec7714, 226cf478, 06d9fd91]:

1.0.3

Patch Changes

1.0.2

Patch Changes

1.0.1

Patch Changes

  • Sync up environment variable names across all example & type files. (#1542) by @michenly

  • Remove error boundary from robots.txt file in the Skeleton template (#1492) by @andrewcohen

  • Use the worker runtime by default when running the dev or preview commands. (#1525) by @frandiox

    Enable it in your project by adding the --worker flag to your package.json scripts:

    "scripts": {
      "build": "shopify hydrogen build",
    - "dev": "shopify hydrogen dev --codegen",
    + "dev": "shopify hydrogen dev --worker --codegen",
    - "preview": "npm run build && shopify hydrogen preview",
    + "preview": "npm run build && shopify hydrogen preview --worker",
      ...
    }
  • Update to the latest version of @shopify/oxygen-workers-types. (#1494) by @frandiox

    In TypeScript projects, when updating to the latest @shopify/remix-oxygen adapter release, you should also update to the latest version of @shopify/oxygen-workers-types:

    "devDependencies": {
      "@remix-run/dev": "2.1.0",
      "@remix-run/eslint-config": "2.1.0",
    - "@shopify/oxygen-workers-types": "^3.17.3",
    + "@shopify/oxygen-workers-types": "^4.0.0",
      "@shopify/prettier-config": "^1.1.2",
      ...
    },
  • Update internal dependencies for bug resolution. (#1496) by @vincentezw

    Update your @shopify/cli dependency to avoid duplicated sub-dependencies:

      "dependencies": {
    -   "@shopify/cli": "3.50.2",
    +   "@shopify/cli": "3.51.0",
      }
  • Update all Node.js dependencies to version 18. (Not a breaking change, since Node.js 18 is already required by Remix v2.) (#1543) by @michenly

  • 🐛 fix undefined menu error (#1533) by @michenly

  • Add @remix-run/server-runtime dependency. (#1489) by @frandiox

    Since Remix is now a peer dependency of @shopify/remix-oxygen, you need to add @remix-run/server-runtime to your dependencies, with the same version as the rest of your Remix dependencies.

    "dependencies": {
      "@remix-run/react": "2.1.0"
    + "@remix-run/server-runtime": "2.1.0"
      ...
    }
  • Updated dependencies [b2a350a7, 9b4f4534, 74ea1dba, 2be9ce82, a9b8bcde, bca112ed, 848c6260, d53b4ed7, 961fd8c6, 2bff9fc7, c8e8f6fd, 8fce70de, f90e4d47, e8cc49fe]:

1.0.0

Major Changes

  • The Storefront API 2023-10 now returns menu item URLs that include the primaryDomainUrl, instead of defaulting to the Shopify store ID URL (example.myshopify.com). The skeleton template requires changes to check for the primaryDomainUrl: by @blittle

    1. Update the HeaderMenu component to accept a primaryDomainUrl and include it in the internal url check
    // app/components/Header.tsx
    
    + import type {HeaderQuery} from 'storefrontapi.generated';
    
    export function HeaderMenu({
      menu,
    +  primaryDomainUrl,
      viewport,
    }: {
      menu: HeaderProps['header']['menu'];
    +  primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url'];
      viewport: Viewport;
    }) {
    
      // ...code
    
      // if the url is internal, we strip the domain
      const url =
        item.url.includes('myshopify.com') ||
        item.url.includes(publicStoreDomain) ||
    +   item.url.includes(primaryDomainUrl)
          ? new URL(item.url).pathname
          : item.url;
    
       // ...code
    
    }
    1. Update the FooterMenu component to accept a primaryDomainUrl prop and include it in the internal url check
    // app/components/Footer.tsx
    
    - import type {FooterQuery} from 'storefrontapi.generated';
    + import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
    
    function FooterMenu({
      menu,
    +  primaryDomainUrl,
    }: {
      menu: FooterQuery['menu'];
    +  primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url'];
    }) {
      // code...
    
      // if the url is internal, we strip the domain
      const url =
        item.url.includes('myshopify.com') ||
        item.url.includes(publicStoreDomain) ||
    +   item.url.includes(primaryDomainUrl)
          ? new URL(item.url).pathname
          : item.url;
    
       // ...code
    
      );
    }
    1. Update the Footer component to accept a shop prop
    export function Footer({
      menu,
    + shop,
    }: FooterQuery & {shop: HeaderQuery['shop']}) {
      return (
        <footer className="footer">
    -      <FooterMenu menu={menu} />
    +      <FooterMenu menu={menu} primaryDomainUrl={shop.primaryDomain.url} />
        </footer>
      );
    }
    1. Update Layout.tsx to pass the shop prop
    export function Layout({
      cart,
      children = null,
      footer,
      header,
      isLoggedIn,
    }: LayoutProps) {
      return (
        <>
          <CartAside cart={cart} />
          <SearchAside />
          <MobileMenuAside menu={header.menu} shop={header.shop} />
          <Header header={header} cart={cart} isLoggedIn={isLoggedIn} />
          <main>{children}</main>
          <Suspense>
            <Await resolve={footer}>
    -          {(footer) => <Footer menu={footer.menu}  />}
    +          {(footer) => <Footer menu={footer.menu} shop={header.shop} />}
            </Await>
          </Suspense>
        </>
      );
    }

Patch Changes