mizdra's blog

ぽよぐらみんぐ

Next.js で SSR を強制する

SSG も ISR も SSR も、どれも便利なのだけど、どれも扱いが難しい! SSR 1つを取っても、first paint の状態で GraphQL API から取得したデータがレンダリングされた状態を目指すなら、それなりに複雑な仕組みを getServerSideProps/_app.ts に実装しないといけない。

素朴にやると getServerSideProps には、GraphQL クライアントを初期化して、ページコンポーネントのレンダリングに必要なクエリを fetch して、それを pageProps に焼き込むためのコードがページごとに出現する訳で...

// 引用元: https://github.com/vercel/next.js/blob/e969d226999bb0fcb52ecc203b359f3715ff69bf/examples/with-apollo/pages/ssr.js#L20-L31
export async function getServerSideProps() {
  const apolloClient = initializeApollo()

  await apolloClient.query({
    query: ALL_POSTS_QUERY,
    variables: allPostsQueryVars,
  })

  return addApolloState(apolloClient, {
    props: {},
  })
}

流石に冗長なので、 Next.js の getServerSideProps を共通化する - mizdra's blog のようにラッパー (withApolloGSSP) を用意して、ページごとのコードの重複を減らしつつ、ページからは SSR 由来の複雑なロジックを意識せずに済むようにする、という方向に力が働いていくと思う。

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

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

type WithApolloOuterContext = GetServerSidePropsContext;
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(),
      },
    };
  };
}
// pages/_app.tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import { createApolloClient, WithApolloOuterPageProps } from '../lib/get-server-side-props';

type CustomAppProps = AppProps<Partial<WithApolloOuterPageProps>>;

export default function App({ Component, pageProps }: CustomAppProps) {
  const apolloClient = createApolloClient(pageProps.initialApolloState);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { withApolloGSSP } 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>
  );
} 

// ページ側では `withApolloGSSP` で囲んで、クエリを fetch するだけで OK!
export const getServerSideProps = withApolloGSSP(async (context) => {
  await context.apolloClient.query({ query: QUERY });
  return { props: {} };
}) satisfies GetServerSideProps;

しかし、更にそこに SSG を導入しようとすると、今後は getStaticProps でも上記のような仕組みを再実装する必要がある! withApolloGSSPgetServerSideProps 専用なので、getStaticProps 用のものを別で用意しないといけない。また何種類か違うバリエーションのページ (例えば多言語対応をしたいとか) を作りたかったら、slug *1 (pages/[lang]/...) を導入して、バリエーションの違うページごとに URL が分かれる構成にしないといけない (SSR では同じ URL で異なるバリエーションのページを配信できるのに!)。

SSG は SSR の静的版、くらいの捉え方がよくされているのだけど、実際に両者を扱ってみると、想像以上に使い勝手が異なる。SSG を SSR と同じ気持ちで使っていると、「あれ? 何だか期待通りに動かないな」ということが頻発する。

SSR に一本化するという選択肢

...ということもあって、id:mizdra は SSG を避けて、極力 SSR を使うようにしている。メンタルモデルが違うものを同時に扱うのは難しいので、どっちかに一本化しようという考えがベース。サーバー負荷が気になるようであれば、CDN を導入してページをキャッシュすれば良い。CDN があれば、SSG/ISR はまず欲しくなることはない。

SSR 一本化を支援する仕組み

ページが常に SSR されるようにしたい訳だが、これが思いの外難しい。基本的には getServerSidePropspages/**/*.tsx に書いてあれば SSR されるのだが、もしそれを書き忘れた場合 (つまり getServerSideProps/getStaticProps/getInitialPtops いずれも書いてない場合)、Next.js はデフォルトで SSG をしてしまう。これは Next.js の Automatic Static Optimization という機能によるもの。

nextjs.org

開発途中など、getServerSideProps を書き忘れることはままあり、その度にうっかり SSG されてトラブル発生!という自体に繋がりかねない。厄介な挙動なので、id:mizdra はこれを回避するために getServerSideProps の書き忘れを検知する eslint rule を書いている。

// @ts-check

const {resolve} = require('path');

const nextConfig = require('../../next.config.js');
const {pageExtensions} = nextConfig;

const pagesDirPath = resolve(__dirname, '../../pages');

