Chapter 10

レンダリングオプション(CSR/SSG/SSR/ISR)

Thirosue
Thirosue
2021.08.28に更新

Next.jsは、様々なレンダリングオプションを提供しています。

図解 CSR, SSR, SSG, ISR

https://zenn.dev/bitarts/articles/37260ddb28ae5d

ECサイトなどの未認証エリアが存在する場合、ISR(+CSR)が有力なオプションになってきます(ex: 商品詳細ページ)が、今回は管理画面を作成するため、以下整理のもとページを作成していきます。

対象 認証 レンダリング方法
ログインページ 未認証エリア SSG
ダッシュボードトップ 認証エリア SSR+CSR
商品詳細 認証エリア SSR+CSR

レンダリングオプション確認

Next.jsではビルド時に各ページのレンダリングオプションが確認できます。

Page                              Size     First Load JS
┌ λ /                             4.05 kB         133 kB // <--- ダッシュボードページ 想定通り、SSRとなっている
├   /_app                         0 B              67 kB
├ ○ /404                          195 B          67.2 kB
├ λ /api/auth                     0 B              67 kB
├ λ /api/auth/check               0 B              67 kB
├ λ /api/auth/signout             0 B              67 kB
├ λ /api/code/verify              0 B              67 kB
├ λ /api/hello                    0 B              67 kB
├ λ /api/password/change          0 B              67 kB
├ λ /api/products                 0 B              67 kB
├ λ /api/products/delete          0 B              67 kB
├ λ /api/products/post            0 B              67 kB
├ λ /api/products/put             0 B              67 kB
├ λ /complete                     897 B           130 kB
├ ○ /login                        164 kB          293 kB // <--- ログインページ 想定通り、SSGとなっている
├ λ /product/[id]                 2.37 kB         131 kB // <--- 商品詳細ページ 想定通り、SSRとなっている
└ ○ /ui-elements                  2.13 kB         131 kB
+ First Load JS shared by all     67 kB
  ├ chunks/framework.925f8b.js    42 kB
  ├ chunks/main.c6384a.js         23.4 kB
  ├ chunks/pages/_app.48583c.js   639 B
  ├ chunks/webpack.0e0f5c.js      870 B
  └ css/90a51ada7dc46c1d8f77.css  8.41 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)(Static)  automatically rendered as static HTML (uses no initial props)(SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)

SSG (Static Site Generating)

ビルド時のオプションの詳細にあるように、ビルド時にプリフェッチデータなしの純粋な静的ページは、オプションを何も指定しないことで選択されます。

