Next.jsでShopifyのストアフロントをつくってみた
表題の通りですが、Next.jsでShopifyのストアフロントをつくってみました。
もともと業務でShopifyを使っていて、技術的にSSRやSSG・ISRなどの様々なレンダリング方法に対応したNext.jsに興味があったので、その二つを合わせるような形でストアを構築してみました。
デザインはShopifyのデフォルトテーマであったDebutテーマ(いまはDawnになりましたね)を参考にしています。
(gif貼ろうとしたけど重いのか失敗...)
使用技術など
ストア: https://nextjs-shopify-store.vercel.app
リポジトリ: https://github.com/naoya-kuma1990/nextjs-shopify-store
使用技術: React, Next.js, TypeScript, Tailwind CSS, Material UI, Shopify JavaScript Buy SDK, Store Front API(Graph QL)
実装ページ: コレクション(商品一覧)、商品詳細、カート、検索結果
Next.jsとStore Front APIのRate Limit
冒頭に書きましたが、Next.jsといえば、SSRやISRにも対応しているReactのフレームワークであるという点が特徴ですよね。今回使ってみたのも、「ECサイトといえばSEOに対応するのが当然で、Reactでそれを実現したいなら、Next.js」と思い、使ってみました。しかし、結果的には、商品情報の取得など主要な情報の取得部分を全てクライアント側で実行しているので、今回作ったサイトはSEO的には不合格です笑
というのも、ShopifyのAPIを叩く上で「API Rate Limits」というコスト制限があり、APIの種類によってアクセス制限がかけられています。詳しくは公式を読んでいただきたいですが、基本的にはストア(厳密にいえばアクセスポイントとなるプライベートアプリ)ごとに持ち点数が決められていて、一定時間ごとに回復するその持ち点を消費しきると、持ち点が回復するまでリクエストがエラーとなってしまうというものです。
そのため、REST製とGraphQL製の二つのAdmin APIを不特定多数のユーザーのアクセスに応じて毎回叩くとすぐにコスト超過となってしまうため、ストアフロント用にStore Front APIが設けられています。こちらは、Admin APIとは違い、その制限の単位がIP単位となっています。そのため、一定量の情報であれば、クライアント側から叩く限り、Store Front APIではコスト超過は発生しません。
以上の理由により、ShopifyのAPIを使ってストアフロントを構築するためには、まず、SSRは適しません。SSRであれば、ユーザーのアクセスごとにAPIを叩くことになってしまい、どのAPIを使ってもすぐにコスト超過してしまうからです。
選択肢としては、ISRで一定時間ごとにAPIを叩いて静的なページとしてレンダリングするか、CSRで都度Store Front APIを叩くか、のどちらかだと思いますが、今回は勉強用に構築するためのストアということで、現実的にどの部分をISRにすべきかなどの判断もつかなかったので、ストア情報の取得はCSR一択にしました(ISRにしたければ情報取得をgetStaticPropsの中で行って、revalidate
で再生成の間隔を設定するらしい)
ページコンポーネント内のuseEffect内で情報取得
カスタムクライアントを使ってStore Front APIにクエリ
JavaScript SDKとカスタムクライアント
Store Front APIを叩くためのSDKとしてJavaScript Buy SDKというものが用意されています。基本的にはこちらのSDK経由で商品情報やカートの操作を行うことができるので全面的にこちらを使おうと思ったのですが、色々と問題があって、SDKは商品の追加・削除などカートの操作でのみ使用し、商品ページでの商品情報の取得や検索機能ではGraphQLのクライアントライブラリ(graphql-request
)を使って直接Store Front APIを叩いています。というのも、まずJS Buy SDKはTypeScriptの型定義が古いものとなっているらしく、実際に取得できるプロパティと型情報が一致せず、部分的に型定義を自分で拡張する必要があります。この型定義の拡張も初めてのことだったので勉強にはなりましたが、どのプロパティが型定義されていてどれがされていないのかいちいち判別するのが面倒でした。また、SDKでサポートされているプロパティは基本的なものは公式に記載されている通りStore Front APIで取得できる情報の一部でしかなく、拡張して使おうとすると、もはや何のためにSDKを使っているのかわからないくらい面倒くさそうでした(例えば、コレクションページの商品のソートには対応していません。)(ソートをSDKで実現するとこんな感じ)
そのため、SDKだけで事足りるカートの操作にだけ、ライブラリの型定義を拡張しながらSDKを使い、それ以外の部分ではカスタムクライアントを定義して直接Store Front APIを叩くことにしました。
JS Buy SDK
カスタムクライアント
SDKの型定義の拡張
カスタムフック
今回一番勉強になったのはカスタムフックの定義の仕方です。
調べていく中で、概念として「クラス・インスタンス」と同じような感じで一部処理を関数として切り出し、再利用・テスト可能なものにするもの、というのはわかりましたが、戻り値をどういった形式にすればいいのか、そもそも関数コンポーネントではないのになぜuseState
などの公式フックがその中で使えるのかがわかりませんでした。
しかし、結局、カスタムフックは公式フックも含めて、ただただ一部の処理を切り出しているだけなんですね。例えば、カスタムフックの中でuseState
使っていて、カスタムフックをとあるコンポーネントの中で呼び出せば、そのstateの所属はカスタムフックを呼び出したコンポーネントになる、といった具合です(開発ツール上では、カスタムフックを呼んだコンポーネントの中でさらにカスタムフックとしてまとめられた中にstateが存在する)。また、私の場合で言えば下記のように、カスタムフックを呼び出した結果として取得したい値と関連の値、さらに、その値を操作する関数をそれぞれオブジェクトにまとめて、useStateの戻り値のように[主な値と
loadingなど関連の値をプロパティに持ったオブジェクト, 値を操作する関数をプロパティに持ったオブジェクト]
といった形で戻り値を返せば、非常にわかりやすい形でカスタムフックの戻り値を返し、利用することができます。
(~Stateという変数名がちょっと微妙?)
export type CartState = {
value: Cart;
loading: boolean;
};
export type Checkout = {
addItem: (variantId: string, quantity: number) => Promise<void>;
updateQuantity: (lineItemId: string, quantity: number) => Promise<void>;
removeItem: (lineItemId: string) => Promise<void>;
buyNow: (variantId: string, quantity: number) => Promise<void>;
};
const useCart = (): [CartState, Checkout] => {
...
return [cartState, checkout];
}
カスタムフック(useCart)
useCart内のstateなどがCartとしてまとめられている
Tailwind CSSとMaterial UI
今回、ShopifyのデフォルトテーマであるDebut(いまはもうDawnがデフォルトテーマですね)を再現するにあたり、基本的にはTailwind CSSを使い、ドロワーやスケルトン(初期描画の際に表示するダミー)などアニメーション部分に関してはMaterial UIのコンポーネントを使っています。Tailwind CSSは個人的にLiquidでテーマを書いた時にも使って、CSSがグローバルである問題を一挙に解決することに非常に感動しました。ReactなどのSPAフレームワークにおいては、CSSのスコープ問題はなくなりますが、それでもTailwindを使うことでスタイリングがHTMLと一体化し、記述量が減ることは、大変使い勝手よく感じられました。
また、TailwindをMaterial UIと併用しましたが、Tailwindはクラス名、Material UIはコンポーネントということで特に競合も発生しませんでした。別のCSSフレームワークを一緒に使うことってあまり推奨されていない気がするので、実際のプロジェクトなどで個人的に推したりすることはなさそうですが、この辺どうなんだろう。
HydrogenとOxygen
「Hydrogen」には、カート、バリエーションピッカー、メディアギャラリーなどのコマース特化型のコンポーネントが含まれ、カスタムストアフロント構築の複雑な部分を解消します。
同時に「Oxygen」も発表されました。これは、「Hydrogen」をShopifyで高速かつグローバルに直接ホストするため方法で、eコマース用に最適化されています。
HydrogenとOxygenが登場
他参考:Hydrogen
今回は自分でゴリゴリ書いたようなコンポーネントやHooksを提供するHydrogenというReactフレームワークと、HydrogenをホスティングするOxygenというサーバーがリリースされるようです。
フロントエンドの開発の潮流が完全にSPAフレームワークに移っている状況で、Liquidを使ってテーマを開発するのはそれなりに苦労がありましたが、Shopifyもその流れに乗るということで、UX・開発スピード・開発体験の向上が期待できますね。
(Oxygenを使うことで、テーマのLiquidオブジェクトのような形で、Store Front API経由で取得できる情報にアクセスしやすくなるのかな?(憶測))
(SEOとかコストとかどうなるのかな)
そのほか瑣末なこと
-
コレクションページでページ総数を表示することができない
総数に関するプロパティがあればいいのですが、コレクション内の商品総数を取得することがStore Front APIではできません。なので、コレクション内の商品を一定数(最大250)取得した後、次のページがあるかどうかはpageInfo.haxNextPage
から判断できますが、「1 / 12」のようにページ総数を表示することができません。思いつく解決策は、コレクションページでは ISRで一定時間ごとにコレクションの情報を取得するようにして、コレクション情報をStore Front APIかGraphQL Admin APIから取得し、collection id
をもとに、商品総数をREST Admin API(products_count
というプロパティがある)から取得する、といった感じでしょうか。
(今回は無限スクロールにしています) -
ストアに表示されている商品
Oberloというアプリを使っています。 -
チェックアウト(「レジに進む」以降)はテーマと同じ、Shopifyで提供されている決済ページに遷移させている
https://github.com/momonoki1990/nextjs-shopify-store/blob/main/lib/useCart.ts#L110 -
コレクション内での商品のソート
https://github.com/momonoki1990/nextjs-shopify-store/blob/main/lib/graphql/collection/getCollectionWithProducts.ts#L87
※クエリの引数はQueryRootの「products」の「arguments」に記載 -
商品タイトルのキーワード検索
https://github.com/momonoki1990/nextjs-shopify-store/blob/a91df30dd6fe4f6e6f057294dbbbb71876602ec9/lib/graphql/product/getProductsByTitle.ts#L52-L90
部分一致のquery
Shopify GraphQL partial matching on query filter
https://stackoverflow.com/questions/51742384/shopify-graphql-partial-matching-on-query-filter
参考
ShopifyのStorefront APIを使用してNext.js製のECフロントを構築した
ReactのカスタムHooksをカジュアルに使ってコードの見通しを良くしよう
[React] カスタムフックを作るときにこころがけていること
Shopify Storefront API
Store Front APIのQueryRoot
模写」とかの著作権についてちょっと調べてみた
Discussion