記事一覧

Real World GraphQL on Next.js SSR

tl;dr

  • Next.jsはv9.3.0以降Initial Loadingの扱いが変わった
  • クライアント側ではApolloを利用することができるが、Authorization Headerを設定するなら一工夫必要
  • SSR時にはfetchによるシンプルなAPIリクエストをすると良い

昨今のWebフロントエンド

昨今のWeb開発において、React、TypeScriptとかのベース知識は当然として、やはりNext.js(あるいはNuxt.js)のような、SPA/SSR両方のニーズを汲み取りながら、dynamic routingを提供してくれたり、ビルド環境を高速に整備してくれるフレームワークが重宝されるようになってきていると感じます。

また、Reduxもアリですが、スキーマ駆動開発が推進できるGraphQL、特に尋常じゃなくステート管理が用意になるHooksとApolloクライアントの組み合わせは、フロントエンド開発がしばらく浦島太郎状態だった自分に、かなり衝撃的なDeveloper Experienceをもたらしてくれています。

The State of JavaScriptをみても、これらの技術がメインストリームに登っていることはわかるかと思います。

昨今のNext.js

で、自分はReact + TypeScriptが好きでNext.jsを選び、あまりに体験がよかったGraphQLを採用して、開発スピードが上がったように思うのですが、如何せんノウハウがあまりに少ない。

特にNext.jsはv9.3.0以降、それまでレンダリング前のData fetchに利用されていた getInitialProps という関数の代わりに getStaticProps(getStaticPaths) または getServerSideProps という関数を利用するよう推奨されていて、それベースのノウハウはほぼ皆無です。

これまで getInitialProps は、initial load時にはサーバー側で、 next/link を用いたクライアントサイドのルーティングではクライアント側で呼ばれていました。

これが getStaticProps または getServerSideProps になると、挙動が変わります。APIリクエストの結果を含めた静的ファイルをビルド時に生成するSSG用の getStaticProps 、またはリクエストごとにサーバー側のSSR時に呼び出される getServerSideProps という位置付けで、要はビルド時またはリクエストごとにサーバー側で呼ばれます。

クライアントとサーバー両方から呼ばれることを前提としたコードを書かなくて済むのですが、この知見があまりになく、公式のexamplesに含まれるコードも、あくまで getInitialProps が前提のものが多いです。完全にdeprecatedなコードというわけではないので、仕方ない(し、素晴らしい改善が高速で行われているため、非難をする気は全く起きない)です。

Next.jsでGraphQLをどう使うか

さて、では実際問題v9.3.0以降のNext.jsでどのようにGraphQLクライアントを利用するべきかという問いに、自分なりの答えを出すなら、

  • クライアント側ではApollo ClientをHooks前提で利用する
  • サーバー側ではプレーンなfetch APIを利用してAPIをコールする

です。

クライアントサイドのGraphQL

まずはクライアントサイドから見ていきましょう。ここではApolloをReact Hooksと組み合わせつつ使います。

ApolloやReact Hooksの詳しい解説は他にお任せするとして、ここではNext.jsのクライアントサイドにおける、GraphQLの利用方法に絞って解説します。

ApolloにおけるHooksというのはこちらのドキュメントを読んでいただければ良いのですが、ApolloProvider コンポーネントでルートコンポーネントを包み、

class CustomApp extends App {
  render() {
    const { Component, pageProps } = this.props

    return (
      <ApolloProvider client={/* いずこかで生成したApolloClientインスタンスを入れる */}>
        <Component {...pageProps} />
      </ApolloProvider>
    )
  }
}

useQuery, useLazyQuery (任意のタイミングでのQueryの遅延実行), useMutation などのクエリを子コンポーネントで呼び出すことで、開発者はApollo ClientのDI的な作業を全く意識することがなく、GraphQLのAPIを呼び出すことができます。

それらのメソッドを呼び出すと、概ね3種類の値、レスポンス内容の data、ローディング状態の loading、エラー内容の error といった変数が返ってくるので、それらをコンポーネント内に渡してあげればレンダリングができます。

