mizdra's blog

ぽよぐらみんぐ

Next.js の getServerSideProps を共通化する

Next.js で開発していると、複数のページの getServerSidePropsで同じようなコードを度々書くことになると思う。例えば 「GraphQL クライアントでクエリを fetch して、そのレスポンスをpagePropsに焼き込むロジック」であったり、「(マルチテナントアプリケーションにおいて) リクエストヘッダーからテナントを特定するロジック」であったり。こうした大部分のページで使われるロジックのコードは、何度も書かずに済むよう、何らかの共通化をしたくなる。

すでに色々な人が似たような動機でgetServerSidePropsを共通化する方法を紹介していると思う。それを参考にしながら id:mizdra も自分流の共通化方法を実践している。何度か Next.js アプリケーションを作っているうちに、自分の型のようなものが身についてきたので、それを紹介してみる。

はじめに結論

こう書いてます。

// lib/get-server-side-props.ts
import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
import { GetServerSideProps, GetServerSidePropsContext } from 'next';

type TenantName = 'Tenant-A' | 'Tenant-B' | 'Tenant-C';

// getServerSideProps の共通化とは関係ないので実装は省略し、型だけ定義しておく
declare function getTenantNameFromHostHeader(context: GetServerSidePropsContext): TenantName | undefined;
export declare function createApolloClient(
  cache: NormalizedCacheObject | undefined,
  tenantName: TenantName,
  context?: GetServerSidePropsContext,
): ApolloClient<NormalizedCacheObject>;

/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` に渡される context の型 */
type WithTenantOuterContext = GetServerSidePropsContext;
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` に渡される context の型 */
type WithTenantInnerContext = WithTenantOuterContext & { tenantName: TenantName };
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` から返すべき pageProps の型 */
type WithTenantInnerPageProps = {};
/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` から返すべき pageProps の型 */
export type WithTenantOuterPageProps = WithTenantInnerPageProps & { tenantName: TenantName };

/**
 * Host ヘッダーからテナント名を取得し、innerGSSP にテナント名を渡す HoF。
 * また、テナント名をコンポーネントから参照できるよう、pageProps に焼き込む。
 */
function withTenantGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithTenantInnerContext) => ReturnType<GetServerSideProps<P & WithTenantInnerPageProps>>,
): (context: WithTenantOuterContext) => ReturnType<GetServerSideProps<P & WithTenantOuterPageProps>> {
  return async (context) => {
    const tenantName = getTenantNameFromHostHeader(context);
    if (tenantName === undefined) throw new Error('Host ヘッダーからテナントの特定に失敗しました。');
    const innerResult = await innerGSSP({ ...context, tenantName });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        tenantName,
      },
    };
  };
}

type WithApolloOuterContext = GetServerSidePropsContext & { tenantName: TenantName };
type WithApolloInnerContext = WithApolloOuterContext & { apolloClient: ApolloClient<NormalizedCacheObject> };
type WithApolloInnerPageProps = {};
export type WithApolloOuterPageProps = WithApolloInnerPageProps & { initialApolloState: NormalizedCacheObject };

/**
 * Apollo クライアントを innerGSSP に渡しつつ、Apollo クライアントのキャッシュを pageProps に焼き込む HoF。
 * 焼きこまれたキャッシュは、_app.tsx で Apollo クライアントを初期化する際に使われる。
 */
function withApolloGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithApolloInnerContext) => ReturnType<GetServerSideProps<P & WithApolloInnerPageProps>>,
): (context: WithApolloOuterContext) => ReturnType<GetServerSideProps<P & WithApolloOuterPageProps>> {
  return async (context) => {
    const apolloClient = createApolloClient(undefined, context.tenantName, context);
    const innerResult = await innerGSSP({ ...context, apolloClient });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        initialApolloState: apolloClient.cache.extract(),
      },
    };
  };
}

export type WithGlobalOuterContext = GetServerSidePropsContext;
export type WithGlobalInnerContext = WithTenantInnerContext & WithApolloInnerContext;
export type WithGlobalInnerPageProps = WithTenantInnerPageProps & WithApolloInnerPageProps;
export type WithGlobalOuterPageProps = WithTenantOuterPageProps & WithApolloOuterPageProps;
/** よく使われる HoF を合成した HoF */
export function withGlobalGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithGlobalInnerContext) => ReturnType<GetServerSideProps<P & WithGlobalInnerPageProps>>,
): (context: WithGlobalOuterContext) => ReturnType<GetServerSideProps<P & WithGlobalOuterPageProps>> {
  return withTenantGSSP(withApolloGSSP(innerGSSP));
}
// pages/_app.tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import { createApolloClient, WithTenantOuterPageProps, WithApolloOuterPageProps } from '../lib/get-server-side-props';

// withGlobalGSSP などで囲われていれば、OuterPageProps 相当のプロパティが pageProps に入っているはず。
// ただしページによっては withGlobalGSSP などで囲ってなかったりするので、 Partial<> で optional にする
type CustomAppProps = AppProps<Partial<WithTenantOuterPageProps & WithApolloOuterPageProps>>;

export default function App({ Component, pageProps }: CustomAppProps) {
  if (!pageProps.tenantName) throw new Error('pageProps.tenantName is required.');
  const apolloClient = createApolloClient(pageProps.initialApolloState, pageProps.tenantName);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { withGlobalGSSP } from '../lib/get-server-side-props';
import { gql, useQuery } from '@apollo/client';

const QUERY = gql`
  query TopPage {
    hello
  }
`;

export default function Home() {
  const { loading, error, data } = useQuery(QUERY);
  return (
    <main>
      <p>top page.</p>
      <div>
        {loading && <p>loading...</p>}
        {error && <p>error: {error.message}</p>}
        {data && <p>data: {JSON.stringify(data)}</p>}
      </div>
    </main>
  );
}

export const getServerSideProps = withGlobalGSSP(async (context) => {
  await context.apolloClient.query({ query: QUERY });
  console.log(context.tenantName);
  return { props: {} };
}) satisfies GetServerSideProps;

以下のリポジトリにコード全文があります。

github.com

ポイントをかいつまんで紹介

  • それぞれのページの固有の getServerSideProps (GSSP) のロジックが実行される前後で共通のロジックを呼び出したいことがあるので、High-order Function (HoF) として共通化してます
    • 簡単に言うと関数を引数にとって、関数を返す関数のこと
    • (React Hooks 登場以前の) Redux 触ったことある人なら馴染みあると思う
  • innerGSSP がページの固有の GSSP で、それを HoF に渡すと、「共通ロジック前半部分の実行」=>「innerGSSP の実行」=>「共通ロジック後半部分の実行」みたいな順で実行されていく
    • 「共通ロジック後半部分」で得たデータを innerGSSP に渡したり、innerGSSP の返り値を「共通ロジック後半部分」で拡張してから返したりできる
  • よく使う HoF を合成した withGlobalGSSP という HoF を用意してる
    • export const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps; と書いて使う
  • Inner と Outer の型をちゃんと表現してるので、 HoF の適用順序を間違えたら型エラーが出るようになってる
    • HoF の適用順序が正しい時 (右) と間違えた時 (左) の比較
    • withApolloGSSPtenantName を要求するので、先に withTenantGSSP を呼ばないといけない

感想

  • withApolloGSSPwithGlobalGSSP などを使うページコンポーネント側はすごくスッキリする
    • けど HoF 実装側は大変なことになってる!!!
    • 何とかならないものか
  • 厳密に型付けするのを諦めて、もう少しナイーブにやったらメンテナンスしやすい形にできないかな
  • 皆さんはどうしてますか?

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.