タイトルのとおり、ShopifyのStorefront APIを使用してNext.js製のECフロントを構築しました。
仕事でECサイトのフロントエンドを構築しているので、そこで培った技術とかノウハウを完全に自分のものとして公開できる何かがほしいなと思ったのが作り始めたきっかけです。

できあがったもの

CSSをほぼ書いていない状態なので、見た目に関してはご容赦ください。。
https://eringiv3-shopify-store-front-with-nextjs.vercel.app/

リポジトリ

https://github.com/EringiV3/shopify-store-front-with-nextjs

出来上がったものの解説

  • 商品一覧、商品詳細、カートトップのECサイトに必要だと思われる最低限の機能を持つ3ページを備えた構成
  • ログインはできない
  • 購入確定画面はShopifyが提供している購入確定画面に飛ばして購入してもらう仕組み(今回作ったサイトはサンドボックス環境なので実際の購入はできない)


使用した技術・ライブラリ

  • TypeScript
  • Next.js
  • Recoil
  • Material UI
  • Shopify js-buy-sdk

以前作ったNext.js + TypeScriptのテンプレートをベースに実装しています。
https://dev.eringiv3.com/blog/post/nextjs_typescript_template

以上が概要です。

Next.jsでECサイトのフロントエンドを構築することについて

まず、前提としてECサイトなので検索からの流入を増やすためのSEO対策はマストです。
世の中はVue.jsやReactのようなJSによってDOMを生成するUIライブラリ・フレームワークの勢いが強まっていくばかりですが、SEOの観点からECサイトをそれらの技術で構築することが難しいといったケースはおそらくそれなりにあるのではないかと思います(GoogleのクローラーはJSの実行を待ってくれるようになっていると聞くので段々とSPA == 弱SEOでもなくなってきいるのかもしれないですが詳しくないので話せないです)。
その問題を解決してくれるのが、Next.js, Nuxt.js, Gatsby.jsのようなSSR/SSGをいい感じにやってくれるフレームワークですね。

中でもNext.jsではIncremental Static Regeneration(ISR)があるのが強みかなと個人的には思います。
ECサイトでは商品詳細画面のような、商品の数だけ個別のページが存在するのが一般的で、それに対してはパフォーマンス・運用面でISRはうってつけの解決策でしょう。
Gatsbyのような静的サイトジェネレータでは商品情報に変更を加えるたびにビルド・デプロイし直す必要がありますが、ISRを使えばデータソースの変更の度のビルド・デプロイは不要なので柔軟な運用ができそうです。

というようなことを考えていたら、2020年10月末にNext.js10とともにNext.js Commerceというものが発表されました。
Next.js CommerceはBigCommerceを使用したヘッドレスなECサイト構築のためのパッケージです。数回クリックするだけでデプロイしてECサイトを作れるよ、というのが売りのようです。
すごく個人的な話ですが、自分は仕事ではLaravelでECフロントを構築していて、Laravelに付属しているBladeというサーバーサイドのテンプレートエンジンでHTMLを吐き出し、それに素のJSで動きを足していくということをやっているので、それがNext.js(React)によって宣言的UIで記述できるようになるかと思うとかなりエキサイティングでした。

また、JAMStsackという言葉が流行っているようにヘッドレスなCMSに続いてヘッドレスなECシステムというのはこれからもっと流行っていくと思っています。
現状Next.js CommerceはBigCommerceしかサポートしてないようですが、公式サイトをみると他のECシステムのロゴ画像も乗っているのでいずれはShopifyや他のECシステムとも連携できるようになりそうです。

作っていく

今回は、個人的に興味があったShopifyをバックエンドに使ってNext.jsでECフロントを構築します。
ShopifyにはStorefront APIというECサイトのフロントエンドを構築するのに必要なデータを取得できるAPIがGraphQLで提供されています。
https://shopify.dev/docs/storefront-api

