Shopify 公式ヘッドレスコマースフレームワーク "Hydrogen" とは

今年もアドベントカレンダーの時期ですね。この記事は [Shopify](https://qiita.com/advent-calendar/2021/shopify) が [React](https://qiita.com/advent-calendar/2021/react) をベースに構築した公式のヘッドレスコマースフレームワーク Hydrogen の調査記事です。掲載画像やソースコードは全て Hydrogen のものを利用しています。

https://hydrogen.shopify.dev/

（2022-03 追記）
Hydrogen ドキュメントを和訳しています。より詳しく知りたい方はこちらをどうぞ。

https://qiita.com/t-kurasawa/items/b5f80f951fa574b17276


# Hydrogen とは

Hydrogen は Shopify Unite 2021 で発表されました。

https://unite.shopify.com/

https://www.shopify.jp/blog/partner-hydrogen-developer-preview

[Shopify は API が豊富でマイクロサービスアーキテクチャ](https://qiita.com/t-kurasawa/items/8f8ac73014d73af8ac10)に適しています。Hydrogen は [Shopify をヘッドレスコマースとして利用](https://www.shopify.jp/blog/headless-commerce)して、「ヘッド」の EC フロント部分を React で構築するためのフレームワークです。

## ヘッドレスコマースとは

EC サイトは、購入してもらうために時代のトレンドに合わせて柔軟に UI/UX を素早く変更していく必要がある一方で、お金を扱うためミスが許されない慎重さが求められるシステムです。なかなか両立するのが難しく、この課題に悩まされている人は多いことかと思います。

これはフロントエンド（UI/UX）とバックエンド（機能・DB）が一体化した作りになっていることが根本的な原因です。ヘッドレスコマースは、フロントエンドとバックエンドを分離するための設計手法です。

## ヘッドレスコマースに用いる技術

Shopify はフロントエンド（Liquid）とバックエンド（API）が分離した作りになっています。[Liquid を用いてフロントを開発する](https://qiita.com/t-kurasawa/items/3dc49d46f0b5e9659da5)と Shopify のサーバー上に ECサイトを構築できます。
新規で EC サイトを構築する分にはそれで十分かと思いますが、既存の会員サイトやスマホアプリに EC 機能を組み込みたいという場合には Shopify の API を用いて Shopify の外側にサイト・アプリケーションを構築します。

※その辺りの解説記事は以下を御覧ください。

https://qiita.com/t-kurasawa/items/afa4f4a64bee7e132aec

ヘッドレスの流れを汲んで Next.js や Gatsby もヘッドレスコマースに取り組んでいます。[Shopify Partner of the Year 2021](https://www.shopify.jp/blog/partner-of-the-year-japan-winners)に選ばれた[non-standardworldさんの解説記事](https://www.non-standardworld.co.jp/23224/)がわかりやすいのでご紹介します。

https://nextjs.org/commerce

しかしながら、これらは開発者各々が学習する必要があり、なかなか取り組みやすいものではありませんでした。そこに登場したのが Shopify 公式フレームワークの Hydrogen です。それでは、私自身の学習も兼ねて解説記事を書いていきたいと思います。

執筆時点では Developer Preview です。

# 実際に試してみましょう

まずは hydrogen のベースをインストールします。

```
$ npx create-hydrogen-app

Need to install the following packages:
  create-hydrogen-app
Ok to proceed? (y)
✔ Project name: · hydrogen-app

Scaffolding Hydrogen app in /mnt/c/dev/prac/hydrogen-app...

Done. Now:

  Update hydrogen-app/shopify.config.js with the values for your storefront. If you want to test your Hydrogen app using the demo store, you can skip this step.

and then run:

$ cd hydrogen-app
$ npm install --legacy-peer-deps
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/0bdbf130-6ba0-3e0e-a994-d2b73ef561a0.png)

動かしてみましょう。

```
$ npm run dev

> hydrogen-app@0.6.4 dev
> vite

Pre-bundling dependencies:
  @headlessui/react
  react/jsx-dev-runtime
  html-dom-parser
  html-react-parser
  react-helmet-async
  (...and 3 more)
(this will be run only when your dependencies or config have changed)

  vite v2.7.1 dev server running at:

  > Local: http://localhost:3000/
  > Network: use `--host` to expose

  ready in 1291ms.
```



### トップページ
`localhost:3000/`


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/7aadc3c0-e040-a370-48b6-0c0b44b59502.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/4b395673-1226-35c3-4a56-1a9d17a7363c.png)


```src/pages/Index.server.jsx
import {
  useShopQuery,
  flattenConnection,
  ProductProviderFragment,
  Image,
  Link,
} from '@shopify/hydrogen';
import gql from 'graphql-tag';

import Layout from '../components/Layout.server';
import FeaturedCollection from '../components/FeaturedCollection.server';
import ProductCard from '../components/ProductCard.server';
import Welcome from '../components/Welcome.server';

function GradientBackground() {
  return (
    <div className="fixed top-0 w-full h-3/5 overflow-hidden">
      <div className="absolute w-full h-full bg-gradient-to-t from-gray-50 z-10" />

      <svg
        viewBox="0 0 960 743"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
        xmlnsXlink="http://www.w3.org/1999/xlink"
        className="filter blur-[30px]"
        aria-hidden="true"
      >
        <defs>
          <path fill="#fff" d="M0 0h960v540H0z" id="reuse-0" />
        </defs>
        <g clipPath="url(#a)">
          <use xlinkHref="#reuse-0" />
          <path d="M960 0H0v743h960V0Z" fill="#7CFBEE" />
          <path
            d="M831 380c200.48 0 363-162.521 363-363s-162.52-363-363-363c-200.479 0-363 162.521-363 363s162.521 363 363 363Z"
            fill="#4F98D0"
          />
          <path
            d="M579 759c200.479 0 363-162.521 363-363S779.479 33 579 33 216 195.521 216 396s162.521 363 363 363Z"
            fill="#7CFBEE"
          />
          <path
            d="M178 691c200.479 0 363-162.521 363-363S378.479-35 178-35c-200.4794 0-363 162.521-363 363s162.5206 363 363 363Z"
            fill="#4F98D0"
          />
          <path
            d="M490 414c200.479 0 363-162.521 363-363S690.479-312 490-312 127-149.479 127 51s162.521 363 363 363Z"
            fill="#4F98D0"
          />
          <path
            d="M354 569c200.479 0 363-162.521 363-363 0-200.47937-162.521-363-363-363S-9 5.52063-9 206c0 200.479 162.521 363 363 363Z"
            fill="#7CFBEE"
          />
          <path
            d="M630 532c200.479 0 363-162.521 363-363 0-200.4794-162.521-363-363-363S267-31.4794 267 169c0 200.479 162.521 363 363 363Z"
            fill="#4F98D0"
          />
        </g>
        <path fill="#fff" d="M0 540h960v203H0z" />
        <defs>
          <clipPath id="a">
            <use xlinkHref="#reuse-0" />
          </clipPath>
        </defs>
      </svg>
    </div>
  );
}

export default function Index({country = {isoCode: 'US'}}) {
  const {data} = useShopQuery({
    query: QUERY,
    variables: {
      country: country.isoCode,
    },
  });

  const collections = data ? flattenConnection(data.collections) : [];
  const featuredProductsCollection = collections[0];
  const featuredProducts = featuredProductsCollection
    ? flattenConnection(featuredProductsCollection.products)
    : null;
  const featuredCollection =
    collections && collections.length > 1 ? collections[1] : collections[0];

  return (
    <Layout hero={<GradientBackground />}>
      <div className="relative mb-12">
        <Welcome />
        <div className="bg-white p-12 shadow-xl rounded-xl mb-10">
          {featuredProductsCollection ? (
            <>
              <div className="flex justify-between items-center mb-8 text-md font-medium">
                <span className="text-black uppercase">
                  {featuredProductsCollection.title}
                </span>
                <span className="hidden md:inline-flex">
                  <Link
                    to={`/collections/${featuredProductsCollection.handle}`}
                    className="text-blue-600 hover:underline"
                  >
                    Shop all
                  </Link>
                </span>
              </div>
              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-8">
                {featuredProducts.map((product) => (
                  <div key={product.id}>
                    <ProductCard product={product} />
                  </div>
                ))}
              </div>
              <div className="md:hidden text-center">
                <Link
                  to={`/collections/${featuredCollection.handle}`}
                  className="text-blue-600"
                >
                  Shop all
                </Link>
              </div>
            </>
          ) : null}
        </div>
        <FeaturedCollection collection={featuredCollection} />
      </div>
    </Layout>
  );
}

const QUERY = gql`
  query indexContent(
    $country: CountryCode
    $numCollections: Int = 2
    $numProducts: Int = 3
    $numProductMetafields: Int = 0
    $numProductVariants: Int = 250
    $numProductMedia: Int = 1
    $numProductVariantMetafields: Int = 10
    $numProductVariantSellingPlanAllocations: Int = 0
    $numProductSellingPlanGroups: Int = 0
    $numProductSellingPlans: Int = 0
  ) @inContext(country: $country) {
    collections(first: $numCollections) {
      edges {
        node {
          descriptionHtml
          description
          handle
          id
          title
          image {
            ...ImageFragment
          }
          products(first: $numProducts) {
            edges {
              node {
                ...ProductProviderFragment
              }
            }
          }
        }
      }
    }
  }

  ${ProductProviderFragment}
  ${Image.Fragment}
`;
```


### 商品ページ
`localhost:3000/products/snowboard`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/98238d4f-4d0f-b7c4-785d-53109dcd8ac4.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/041ea801-3500-09e7-5167-322d1dbc0ed7.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/4e41628a-1071-a452-e003-1f107b5d6823.png)


```src/pages/products/[handle].server.jsx
import {useShopQuery, ProductProviderFragment} from '@shopify/hydrogen';
import {useParams} from 'react-router-dom';
import gql from 'graphql-tag';

import ProductDetails from '../../components/ProductDetails.client';
import NotFound from '../../components/NotFound.server';
import Layout from '../../components/Layout.server';

export default function Product({country = {isoCode: 'US'}}) {
  const {handle} = useParams();

  const {data} = useShopQuery({
    query: QUERY,
    variables: {
      country: country.isoCode,
      handle,
    },
  });

  if (!data.product) {
    return <NotFound />;
  }

  return (
    <Layout>
      <ProductDetails product={data.product} />
    </Layout>
  );
}

const QUERY = gql`
  query product(
    $country: CountryCode
    $handle: String!
    $numProductMetafields: Int = 20
    $numProductVariants: Int = 250
    $numProductMedia: Int = 6
    $numProductVariantMetafields: Int = 10
    $numProductVariantSellingPlanAllocations: Int = 0
    $numProductSellingPlanGroups: Int = 0
    $numProductSellingPlans: Int = 0
  ) @inContext(country: $country) {
    product: product(handle: $handle) {
      id
      vendor
      seo {
        title
        description
      }
      images(first: 1) {
        edges {
          node {
            url
          }
        }
      }
      ...ProductProviderFragment
    }
  }

  ${ProductProviderFragment}
`;
```

### チェックアウト
`hydrogen-preview.myshopify.com/5514****/checkouts/3988****`

これはいつもの Shopify のチェックアウト画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/249ab576-a2ce-e128-8137-8e748c3ae1ac.png)


### コレクション
`localhost:3000/collections/freestyle-collection`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/91c52284-5e98-eb8b-f2dc-457ce50d8587.png)

```src/pages/collections/[handle].server.jsx
import {
  MediaFileFragment,
  ProductProviderFragment,
  useShopQuery,
  flattenConnection,
  RawHtml,
} from '@shopify/hydrogen';
import {useParams} from 'react-router-dom';
import gql from 'graphql-tag';

import LoadMoreProducts from '../../components/LoadMoreProducts.client';
import Layout from '../../components/Layout.server';
import ProductCard from '../../components/ProductCard.server';
import NotFound from '../../components/NotFound.server';

export default function Collection({
  country = {isoCode: 'US'},
  collectionProductCount = 24,
}) {
  const {handle} = useParams();
  const {data} = useShopQuery({
    query: QUERY,
    variables: {
      handle,
      country: country.isoCode,
      numProducts: collectionProductCount,
    },
  });

  if (data?.collection == null) {
    return <NotFound />;
  }

  const collection = data.collection;
  const products = flattenConnection(collection.products);
  const hasNextPage = data.collection.products.pageInfo.hasNextPage;

  return (
    <Layout>
      <h1 className="font-bold text-4xl md:text-5xl text-gray-900 mb-6 mt-6">
        {collection.title}
      </h1>
      <RawHtml string={collection.descriptionHtml} className="text-2xl" />
      <p className="text-sm text-gray-500 mt-5 mb-5">
        {products.length} {products.length > 1 ? 'products' : 'product'}
      </p>

      <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
        {products.map((product) => (
          <li key={product.id}>
            <ProductCard product={product} />
          </li>
        ))}
      </ul>

      {hasNextPage && (
        <LoadMoreProducts startingCount={collectionProductCount} />
      )}
    </Layout>
  );
}

const QUERY = gql`
  query CollectionDetails(
    $handle: String!
    $country: CountryCode
    $numProducts: Int!
    $numProductMetafields: Int = 0
    $numProductVariants: Int = 250
    $numProductMedia: Int = 6
    $numProductVariantMetafields: Int = 0
    $numProductVariantSellingPlanAllocations: Int = 0
    $numProductSellingPlanGroups: Int = 0
    $numProductSellingPlans: Int = 0
  ) @inContext(country: $country) {
    collection(handle: $handle) {
      id
      title
      descriptionHtml

      products(first: $numProducts) {
        edges {
          node {
            vendor
            ...ProductProviderFragment
          }
        }
        pageInfo {
          hasNextPage
        }
      }
    }
  }

  ${MediaFileFragment}
  ${ProductProviderFragment}
`;
```

### 404エラーページ
`localhost:3000/error-page`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/37621a4a-05c9-cf1c-5bd7-3af20e1aaccd.png)

App 全体で NotFound を拾ったり、個別の collections, products, pages で見つからなかった時の処理を書いています。

```src/pages/products/[handle].server.jsx
  if (data?.collection == null) {
    return <NotFound />;
  }
```

```src/App.server.tsx
import {ShopifyServerProvider, DefaultRoutes} from '@shopify/hydrogen';
import {Switch} from 'react-router-dom';
import {Suspense} from 'react';

import shopifyConfig from '../shopify.config';

import DefaultSeo from './components/DefaultSeo.server';
import NotFound from './components/NotFound.server';
import CartProvider from './components/CartProvider.client';
import LoadingFallback from './components/LoadingFallback';

export default function App({...serverState}) {
  const pages = import.meta.globEager('./pages/**/*.server.[jt]sx');

  return (
    <Suspense fallback={<LoadingFallback />}>
      <ShopifyServerProvider shopifyConfig={shopifyConfig} {...serverState}>
        <CartProvider>
          <DefaultSeo />
          <Switch>
            <DefaultRoutes
              pages={pages}
              serverState={serverState}
              fallback={<NotFound />}
            />
          </Switch>
        </CartProvider>
      </ShopifyServerProvider>
    </Suspense>
  );
}
```




```src/components/NotFound.server.jsx
import {
  useShopQuery,
  ProductProviderFragment,
  flattenConnection,
} from '@shopify/hydrogen';
import gql from 'graphql-tag';

import Layout from './Layout.server';
import Button from './Button.client';
import ProductCard from './ProductCard.server';

function NotFoundHero() {
  return (
    <div className="py-10 border-b border-gray-200">
      <div className="max-w-3xl text-center mx-4 md:mx-auto">
        <h1 className="text-gray-700 text-5xl font-bold mb-4">
          We&#39;ve lost this page
        </h1>
        <p className="text-xl m-8 text-gray-500">
          We couldn’t find the page you’re looking for. Try checking the URL or
          heading back to the home page.
        </p>
        <Button
          className="w-full md:mx-auto md:w-96"
          url="/"
          label="Take me to the home page"
        />
      </div>
    </div>
  );
}

export default function NotFound({country = {isoCode: 'US'}}) {
  const {data} = useShopQuery({
    query: QUERY,
    variables: {
      country: country.isoCode,
      numProductMetafields: 0,
      numProductVariants: 250,
      numProductMedia: 0,
      numProductVariantMetafields: 0,
      numProductVariantSellingPlanAllocations: 0,
      numProductSellingPlanGroups: 0,
      numProductSellingPlans: 0,
    },
  });
  const products = data ? flattenConnection(data.products) : [];

  return (
    <Layout>
      <NotFoundHero />
      <div className="my-8">
        <p className="mb-8 text-lg text-black font-medium uppercase">
          Products you might like
        </p>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
          {products.map((product) => (
            <div key={product.id}>
              <ProductCard product={product} />
            </div>
          ))}
        </div>
      </div>
    </Layout>
  );
}

const QUERY = gql`
  query NotFoundProductDetails(
    $country: CountryCode
    $numProductMetafields: Int!
    $numProductVariants: Int!
    $numProductMedia: Int!
    $numProductVariantMetafields: Int!
    $numProductVariantSellingPlanAllocations: Int!
    $numProductSellingPlanGroups: Int!
    $numProductSellingPlans: Int!
  ) @inContext(country: $country) {
    products(first: 3) {
      edges {
        node {
          ...ProductProviderFragment
        }
      }
    }
  }

  ${ProductProviderFragment}
`;
```

### ページ
`localhost:3000/home`

ソースコードはあるのですが、サンプルのストアにはページのデータが登録されていないのか見つかりませんでした。（Developer Preview なのでご愛嬌）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/02c3f681-2bce-b898-4e43-6e8ea78f4125.png)


```src/pages/pages/[handle].server.jsx
import {useParams} from 'react-router-dom';
import {useShopQuery, RawHtml} from '@shopify/hydrogen';
import gql from 'graphql-tag';

import Layout from '../../components/Layout.server';
import NotFound from '../../components/NotFound.server';

export default function Page() {
  const {handle} = useParams();
  const {data} = useShopQuery({query: QUERY, variables: {handle}});

  if (!data.pageByHandle) {
    return <NotFound />;
  }

  const page = data.pageByHandle;

  return (
    <Layout>
      <h1 className="text-2xl font-bold">{page.title}</h1>
      <RawHtml string={page.body} className="prose mt-8" />
    </Layout>
  );
}

const QUERY = gql`
  query PageDetails($handle: String!) {
    pageByHandle(handle: $handle) {
      title
      body
    }
  }
`;
```

### コンポーネント

コンポーネントは個別のサイトで中身は変わると思いますが Hydrogen にもかなりの数が準備されていて嬉しいですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/7189d2ab-ca74-1f84-41d9-46da01dd1d5d.png)


### storefront の設定

```shopify.config.js
export default {
  locale: 'en-us',
  storeDomain: 'hydrogen-preview.myshopify.com',
  storefrontToken: '3b58********',
  graphqlApiVersion: 'unstable',
};
```

# 所感
Server-Side Rendering (SSR) や StoreFront API にアクセスするための GraphQL が整備されていることがありがたいです。また、Shopify データモデルに沿って [components, hooks, utilities](https://shopify.dev/custom-storefronts/hydrogen/framework/react-server-components) を扱えばいいので開発は随分楽になると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127260/45942630-e087-f1e4-1a38-57be8e23b2b3.png)
出典）[React Conf 2021 keynote](https://conf.reactjs.org/stage)

先日開催された React Conf 2021 でも React は「design principles」（設計原則）に根差していて、デザインとプログラミングの統合という話が印象的でした。Hydrogen + React 18 のパートもありましたので時間がある方は是非ご覧ください。

https://conf.reactjs.org/stage

単純に、フロントエンド（UI/UX=デザイナー）とバックエンド（機能・DB=プログラマー）を切り離すだけならこれまでも出来ていたはずなのに、ここに来てヘッドレスコマースが現実味を帯びてきたのは React の進化のおかげと言えます。React とヘッドレスコマースは補完関係にあり、今後もこの流れは目が離せませんね。

以上、Hydrogen の調査記事でした。
まだ Developer Preview なので GA が待ち遠しいです。