// _app と _document には getServerSideProps を書けないので無視する
const ignoredFileNames = [
  ...pageExtensions.map(ext => `${pagesDirPath}/_app.${ext}`),
  ...pageExtensions.map(ext => `${pagesDirPath}/_document.${ext}`),
];

/**
 * @file ページコンポーネントに `export const getServerSideProps = withGlobalGSSP(...)` が書かれていなければエラーを出す rule。
 */

/**
 * ファイル全体の AST から (`export const getServerSideProps = ...`) の node を探す
 * @param {import('estree').Program} programNode
 * @returns {import('estree').VariableDeclarator | undefined}
 * @example `export const getServerSideProps = ...` なら node が見つかる
 * @example `const getServerSideProps = ...; export { getServerSideProps };` なら node が見つからない
 */
function findVariableDeclaratorForGetServerSideProps(programNode) {
  // ファイルから getServerSideProps を探す
  for (const bodyNode of programNode.body) {
    if (bodyNode.type === 'ExportNamedDeclaration') {
      if (!bodyNode.declaration) continue;
      if (bodyNode.declaration.type !== 'VariableDeclaration') continue;

      // `export { getServerSideProps, otherExportedItem };` のような複数 export されているケースは考慮不要なので、[0] で取得
      const variableDeclarator = bodyNode.declaration.declarations[0];
      if (variableDeclarator === undefined) continue;

      if (variableDeclarator.id.type === 'Identifier' && variableDeclarator.id.name === 'getServerSideProps') {
        return variableDeclarator;
      }
    }
  }
}

/**
 * `export const getServerSideProps = ...` の右辺が `withGlobalGSSP(...)` となっているかどうかを返す。
 * @param {import('estree').VariableDeclarator} variableDeclarator
 * @returns {boolean}
 *
 * @example `export const getServerSideProps = withGlobalGSSP(async (context) => { ... })` なら `true`
 * @example `export const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps` なら `true`
 * @example `export const getServerSideProps = async (context) => { ... }` なら `false`
 * @example `export const getServerSideProps = withI18nGSSP(async (context) => { ... })` なら `false`
 * @example `export const getServerSideProps = withI18nGSSP(withGlobalGSSP(async (context) => { ... }))` なら `false`
 * @example `const getServerSideProps = withGlobalGSSP(async (context) => { ... }); export { getServerSideProps };` なら `false`
 */
function containsWithGlobalGSSP(variableDeclarator) {
  const init = variableDeclarator.init;
  if (!init) return false;

  // `... satisfies GetServerSideProps` と書かれていたら、そこから `...` の node を取り出す
  // @ts-expect-error -- TSSatisfiesExpression の型定義がなくてエラーになるので無視
  const callExpression = init.type === 'TSSatisfiesExpression' ? init.expression : init;

  if (callExpression.type !== 'CallExpression') return false;
  if (callExpression.callee.type !== 'Identifier') return false;
  if (callExpression.callee.name !== 'withGlobalGSSP') return false;
  return true;
}

/** @type {import('eslint').Rule.RuleModule} */
const requireGetServerSideProps = {
  create(context) {
    const filename = context.getFilename();
    if (!filename.startsWith(pagesDirPath)) return {};
    const isPageExtension = pageExtensions.some(ext => filename.endsWith(ext));
    if (!isPageExtension) return {};
    if (ignoredFileNames.includes(filename)) return {};

    return {
      Program(node) {
        const variableDeclaratorForGetServerSideProps = findVariableDeclaratorForGetServerSideProps(node);

        // getServerSideProps が見つからなかったらエラーを出す
        if (!variableDeclaratorForGetServerSideProps) {
          context.report({
            // ファイルの先頭に赤線を引いてエラーメッセージを表示
            loc: {line: 1, column: 0},
            message:
              'getServerSideProps がありません。必ず getServerSideProps を export してページを SSR してください。',
          });
          return;
        }

        if (!containsWithGlobalGSSP(variableDeclaratorForGetServerSideProps)) {
          context.report({
            node: variableDeclaratorForGetServerSideProps,
            message:
              'getServerSideProps が withGlobalGSSP でラップされていません。必ず getServerSideProps を withGlobalGSSP でラップしてください。',
          });
        }
      },
    };
  },
};

module.exports = requireGetServerSideProps;

適当に eslint-plugin-local-rules とかで読み込めば使えるはず。どうぞお使いください。

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

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