それを生で使ってもいいのですが、Storefront APIをラップしてよりシンプルなインターフェイスで提供してくれているjs-buy-sdkというSDKがあります。
ドキュメントの量がそこそこ多く、Storefonrt APIの仕様は全然把握しきれていないですが、js-buy-sdkはシンプルな分できることは限られているのかなという印象です。
https://shopify.dev/tools/libraries/storefront-api/javascript

このjs-buy-sdk、公式のexamplesの数がめちゃくちゃ豊富でreact, angular, emberなど様々なUIフレームワークを使った実装が存在しているので、SDKの使い方で困ることはまずないのが嬉しい点です。
https://github.com/Shopify/storefront-api-examples

今回お試しでECフロントを構築するのにあたって、js-buy-sdkの公式exampleの中で公開されているサンドボックスストアを使わせてもらいました(時間あったらちゃんと自分でストアの設定からやってみたい。購入できないようになってるので問題ないはず)。
https://github.com/Shopify/storefront-api-examples/blob/master/react-js-buy/src/index.js

このjs-buy-sdkを使って、実装します。
以下の最小限の機能の実装を一旦のゴールとしました。

  • 商品一覧画面から商品詳細画面に遷移できる
  • 商品詳細画面で商品をカートに追加することができる
  • カートトップ画面でカートに追加されている商品の確認・数量変更・購入確定ページへの遷移ができる


商品一覧ページ

まずは商品一覧から。商品全件を取得して表示するだけです。実際にはページネーションの用意が必須だと思いますが今回は実装省略しています。
getServerSidePropsで商品全件を取得してSearchResultコンポーネントに渡します。
ECサイトの商品一覧って、レフトナビや絞り込みのUIが用意されていてそこから対話的に検索条件を追加して検索結果を絞り込んでいくのでSSGだと対応しきれないかなと思って悩んでSSRにしました。
正直今回の規模ならSSGにしたほうが軽くなるのでいいんですが(絞り込みUIとかまで実装してないし)、現実的なユースケースとしては検索条件ってたくさんあってクエリストリングに付与することになると思うのでSSRが現実的だと考えました。ですが今考えたら検索結果以外の部分だけサーバーサイドでレンダリングして(SEOのため)、検索結果はuseEffectでデータ取得でもいい気がしました。

import { GetServerSideProps } from 'next';
import { Product } from '@/types';
import { Layout } from '@/components/layout';
import { SearchResult } from '@/components/product';
import { client } from '@/shopify/client';

type Props = {
  products: Product[];
};

const ProductListPage: React.FC<Props> = ({ products }) => {
  return (
    <Layout title="商品一覧">
      <SearchResult products={products} />
    </Layout>
  );
};

export const getServerSideProps: GetServerSideProps = async () => {
  const products: Product[] = await client.product.fetchAll();
  return {
    props: {
      // SerializableErrorの回避のため
      // @see https://github.com/vercel/next.js/issues/11993
      products: JSON.parse(JSON.stringify(products)),
    },
  };
};

export default ProductListPage;


商品詳細

商品詳細はISRします。revalidateに指定する秒数は、1秒にしてますがそんなに大規模なECサイトでなければ5~15分とかにしてもいいと思います。
後述しますが、カートに対する操作(商品詳細ではカートへの追加)はカスタムフックに切り出してカプセル化しています。

import { GetStaticProps, GetStaticPaths } from 'next';
import { client } from '@/shopify/client';
import { ProductDetail } from '@/components/product';
import { Product } from '@/types';
import { Layout } from '@/components/layout';

type Props = {
  product: Product;
  errors?: any;
};

const ProductDetailPage: React.FC<Props> = ({ product, errors }) => {
  if (!product) return <div>loading...</div>;
  if (errors) return <div>error</div>;
  return (
    <Layout title={product.title}>
      <ProductDetail product={product} />
    </Layout>
  );
};

export default ProductDetailPage;

export const getStaticPaths: GetStaticPaths = async () => ({
  paths: [],
  fallback: true,
});