また、単純にReact Hooksの記法はそれ自体が状態管理を楽にしてくれるものなのですが、特に apollo-hooks の各種メソッドに渡している引数を柔軟に変えなければならないケース、例えば検索やページングなどで、強力なDX向上をもたらしてくれます。

例えば、ブログ一覧を検索・ページングしながら呼び出すとしたらこんな感じでしょうか。他の書き方もあると思いますが、その場合はTwitterなどでオススメの設計を教えてください。

const blogListPage = (props: IProps) => {
  const [activeIndex, setActiveIndex] = useState<number>(0)
  const defaultLimit: number = 10
  const [blogSearchInput, setBlogSearchInput] = useState<SearchInput>({
    query: '',
  })
  const initialBlogPageInput: PageInput = {
    first: defaultLimit,
    last: null,
    before: null,
    after: null,
  }
  const [blogPageInput, setBlogPageInput] = useState<PageInput>(initialBlogPageInput)
  const { data, loading, error } = useBlogListPageQuery({
    variables: {
      listBlogInput: {
        page: blogPageInput,
        search: blogSearchInput,
      },
    },
  })

  // 以下renderやエラーハンドリング、クリックイベントのハンドラなどの関数が続く

少し話がそれますが、ここで useBlogListPageQuery というのを呼び出しています。例のために useQuery を直で呼び出していませんが、こういった特定のコンポーネント内部で呼び出すQueryのHooksのラッパーを自動生成することもできます。

そのようなクライアントサイドのGraphQL開発を楽にしてくれるジェネレーターとして、自分は

などを使ってます。これらを使わない開発は正直想像できません。

話を戻しますが、選択肢やフォーム入力に応じて検索・ページング内容を切り替えたいなら、 blogSearchInputblogPageInput の値を更新しさえすれば、それを参照している useQuery が自動的にリクエストを投げ、 data の中身の更新、さらにはそれを参照しているコンポーネントの再レンダリングが行われます。

ページ遷移のためのクリックイベントに応じてstateのsetterを呼び出しさえすれば、このような複雑な表示切りかえも柔軟に可能というわけです。jQueryをディスるつもりはないですが、正直以前の開発体験とは比較にならないほど速く、容易です。

クライアントサイドでの認証について

さて、ここまでは平和なのですが、問題なのが認証です。FirebaseやAuth0などを用いてAPI側で認証をかける場合、例えばAuthorization Headerを使う場合を想定しましょう。

Apolloの場合、クライアントインスタンス生成時に link というプロパティに、ヘッダー情報を入れてあげれば、認証は可能です。以下のコードの真ん中あたりです。

const httpLink = new HttpLink({
  uri: process.env.GRAPHQL_URL,
  credentials: 'same-origin',
  fetch,
})

const authLink = setContext((_, { headers }) => {
  // return the headers to the context so httpLink can read them
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : "",
    }
  }
})

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache()
});

しかしここで問題になるのが、Authorization Tokenを取得できるタイミングです。

自分はAuth0を使っているのですが、アクセストークンはログイン後にセッション情報取得メソッドを呼び出すことで手に入ります。

仮にSSRのプロダクトだとして、Auth0認証ページからリダイレクトしてきた直後、どのようにしてトークンを取得してからApolloClientを初期化するのでしょうか。

結論からいうと、現状は「AppのgetInitialProps でtokenを引数としたApollo Clientの初期化を行う」以外の解を見出せませんでした。

const createApolloClient = (token: string) => {
  const httpLink = new HttpLink({
    uri: process.env.GRAPHQL_URL,
    credentials: 'same-origin',
    fetch,
  })

  const authLink = setContext((_, { headers }) => {
    // return the headers to the context so httpLink can read them
    return {
      headers: {
        ...headers,
        authorization: token ? `Bearer ${token}` : "",
      }
    }
  })

  const client = new ApolloClient({
    link: authLink.concat(httpLink),
    cache: new InMemoryCache()
  });

  return client
}

class CustomApp extends App<AppInitialProps & { token: string }>  {
  static async getInitialProps({ Component, ctx }) {
    const pageProps = Component.getInitialProps
      ? await Component.getInitialProps(ctx)
      : {};

    const session = await auth0.getSession(ctx.req)

    return { pageProps, token: session?.idToken };
  }

