SSG も ISR も SSR も、どれも便利なのだけど、どれも扱いが難しい! SSR 1つを取っても、first paint の状態で GraphQL API から取得したデータがレンダリングされた状態を目指すなら、それなりに複雑な仕組みを getServerSideProps
/_app.ts
に実装しないといけない。
BFF で GraphQL API を叩いて、その結果を pageProps に焼き込んで、 コンポーネントのレンダリング時に pageProps から結果を取り出して GraphQL クライアントのキャッシュにそれを書き込んで…みたいなことを SSR でやる必要がある訳だけど、これを世の中の人々がちゃんと実装できてると到底思えない
— mizdra (@mizdra) 2023年6月21日
素朴にやると 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
でも上記のような仕組みを再実装する必要がある! withApolloGSSP
は getServerSideProps
専用なので、getStaticProps
用のものを別で用意しないといけない。また何種類か違うバリエーションのページ (例えば多言語対応をしたいとか) を作りたかったら、slug *1 (pages/[lang]/...
) を導入して、バリエーションの違うページごとに URL が分かれる構成にしないといけない (SSR では同じ URL で異なるバリエーションのページを配信できるのに!)。
SSG は SSR の静的版、くらいの捉え方がよくされているのだけど、実際に両者を扱ってみると、想像以上に使い勝手が異なる。SSG を SSR と同じ気持ちで使っていると、「あれ? 何だか期待通りに動かないな」ということが頻発する。
SSR に一本化するという選択肢
...ということもあって、id:mizdra は SSG を避けて、極力 SSR を使うようにしている。メンタルモデルが違うものを同時に扱うのは難しいので、どっちかに一本化しようという考えがベース。サーバー負荷が気になるようであれば、CDN を導入してページをキャッシュすれば良い。CDN があれば、SSG/ISR はまず欲しくなることはない。
SSR 一本化を支援する仕組み
ページが常に SSR されるようにしたい訳だが、これが思いの外難しい。基本的には getServerSideProps
が pages/**/*.tsx
に書いてあれば SSR されるのだが、もしそれを書き忘れた場合 (つまり getServerSideProps
/getStaticProps
/getInitialPtops
いずれも書いてない場合)、Next.js はデフォルトで SSG をしてしまう。これは Next.js の Automatic Static Optimization という機能によるもの。
開発途中など、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
とかで読み込めば使えるはず。どうぞお使いください。