(Static)  automatically rendered as static HTML (uses no initial props)(SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

ログインページ

以下サンプルのように、ページにデータが必要ないページは、純粋なHTML+JavaScriptページとして出力されます。

pages/login.tsx
import { useState, ReactElement } from 'react'
import { SimpleLayout } from '../components/template'
import LoginPage from '../components/page/login-page'
import PasswordDialog from '../components/page/password-dialog'
import ConfirmCodeModal from '../components/page/confirm-code-dialog'

export default function Login(): JSX.Element {
  // For Password Modal
  const [state, setState] = useState<'Init' | 'WaitingForCode' | 'Done'>('Init')

  // For Password Modal
  const [passwordModal, setPasswordModalOpen] = useState(false)
  const handlePasswordModalClose = (_: any): void => setPasswordModalOpen(false)

  return (
    <>
      <LoginPage passwordModalOpen={() => setPasswordModalOpen(true)} />
      {passwordModal && (
        <PasswordDialog
          onSubmit={() => setState('WaitingForCode')}
          onClose={handlePasswordModalClose}
          onCancel={handlePasswordModalClose}
        />
      )}
      {state === 'WaitingForCode' && (
        <ConfirmCodeModal
          onSubmit={() => setState('Done')}
          onClose={() => setState('Init')}
          onCancel={() => setState('Init')}
        />
      )}
    </>
  )
}

Login.getLayout = function getLayout(page: ReactElement) {
  return <SimpleLayout title={'ログイン'}>{page}</SimpleLayout>
}

Httpieで取得すると、画面のDomが生成されている状態でHTMLが返されていることが確認できます。事前にページ全体がレンダリングされているため、SPAに比べレンダリングコストが圧縮されており、パフォーマンスが優れています。

% http https://next-typescript-sample-mu.vercel.app/login
HTTP/1.1 200 OK
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Sat, 28 Aug 2021 10:56:39 GMT
Transfer-Encoding: chunked
access-control-allow-origin: *
age: 0
cache-control: public, max-age=0, must-revalidate
content-disposition: inline; filename="login"
etag: W/"d5c4171567ba371ad4496274e26e06b543997cd85a6ba7afb970181ea706d545"
server: Vercel
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-matched-path: /login
x-vercel-cache: MISS
x-vercel-id: hnd1::42lsz-1630148198612-7aab6ae5c105

<!DOCTYPE html><html><head><meta charSet="utf-8"/><title>Sample - ログイン</title><link rel="icon" href="/favicon.ico"/><meta name="description" content="Create a Next.js sample app powered by Vercel."/><meta property="og:image" content="https://avatars.githubusercontent.com/u/14899056?v=4"/><meta name="og:title" content="Sample - ログイン"/><meta name="twitter:card" content="summary_large_image"/><meta property="og:site_name" content="Sample"/><meta property="og:title" content="Sample - ログイン"/><meta property="og:description" content="Create a Next.js sample app powered by Vercel."/><meta property="og:url" content="https://next-typescript-sample-mu.vercel.app/"/><meta property="og:type" content="website"/><meta property="og:locale" content="ja_JP"/><meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/><meta http-equiv="x-ua-compatible" content="ie=edge"/><meta name="referrer" content="always"/><meta name="next-head-count" content="16"/><link rel="preload" href="/_next/static/css/90a51ada7dc46c1d8f77.css" as="style"/><link rel="stylesheet" href="/_next/static/css/90a51ada7dc46c1d8f77.css" data-n-g=""/><noscript data-n-css=""></noscript><script defer="" nomodule="" src="/_next/static/chunks/polyfills-e7a279300235e161e32a.js"></script><script src="/_next/static/chunks/webpack-0e0f5c5c9fa5a29e0d78.js" defer=""></script><script src="/_next/static/chunks/framework-925f8b290ee4a52af3fc.js" defer=""></script><script src="/_next/static/chunks/main-c6384a826dbb19afa6a3.js" defer=""></script><script src="/_next/static/chunks/pages/_app-48583c9f5cea0431e817.js" defer=""></script><script src="/_next/static/chunks/29107295-62449f6ab50432c0efef.js" defer=""></script><script src="/_next/static/chunks/385-7f20216bc397cd254bf9.js" defer=""></script><script src="/_next/static/chunks/50-9f664874ca121ec9c499.js" defer=""></script><script src="/_next/static/chunks/731-f4167a700c88d6570f6c.js" defer=""></script><script src="/_next/static/chunks/pages/login-d4d950721c1a1cea8be3.js" defer=""></script><script src="/_next/static/1EFy6xju_t32l4hm2bGpi/_buildManifest.js" defer=""></script><script src="/_next/static/1EFy6xju_t32l4hm2bGpi/_ssgManifest.js" defer=""></script></head><body><div id="__next"><div class="flex justify-center items-center h-screen bg-gray-200 px-6"><div class="p-6 max-w-sm w-full bg-white shadow-md rounded-md"><div class="flex justify-center items-center"><svg class="h-10 w-10" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M364.61 390.213C304.625 450.196 207.37 450.196 147.386 390.213C117.394 360.22 102.398 320.911 102.398 281.6C102.398 242.291 117.394 202.981 147.386 172.989C147.386 230.4 153.6 281.6 230.4 307.2C230.4 256 256 102.4 294.4 76.7999C320 128 334.618 142.997 364.608 172.989C394.601 202.981 409.597 242.291 409.597 281.6C409.597 320.911 394.601 360.22 364.61 390.213Z" fill="#4C51BF" stroke="#4C51BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M201.694 387.105C231.686 417.098 280.312 417.098 310.305 387.105C325.301 372.109 332.8 352.456 332.8 332.8C332.8 313.144 325.301 293.491 310.305 278.495C295.309 263.498 288 256 275.2 230.4C256 243.2 243.201 320 243.201 345.6C201.694 345.6 179.2 332.8 179.2 332.8C179.2 352.456 186.698 372.109 201.694 387.105Z" fill="white"></path></svg><div><span class="text-gray-900 text-2xl font-semibold">Dashboard</span></div></div><form class="mt-4"><label class="block"><div><span class="prose prose-sm">Email</span></div><input type="email" id="email" class="mt-1 w-full border-gray-300 block rounded-md focus:border-indigo-600 " name="email"/><p class="text-red-500 text-xs mt-1 email-error-message-area"></p></label><label class="block mt-3"><div><span class="prose prose-sm">Password</span></div><input type="password" id="password" class="mt-1 w-full border-gray-300 block rounded-md focus:border-indigo-600 " name="password"/><p class="text-red-500 text-xs mt-1 password-error-message-area"></p></label><div class="flex justify-between items-center mt-4"><div><label class="inline-flex items-center"><input type="checkbox" id="rememberMe" class="form-checkbox text-indigo-600" name="rememberMe"/><div><span class="prose prose-sm mx-2">Remember me</span></div></label></div><div><a class="block text-sm fontme text-indigo-700 hover:underline" href="#">Forgot your password?</a></div></div><div class="mt-6"><button class="inline-flex justify-center rounded-md border focus:outline-none focus:ring-2 focus:ring-offset-2 text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500 border-transparent primary-button py-2 px-4 text-sm w-full ">Sign in</button></div></form></div></div><div class="Toastify"></div></div><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/login","query":{},"buildId":"1EFy6xju_t32l4hm2bGpi","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[]}</script></body></html>

SSR (Server Side Rendering)

今回のアプリは、管理画面であるため、未認証エリアのページは基本SSRとする必要があります。
SSRを選択する理由は、以下などです。

  • 画面(データ)への閲覧制御が必要
  • SSG+CSRのオプションで対応することも検討できるが、HTMLが流出することで、機能および業務の類推が可能となり競争優位性が失われる可能性がある

期待する結果の確認

以下が、認証が必要なページへの直アクセス時に期待する結果です。

認証情報がない場合は、ログイン画面へリダイレクトさせる

Httpieで動作が想定どおりか確認してみます。

 % http https://next-typescript-sample-mu.vercel.app/
HTTP/1.1 307 Temporary Redirect
Connection: keep-alive
age: 0
cache-control: public, max-age=0, must-revalidate
content-length: 0
date: Sat, 28 Aug 2021 11:01:14 GMT
location: /login
server: Vercel
set-cookie: state=; Max-Age=-1
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-matched-path: /
x-vercel-cache: MISS
x-vercel-id: hnd1::iad1::gjhkh-1630148473016-bc973b904bd2

結果、想定どおりの動作であることが確認できました。

SSR設定

商品詳細画面

上記のビルド時のオプションの詳細にあるように、getServerSidePropsをページに指定することで、レンダリング時のプリフェッチ処理を設定でき、対象の画面はSSRモードでビルドされます。

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
pages/product/[id].tsx
export default function ProductDetail({
  product,
}: {
  product: Product // getServerSidePropsで処理したプロパティを受け取り、画面を表示する
}): JSX.Element {
  const router = useRouter()

...(中略)...
  
}

// getServerSidePropsを設定することで、対象画面はSSRでビルドされる
export const getServerSideProps: GetServerSideProps = checkSession(
  async ({ params }) => {
    // 指定されたパラメータを元に商品を特定し、ページに渡す
    const product = _.head(
      data.getProducts().filter((row: Product) => row.id === Number(params.id))
    )
    captains.log(`target product id = ${product.id}`)
    if (product) {
      return {
        props: {
          product,
        },
      }
    } else {
      return {
        redirect: {
          permanent: false, // 永続的なリダイレクトかどうか
          destination: '/404', // リダイレクト先
        },
      }
    }
  }
)

共通処理(SSR)

管理画面などのアプリケーションでは、SSRモードの際に共通で設定したい処理があります。

  • 認証処理
  • ロギング処理
  • リクエスト追跡ID設定処理

認証処理

戻り値が関数の高階関数を定義することで、認証が必要なページなどで必要な処理を共通できます。
以下サンプルは、認証チェック処理です。

認証チェック処理は、認証cookieからトークン(jwtToken)を抽出し、トークンの有効性を判定し、トークンが無効な場合は、ログイン画面へリダイレクトさせます。

filters/checkSession.ts
import { GetServerSidePropsContext } from 'next'
import { NextApiRequestCookies } from 'next/dist/server/api-utils'
import { GlobalState } from '../data/global-state'
import TokenHelper from '../helpers/token'
import { ParsedUrlQuery } from 'node:querystring'
import { destroyCookie } from 'nookies'

const captains = console

// 認証cookieの型定義
type SessionCookie = NextApiRequestCookies & {
  state?: string
}

export const checkSession =
  (f: (ctx: GetServerSidePropsContext<ParsedUrlQuery>) => any): any =>
  async (ctx: GetServerSidePropsContext<ParsedUrlQuery>) => {
    try {
      const cookie = ctx.req.cookies as SessionCookie // 認証cooikeを取得
      const { session } = JSON.parse(cookie.state) as GlobalState
      TokenHelper.verify(session.jwtToken) // jwtトークンの検証
      return f(ctx)
    } catch (e) {
     // トークンが無効な場合は、ログインページにリダイレクトさせる
      captains.warn('cookie is invalid... redirect to login page')
      destroyCookie(ctx, 'state')
      return {
        redirect: {
          permanent: false, // 永続的なリダイレクトかどうか
          destination: '/login', // リダイレクト先
        },
      }
    }
  }

利用方法

getServerSidePropsを定義時に、作成した共通処理(高階関数)でラップします。

pages/product/[id].tsx
import { checkSession } from '../../filters/checkSession'

...(中略)...

// checkSessionで関数をラップする
export const getServerSideProps: GetServerSideProps = checkSession(
  async ({ params }) => {
    // 指定されたパラメータを元に商品を特定し、ページに渡す
    const product = _.head(
      data.getProducts().filter((row: Product) => row.id === Number(params.id))
    )
    captains.log(`target product id = ${product.id}`)
    if (product) {
      return {
        props: {
          product,
        },
      }
    } else {
      return {
        redirect: {
          permanent: false, // 永続的なリダイレクトかどうか
          destination: '/404', // リダイレクト先
        },
      }
    }
  }
)