  render() {
    const client = createApolloClient(this.props.token)
    const { Component, pageProps } = this.props

    return (
      <ApolloProvider client={client}>
        <Component {...pageProps} />
      </ApolloProvider>
    )
  }
}

export default CustomApp

このコードだと毎度Auth0とのAPI通信が走るので、レンダリングのコストを考えれば悪手ですし、そもそも冒頭で述べたような理由から、 getInitialProps を呼びたくないのです。

ただ、苦肉の策として、こうする以外に認証前提のSSRのNext.jsプロダクトでのApollo Clientの初期化方法を思いつきません。というかどこにも載ってない。本当に辛かった。夢の中で403エラーめっちゃ返ってきた。

しかも大体同じようなコードを、localStorageや初回呼び出しタイミングでtoken取得ができる前提で書かれている事例が多く、現実的に対応できない例が多いのです。

ちなみにtokenの保存場所ですが、一度SDKから取得したtokenをlocalStorageに一時保存するでもいいでしょう。ただ、それだとAuth0側で管理する厳密なセッション切れに対応できなくなるのと、Next.jsのAPI Routeでログアウトハンドリングをする場合に、localStorageが初期化できなくて詰みます。

同じようにAuth0をNext.jsで使う方は、nextjs-auth0ライブラリを使うかもしれませんが、上記の問題はこちらのIssueにて言及されています。

※以下追記

Twitterで数名の方からご指摘いただき修正したので、別の実装方法を書いておきます。

クライアント側で無理にトークンを設定しなくても、SSRサーバー側や、BFFのゲートウェイサーバーでトークンを挿入してあげれば、getInitialPropsの不器用な処理を書く必要がなくなります。

BFFを作らなくても、API Routeの機能を使ってできるなら簡単だと思いまして、今回はその方針を取りました。

例として、API Routeの機能で作成した、/api/graph というパスをプロキシしてGraphQL APIにリクエストしてみましょう。

Auth0をサンプルに使っています。セッション情報からidTokenを取得してヘッダーに追加していること以外は、APIのレスポンスをそのままコピーして、クライアントに横流ししています。

以下は、こちらのgistを参考にしています。

import auth0 from '../../lib/auth0';

const callAPI = async (path, body, headers) => {
  const res = await fetch(process.env.GRAPHQL_URL, {
    method: 'post',
    headers: {
      'content-type': 'application/json',
      ...headers,
    },
    body: JSON.stringify(body),
  });
  return {
    body: await res.text(),
    status: res.status,
    headers: res.headers,
  };
};

const forwardHeader = (res, apiRes, header) => {
  if (apiRes.headers.get(header)) {
    res.setHeader(header, apiRes.headers.get(header));
  }
};

const forwardResponse = (res, apiRes) => {
  forwardHeader(res, apiRes, 'content-type');
  forwardHeader(res, apiRes, 'www-authenticate');
  res.status(apiRes.status);
  res.send(apiRes.body);
};

export default async function graph(req, res) {
  try {
    const { idToken } = await auth0.getSession(req)
    const apiRes = await callAPI('graphql', req.body, {
      authorization: idToken ? `Bearer ${idToken}` : ""
    });
    forwardResponse(res, apiRes);
  } catch (error) {
    console.error(error);
    res.status(error.status || 400).end(error.message);
  }
}

上記のプロキシを経由すると、クライアント側でのApollo Clientの初期化プロセスは以下のようにシンプルになります。

もちろん、BFFのゲートウェイやSSRサーバーの負荷と、クライアント側のコード品質とのトレードオフになりますが、こちらの方がレンダリング時にセッション取得によって処理がブロックされることもないので良いと思います。

class CustomApp extends App {
  render() {
    const client = new ApolloClient({
      link: createHttpLink({
        uri: "/api/graph",
        credentials: 'same-origin',
        fetch,
      }),
      cache: new InMemoryCache()
    })
    const { Component, pageProps } = this.props

    return (
      <ApolloProvider client={client}>
        <ThemeProvider theme={defaultTheme}>
          <Fragment>
            <BaseStyles />
            <Component {...pageProps} />
            <ToastContainer />
            <NextNProgress />
          </Fragment>
        </ThemeProvider>
      </ApolloProvider>
    )
  }
}

