🍇

Next.js App RouterでRSCからGraphQLを呼ぶ

2024/04/09に公開

モチベーション

Next.js App Routerでは, GraphQLは不要でありリソース指向のREST APIのほうが使いがってが良いという議論が多くあります。GraphQLがブラウザからBFFまでの通信をもともと担っており、BFFから後ろはREST APIであれば確かにGraphQLサーバーをわざわざ保守するのは手間であるため納得できる議論です。しかし、Next.JSはむしろバックエンドもまともに管理する気がなくHeadless CMSやBaaSなどを組み合わせて最速で動くものを作るときに最適なフレームワークだと思っており外部サービスがGraphQLサーバーを保守してくれているケースではむしろGraphQL Codegenによる型のサポートがつくことでOpenAPIを利用するよりも遥かに良い開発体験を獲得できると思っています。
このとき、Next.js App Routerの積極的なキャッシュ利用をどのように実現するのかは自明ではありません。Vercelのサンプル実装であるcommerceリポジトリでは、以下のようにfetchをカスタムしていますが、型の自動生成を利用するところまで行っておらず、わざわざGraphQLを利用する旨味が削がれています。

export async function shopifyFetch<T>({
  cache = 'force-cache',
  headers,
  query,
  tags,
  variables
}: {
  cache?: RequestCache;
  headers?: HeadersInit;
  query: string;
  tags?: string[];
  variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
  try {
    const result = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Storefront-Access-Token': key,
        ...headers
      },
      body: JSON.stringify({
        ...(query && { query }),
        ...(variables && { variables })
      }),
      cache,
      ...(tags && { next: { tags } })
    });

    const body = await result.json();

    if (body.errors) {
      throw body.errors[0];
    }

    return {
      status: result.status,
      body
    };
  } catch (e) {
    if (isShopifyError(e)) {
      throw {
        cause: e.cause?.toString() || 'unknown',
        status: e.status || 500,
        message: e.message,
        query
      };
    }

    throw {
      error: e,
      query
    };
  }
}

今回は、GraphQL Codegenを利用した型のサポートの利用とfetchを経由したData Cacheをどのように両立するかという点を考察していきます。
実際に動作するサンプルリポジトリは以下になります。
https://github.com/takumiyoshikawa/nextjs-graphql-example

データをどのように取得するか

そもそもなぜ簡単にいかないのか

GraphQLのリクエストを行うのは基本的にApollo, Relay, urqlなどのGraphQL clientが担ってきました。これらはfetchを経由してデータを取得することを前提としておらず、Next.jsがfetchをpatchするようにうまく呼び出しをすることが困難になります。実際、Vercel-Commerceリポジトリでもキャッシュを利用するために既存のGraphQL Clientを利用せずfetchを拡張してGraphQLをリクエストしていることは先述した通りです。今までClientAPI経由でデータを取得することが当たり前だったことが混乱を生む一つの原因になっていると思っています。

typescript-generic-sdkでfetchをラップする

Cacheの関係からfetchを使いたいが、GraphQL codegenのサポートを得たいという要件を叶えるために@graphql-codegen/typescript-generic-sdkを利用します。@graphql-codegen/typescript-generic-sdkは 各GraphqQL ClientのRequesterを実装する前の、素の状態のsdkでありfetchをラップしたCustom Requsterを実装することでデータのフェッチの仕方を自由に決めることができます。

lib/shopify/index.ts
import { print } from "graphql";
import { getSdk, Requester } from "./__generated__/graphql";

const endpoint =
  `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2024-04/graphql.json` as string;
const token = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN as string;

interface RequestOptions {
  headers?: Record<string, string>;
  revalidate: false | number;
  tags?: string[];
}

const customShopifyRequester: Requester<RequestOptions> = async (
  doc,
  variables,
  options?,
) => {
  const headers = {
    "Content-Type": "application/json",
    "X-Shopify-Storefront-Access-Token": token,
    ...options?.headers,
  };
  const revalidate = options?.revalidate ?? 0;
  const tags = options?.tags ?? [];
  try {
    const response = await fetch(endpoint, {
      method: "POST",
      headers,
      body: JSON.stringify({
        query: print(doc),
        variables,
      }),
      next: {
        revalidate,
        tags,
      },
    });

    if (!response.ok) {
      throw new Error(
        `GraphQL Error: ${response.status} ${response.statusText}`,
      );
    }
    const data = (await response.json()).data;
    return data;
  } catch (error) {
    console.error("Error in GraphQL request:", error);
  }
};

export const shopifyFetchSdk = getSdk(customShopifyRequester);

また、codegen init 経由で生成されたcodegen.tsを以下のように設定することで、上記のように定義したshopifyFetchSdkがクエリを参照できるようにします。

codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
import dotenv from "dotenv";

dotenv.config();

const endpoint =
  `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2024-04/graphql.json` as string;
const token = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN as string;

const config: CodegenConfig = {
  overwrite: true,
  schema: {
    [endpoint]: {
      headers: {
        "X-Shopify-Storefront-Access-Token": token,
      },
    },
  },
  documents: ["./lib/**/*.graphql"],
  ignoreNoDocuments: false,
  generates: {
    "./lib/shopify/__generated__/graphql.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-generic-sdk",
      ],
    },
  },
};

export default config;

あとは実際にgraphql queryを記載したらpnpm run codegenを実行することでshopifyFetchSdkに記述したqueryのメソッドが追加され呼び出し可能になります。

lib/shopify/product/query.graphql
query getProduct($handle: String!) {
  product(handle: $handle) {
    id
    title
    description
  }
}
lib/shopify/product/index.ts
import { cache } from "react";
import { shopifyFetchSdk } from "..";

const getProduct = async (handle: string) => {
  const res = await shopifyFetchSdk.getProduct(
    { handle },
    { revalidate: 86400, tags: ["product"] },
  );

  return res;
};

export const cachedGetProduct = cache(getProduct);
app/page.tsx
import { cachedGetProduct } from "@/lib/shopify/product";

export default async function Home() {
  const product = await cachedGetProduct("gift-card");
  return (
    <div>
      <p>title: {product.product?.title}</p>
      <p>description: {product.product?.description}</p>
    </div>
  );
}

実現できたこと

typescript-generic-sdkを利用することで、graphql-codegenを利用した型付けが行えるようになりフロントエンドの実装時の体験が格段に上がりました。また、.next/cacheを確認するとたしかにfetch cacheが生成されることも確認できます。余談ではありますが、react/cacheで囲うことで通常はPOSTリクエストでは実行されないRequest Memoizationも実現できているはずです。

Next.js App Routerにおいて、RSCからGraphQLエンドポイントにデータフェッチを行う際に、graphql-codegenによる型のサポート、Data Cache、Request Memoizationをすべて実現するために実装したことをまとめました。フォローもよければお願いします。@takuumi7

Discussion