export const getStaticProps: GetStaticProps = async ({ params }) => {
  try {
    const id = params?.id;
    if (!id) throw new Error('idが取得できません');
    const product = await client.product.fetch(id as string);
    return {
      props: { product: JSON.parse(JSON.stringify(product)) },
      revalidate: 1,
    };
  } catch (err) {
    return { props: { errors: err.message } };
  }
};


カートトップ

クライアントサイドでカートをフェッチし、クライアントサイドでレンダリングします。
カートをグローバルな状態として管理するためにRecoilを使用しています。

import { CartProducts } from '@/components/cart';
import { useCart } from '@/hooks/cart/useCart';
import { Layout } from '@/components/layout';

const CartTopPage: React.FC = () => {
  const { cart, fetchCart } = useCart();
  fetchCart();
  return (
    <Layout title="カートトップ">
      <h1>ショッピングカート</h1>
      {cart === null ? <div>loading...</div> : <CartProducts />}
    </Layout>
  );
};

export default CartTopPage;


カスタムフック

Recoilと合わせてuseCartというカスタムフックを作りました。カートが持つ一意なIDはブラウザのローカルストレージに保存して、カートに対する操作を行うときに使用します。
このフックでは、カート内商品の数量変更、カートからの商品の削除、カートへの商品の追加、カートの取得の操作を提供します。
ロジックをカスタムフックに切り出して、状態の更新処理を集約するというのを初めてやりましたが、たしかに各コンポーネントからjsx以外のコード量が減って再利用も容易にすることができました。

import { Cart } from '@/types';
import { useEffect } from 'react';
import { client } from '@/shopify/client';
import { getCheckoutId, setCheckoutId } from '@/utils/helpers';
import { atom, useRecoilState, selector, useRecoilValue } from 'recoil';

type useCartInterface = {
  cart: Cart | null;
  cartItemQuantity: number;
  changeQuantity: (skuId: string, quantity: string) => void;
  removeProduct: (productId: string) => void;
  addToCart: (skuId: string | number) => Promise<void>;
  fetchCart: () => void;
};

const cartState = atom<Cart | null>({
  key: 'cartState',
  default: null,
});

const cartItemQuantityState = selector({
  key: 'cartItemQuantityState',
  get: ({ get }) =>
    get(cartState)?.lineItems.reduce(
      (accumulator, currentValue) => accumulator + currentValue.quantity,
      0
    ) ?? 0,
});

export const useCart = (): useCartInterface => {
  const [cart, setCart] = useRecoilState(cartState);
  const cartItemQuantity = useRecoilValue(cartItemQuantityState);

  /**
   * カートを初期化します
   */
  const initializeCart = () => {
    useEffect(() => {
      const checkoutId = getCheckoutId();
      if (checkoutId) return;
      client.checkout.create().then((cart) => {
        setCart(cart as Cart);
        setCheckoutId(cart.id);
      });
    }, []);
  };

  initializeCart();

  /**
   * カート内の商品の数量をquantityに変更します
   * @param skuId
   * @param quantity
   */
  const changeQuantity = (skuId: string, quantity: string): void => {
    if (!cart) return;
    client.checkout
      // @ts-ignore
      .updateLineItems(cart.id, [{ id: skuId, quantity: parseInt(quantity) }])
      .then((cart: Cart) => {
        setCart(cart as Cart);
      });
  };

  /**
   * カートから商品を削除します
   * @param productId
   */
  const removeProduct = (productId: string): void => {
    if (!cart) return;
    client.checkout.removeLineItems(cart.id, [productId]).then((cart) => {
      setCart(cart as Cart);
    });
  };

  /**
   * カートに商品を追加する
   * @param skuId
   */
  const addToCart = (skuId: string | number): Promise<void> => {
    return client.checkout
      .addLineItems(getCheckoutId(), [
        {
          variantId: skuId,
          quantity: 1,
        },
      ])
      .then((cart) => setCart(cart as Cart));
  };

  /**
   * カートを取得します
   */
  const fetchCart = () => {
    useEffect(() => {
      const checkoutId = getCheckoutId();
      if (!checkoutId) return;
      client.checkout.fetch(checkoutId).then((cart) => setCart(cart as Cart));
    }, []);
  };

  return {
    cart,
    cartItemQuantity,
    changeQuantity,
    removeProduct,
    addToCart,
    fetchCart,
  };
};