サーバーサイドのGraphQL

クライアント側の開発体験は、基本的にはApolloを使うことで劇的に向上します。が、サーバーサイドではそうもいきません。

前述の通りプレーンなfetch API呼び出しで解決しなければならないのですが、それは何故でしょうか。

公式のexamplesのwith-apolloでも、SSR時にApolloを使えているじゃないか!と思いますよね。

これは大嘘で、現状Next.jsのSSR時にApolloを使うと馬鹿みたいにレンダリングのパフォーマンスが落ちます。

公式のこちらのDiscussionをご覧ください。

おおまかな理解で恐縮なのですが、解説すると、ApolloはSSR時に getDataFromTree という関数でもって、コンポーネントツリーのどのノードでクエリ結果が参照されているかをManagerクラス経由で監視し、結果が全部出揃うまでPromise.allで待機する、というのが遅くなる原因です。

なので、こちらのコメントにもある通り、マジにシンプルなAPI呼び出しをfetch APIで実装した方がいいです。SSRでのApollo使用をやめた途端に爆速になったので思わず笑いました。

簡単な例で、ユーザーの基本情報だけMutationで登録する場合を想定すると、以下のようなAPI呼び出しのメソッドを定義しておいて、

interface GQLQueryResponse {
  data: any
}

async function callGraphAPI(ctx: NextPageContext, query: string, { variables }: { variables: any } = {
  variables: {}
}) {
  const token = getCookie(ctx.req, TOKEN_COOKIE_NAME)
  const res = await fetch(process.env.GRAPHQL_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      query,
      variables,
    }),
  })

  const json = await res.json()
  if (json.errors) {
    throw new Error(json.errors.toString())
  }
  return json
}

export const createUser = async (ctx: NextPageContext, input: CreateUser): Promise<GQLQueryResponse> => {
  return await callGraphAPI(ctx, `
    mutation createUser($input: CreateUser!) {
  createUser(input: $input) {
    id
    email
    enabled
    createdAt
    updatedAt
  }
}`, {
    variables: {
      input
    }
  })
}

ログイン後のコールバックページでこんな感じの呼び出し方をする、とかでしょうか。(結構コードを省略してます)

const callback = () => {
  return <Loading />
}

export async function getServerSideProps(ctx: NextPageContext) {
 const session = await auth0.getSession(ctx.req)

  try {
    await createUser(ctx, {
      id: session.user.sub,
      email: session.user.email,
    })
  } catch (error) {
    console.error(error)
    res.writeHead(500).end(error.message);
  }

  return {
    props: {}
  }
}

export default callback

以上のように、SSRサーバー側では、Apollo Clientをexamplesディレクトリの例をそのままに利用しようとしても、パフォーマンスが低下するため、fetch APIやそれに準ずるシンプルなAPIクライアントを実装するべきです。

また、HOCでNextPageContextにApollo Clientを入れる必要もなくなるので、クライアント側で利用するApollo Clientは、普通に素のApolloProvider経由で子コンポーネントに渡すことができます。これは良い点ですね。

まとめ

Next.js、GraphQL、Apolloと、全てがこれまでの開発者のストレスを解消してくれる素晴らしいツール群です。

しかし、まだまだ英語圏ですらノウハウが不足しており、公式の例を参考にしても、絶対に現実のプロダクトでは綺麗にいかなかったり、パフォーマンスが下がるケースがあります。

たとえ理想とは遠い愚直な方法でも、気合いで実装する他ないのですが、その努力は実際にプロダクションでツールを使ってる人間が、理想とのギャップ溢れるノウハウを公開していけば良い話です。

「個人プロダクトならいいけどね」なんてキャズムは既に超えたはずのツールなので、よりスムーズに開発ができるように、自分も力添えできればと思い、この記事を書きました。

誰かの助けになれば幸いです。