カート内商品の数量変更したり、カートから商品を削除したりすると、グローバルステートのcartItemQuantityが更新されるのでヘッダーのカート内商品点数の数字も再レンダリングされます。
仕事で素のJSでカートページでAjaxで数量変更のリクエストをサーバに送信してサーバーサイドでPHPでゴニョゴニョしてAjaxレスポンス返してJSでそれレスポンスを受け取ってHTMLをまるごと書き換えるみたいなことをやっていた人間からするとReactの仮想DOM, 差分レンダリングの仕組みは素晴らしすぎて涙が出ます。

その他

他に書いておきたいことといえばjs-buy-sdkの型定義についてでしょうか。
このライブラリ、最近はあまり活発にメンテナンスされてないようで最後のコミットが5ヶ月前とかになってます。
そのせいか、Storefonrt APIの変更にjs-buy-sdkが追いついておらず提供されている型定義と実際のデータ構造が食い違っているということが開発中何度もありました。
ライブラリの型定義を拡張する、というのもやったことがなかったのですが、やりました。ちょっと食い違っているプロパティの数が多すぎて完璧ではなくあまりいい状態ではないのですが、使うプロパティに関しては正しい型定義で拡張してくという対応を取りました。

import {
  Option as SdkOption,
  ProductVariant as SdkProductVariant,
  Product as SdkProduct,
  Cart as SdkCart,
} from 'shopify-buy';

export type SelectedOption = {
  name: string;
  value: string;
};

export type Sku = {
  selectedOptions: SelectedOption[];
  image: {
    altText?: string | null;
  };
  product: {
    id: string;
  };
} & SdkProductVariant;

export type Option = {} & SdkOption;

export type Product = {} & SdkProduct;

type lineItem = {
  id: string;
  title: string;
  quantity: number;
  variant: Sku;
};

export type Cart = {
  lineItems: lineItem[];
  webUrl: string;
} & SdkCart;


また、js-buy-sdkにはログインのためのインターフェイスが提供されていません。
なのでECサイト上での購入までの流れとしては、「カスタムECフロント(今回作ったような自分でつくるフロント)で商品をカートに追加→購入ボタン押下でShopifyECフロント(Shopifyのテーマとかで提供されているできあがってるフロント)の購入確定ページへリダイレクト→アカウントがある人は購入確定ページでログインしてもらう」という感じになると思います。
ただ、要件としてECサイト上でログインしてもらって過去の購入履歴とかを見れるようにしたいというのはあると思うので、そのへんは柔軟にShopifyのテーマを使ったフロントのマイページへの動線を用意するなどするしかなさそうです。
このあたりはUX的に改善の余地がありそうです。

感想

ShopifyのStoreFront APIのできがいいのと、React, Recoilでの開発体験がよすぎてだいぶスムーズに実装できました。
ただ、js-buy-sdkに関してはあまりメンテナンスされていなさそうなので先行きが気になります。今後は直接Storefonrt API使ったほうがいいかもしれないですね。
もしくはBigCommerceのように、SDKではなく公式のカスタムフックを提供してくれたりするとよりいい感じになりそうです。
今回、レイアウトを整えるためだけにMaterial UIを使ってますが、せっかくコンポーネントごとにディレクトリ切ってCSS modulesも各コンポーネントごとに用意してるのでCSS書き足して見た目も整えていこうと思います。あとはまだSEOの仕組み(商品詳細ページはOGP画像に商品画像設定するとか)とか商品一覧のページネーションとか後回しにして実装できてないので、今後の勉強の課題として残しておきます。
一旦、今回ゴールとしたECサイトに必要な最低限の機能は実装しきることができたのでよかったです。Recoilとカスタムフックの使い方とか、とても勉強になりました。