Next.js ではそのレンダリング方法を、ページ単位で CSR / SSR / SSG から選び使うことができます。それぞれの違いは、ざっくりと以下のように説明できます。

  • SG(Static Generation): アプリのビルド時にデータフェッチ等の処理をおこない、HTMLを事前に構築しておく。リクエスト時にビルド済の HTML を返す。
  • CSR(Client-Side Rendering): React アプリをレンダリングする最も一般的な方法。ブラウザでアプリをレンダリングする。
  • SSR(Server-Side Rendering): リクエストを受けたサーバ側でデータフェッチ等の処理をおこない、HTML を構築しブラウザに返す。

特に SG(Static Generation)とSSR (Server-Side Rendering)は、クライアントサイドのjavascriptですべて処理するのではなく、事前にHTMLを生成しておくことから、Pre-Rendering との名称で括られています。

参考: Basic Features: Pages | Next.js

本記事は、Next.js で構築したサイトの各ページにどのようにメタ情報を設定するかに注目し、上記のそれぞれのレンダリング方法でのメタタグの設定方法をいろいろ試してみた、なかば実験的な内容となります。

特に、ダイナミックルーティングによって、動的にページを出し分けたりする場合に、そのページのメタ情報をどのように設定すればいいかなど、参考になれば幸いです。

サンプルについて

今回はサンプルとして作ったサイトが以下になります。
https://study-next-ogp.vercel.app/

サイトの構成は以下のようになっています。

└── pages/
    ├── index.js ... ①
    ├── about.js ... ①
    ├── Photos-client/
    │     ├── index.js ... ①
    │     └── [photo_id]/
    │             └── index.js ... ②
    └── Photos-ssr/
          ├── index.js ... ①
          └── [photo_id]/
                  └── index.js ... ③

① は静的なメタ情報を持つページです。トップページや About ページといった、常に同じ値のメタ情報を返すイメージです。

② は動的なメタ情報を持つページで、CSR で構築します。

③ は②と同様に動的なメタ情報を持つページです。こちらは SSR で構築します。

API について

今回はダミーの REST API を使える JSONPlaceholder というサービスを利用します。
個人が制作、メンテナンスしているサービスなので利用には注意が必要ですが(個人が学習目的が使うレベルに留めておくのをオススメします)、今回のケースのようにサクッと API を叩いてなにかを確認したいときなどに便利です。

その他事前の補足

  • Next.js で、かつ SSR を必要とするアプリケーションを作るということもあり、今回デプロイには Vercel を利用します。
  • 記事の取得には useSWR を利用しています。詳細な使い方については、公式サイトのこのあたりのページに詳しく書かれていますので、参照ください。
  • ソースコードはこちらに置いておきますので、適宜参照してください。(基本的には、 create-next-app コマンドで立ち上げた構成をそのまま使っています。)

① 静的なメタ情報を持つページの場合(SG)

まず、静的なメタ情報を持つページとしてトップページの例を見てみます。コードは以下のようになります。

// /pages/index.js

import Link from "next/link"
import CommonMeta from '../components/CommonMeta'
import styles from '../styles/Home.module.css'

export default function Home() {

  return (
    <div className={styles.container}>
      <CommonMeta />
      <main>
        <h1>Photo Appliation</h1>
        <div>
          <Link href="/about">
            <a>about</a>
          </Link>
        </div>
        <div>
          <Link href="/photos-ssr">
            <a>Photos - SSR</a>
          </Link>
        </div>
        <div>
          <Link href="/photos-client">
            <a>Photos - client</a>
          </Link>
        </div>
      </main>
    </div>
  )
}

ここでは例として、完全に静的なページという想定なので、getStaticPropsgetServerSideProps 等の API は一切使っていません。

9行目で展開される <CommonMeta> コンポーネントは、titledescription を渡して <head>タグを生成する共通コンポーネントとして、以下のように定義しています。OGイメージは共通の画像です。

Next.js では <head> タグに含みたい情報は next/head からインポートした Head 内に記述します。

// /components/CommonMeta/index.js

import Head from 'next/head'

export default function CommonMeta({ title = "Photo Application", description = "This is Photo Application!!!!" }) {

  return (
    <Head>
      <title>{title}</title>
      <meta property="description" content={description} />
      <meta property="og:title" content={title} />
      <meta property="og:description" content={description} />
      <meta property="og:image" content={`${process.env.SITE_URL}/ogp_large.png`} />
      <meta name="twitter:card" content="summary_large_image"/>
    </Head>
  )
}

そして、このページを next build コマンドでビルドすると、以下のような HTML が生成されます。

<!DOCTYPE html><html lang="ja"><head><meta name="format-detection" content="telephone=no"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><link rel="icon" href="/img/favicon.ico"/><meta name="viewport" content="width=device-width"/><meta charSet="utf-8"/><title>Photo Application</title><meta property="description" content="This is Photo Application!!!!"/><meta property="og:title" content="Photo Application"/><meta property="og:description" content="This is Photo Application!!!!"/><meta property="og:image" content="https://study-next-ogp.vercel.app/ogp_large.png"/><meta name="twitter:card" content="summary_large_image"/><meta name="next-head-count" content="8"/><link rel="preload" href="/_next/static/css/dc3b0926b19639b49043.css" as="style"/><link rel="stylesheet" href="/_next/static/css/dc3b0926b19639b49043.css" data-n-g=""/><link rel="preload" href="/_next/static/css/afd7172b7cfc566ac23d.css" as="style"/><link rel="stylesheet" href="/_next/static/css/afd7172b7cfc566ac23d.css" data-n-p=""/><noscript data-n-css=""></noscript><link rel="preload" href="/_next/static/chunks/main-5e609e80697683d3f38f.js" as="script"/><link rel="preload" href="/_next/static/chunks/webpack-50bee04d1dc61f8adf5b.js" as="script"/><link rel="preload" href="/_next/static/chunks/framework.e2fe4ae6b85b1c7a6eb1.js" as="script"/><link rel="preload" href="/_next/static/chunks/e140ceb0519c3aea3ebb96b2ad081f8a2b858b07.8835e33749f1f58f5fef.js" as="script"/><link rel="preload" href="/_next/static/chunks/pages/_app-94eff34f3334bc5c50bb.js" as="script"/><link rel="preload" href="/_next/static/chunks/f3f1347cad7da80e7f52277ee5a50c9536753e95.b9111c7fc7e68c6df901.js" as="script"/><link rel="preload" href="/_next/static/chunks/pages/index-9b66544957d6b870e40e.js" as="script"/></head><body><div id="__next"><div><main><h1>Photo Appliation</h1><div><a href="/about">about</a></div><div><a href="/photos-ssr">Photos - SSR</a></div><div><a href="/photos-client">Photos - client</a></div></main></div></div><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}},"page":"/","query":{},"buildId":"mv5qb15L8Z7Zivjq_dyeA","runtimeConfig":{"API_URL":"https://jsonplaceholder.typicode.com","SITE_URL":"https://study-next-ogp.vercel.app"},"nextExport":true,"autoExport":true,"isFallback":false}</script><script nomodule="" src="/_next/static/chunks/polyfills-28654a8145d7603786fc.js"></script><script src="/_next/static/chunks/main-5e609e80697683d3f38f.js" async=""></script><script src="/_next/static/chunks/webpack-50bee04d1dc61f8adf5b.js" async=""></script><script src="/_next/static/chunks/framework.e2fe4ae6b85b1c7a6eb1.js" async=""></script><script src="/_next/static/chunks/e140ceb0519c3aea3ebb96b2ad081f8a2b858b07.8835e33749f1f58f5fef.js" async=""></script><script src="/_next/static/chunks/pages/_app-94eff34f3334bc5c50bb.js" async=""></script><script src="/_next/static/chunks/f3f1347cad7da80e7f52277ee5a50c9536753e95.b9111c7fc7e68c6df901.js" async=""></script><script src="/_next/static/chunks/pages/index-9b66544957d6b870e40e.js" async=""></script><script src="/_next/static/mv5qb15L8Z7Zivjq_dyeA/_buildManifest.js" async=""></script><script src="/_next/static/mv5qb15L8Z7Zivjq_dyeA/_ssgManifest.js" async=""></script></body></html>

minify された1行のコードはちょっと見づらいので、ブラウザのページソースの表示画面の画像も貼っておきます。

生成された HTML の中に、<CommonMeta> 内で記述したメタタグもしっかり含まれているので、このトップページで OGP を確認すると当然ちゃんと指定したとおりに表示されます。以下は Twitter の Card Validator で確認してみたものです。

このケースでは、このトップページでは props を持たず、常に出力結果は同じです。Next.js では Automatic Static Optimization という機能を備えており、こういった場合は静的なHTMLをビルド時に生成してくれます。

ビルドログを見てみると、たしかにトップページは ○ (Static) として生成されていることが確認できます。

また、もうひとつ別のページの例として About ページを見てみましょう。

主な違いとしては、title および description をデフォルト値ではなく、コンポーネントの呼び出し側で設定している、というくらいです。

// /pages/about.js

import CommonMeta from '../components/CommonMeta'
import styles from '../styles/Home.module.css'

export default function About() {
  return (
    <div className={styles.container}>
      <CommonMeta title="About" description="This is About page." />
      <main className={styles.main}>
        <h1 className={styles.title}>
          about
        </h1>
        <p>This is about page.</p>
      </main>
    </div>
  )
}

こちらも、先ほどと同様、Card Validator で確認してみると、About ページ用に記述した title および description は適切に設定されていることが分かります。

② 動的なメタ情報を持つページの場合(SSR)

次にSSRで記事を取得する例を見ていきましょう。ページコンポーネントのコードはこちら。

// /pages/photos-ssr/[photo_id]/index.js

import axios from "axios";
import useSWR from 'swr'
import Head from 'next/head'
import getConfig from 'next/config'
import styles from '../../../styles/Home.module.css'

const { publicRuntimeConfig } = getConfig()
const { API_URL } = publicRuntimeConfig
export const fetcher = (url)=> axios(url).then(res => res.data)

export async function getServerSideProps({ query }) {
  const data = await fetcher(`${API_URL}/photos/${query.photo_id}`)
  return { props: { data, query } }
}

export default function Photo(props) {
  const initialData = props.data
  const { data } = useSWR(`${API_URL}/todos`, fetcher, {
    initialData
  })

  return (
    <div className={styles.container}>
      <Head>
        <title>【SSR】Photo_id: {props.query.photo_id}</title>
        <meta property="og:title" content={`【SSR】photo_id: ${props.query.photo_id}`} />
        <meta property="og:description" content={`${data.title}`} />
        <meta property="og:image" content={data.thumbnailUrl} />
        <meta name="twitter:card" content="summary"/>
      </Head>

      <main className={styles.main}>
        <h1>Photos - SSR</h1>
        <h2>
          Photo_Id: {props.query.photo_id}
        </h2>
        <img src={data.url} alt={data.title} />
      </main>
    </div>
  )
}

サーバサイドで実行される getServerSideProps で、記事の取得を行い、得られた data および query を props として返しています。

そのうえで、コンポーネントの本体となる Photo 関数に上記の props を渡し、パスパラメータの値や、data 内の各種値を使ってレンダリングしています。

<title>【SSR】Photo_id: {props.query.photo_id}</title>
<meta property="og:title" content={`【SSR】photo_id: ${props.query.photo_id}`} />
<meta property="og:description" content={`${data.title}`} />
<meta property="og:image" content={data.thumbnailUrl} />
<meta name="twitter:card" content="summary"/>

なお、useSWR を用いた SSR については、以下の記事の解説がわかりやすくておすすめです。

参考: 【React】useSWRはAPIからデータ取得をする快適なReact Hooksだと伝えたい

それでは、上記のコードでビルドされたソースコードを見てみましょう。

<head> タグ周辺をよく見ると、<title> タグや <og:image> タグといったメタタグがたしかに存在してることが確認できます。

これらはサーバ側の実行結果であるので、Card Validator での OGP の表示結果にも当然反映されます。ちなみに、Twitterカードのサイズは、他のページとは違い summary で設定してみました。

③ 動的なメタ情報を持つページの場合(CSR)

最後にクライアントサイドで記事を取得する例を見ていきましょう。コードはこちら。

// /pages/photos-client/[photo_id]/index.js

import useSWR from 'swr'
import { useRouter } from 'next/router'
import axios from "axios";
import Head from 'next/head'
import getConfig from 'next/config'
import styles from '../../../styles/Home.module.css'

const { publicRuntimeConfig } = getConfig()
const { API_URL } = publicRuntimeConfig

export default function Photo() {
  const router = useRouter()
  const { photo_id } = router.query

  const fetcher = (url)=> axios(url).then(res => res.data)
  const { data } = useSWR(`${API_URL}/photos/${photo_id}`, fetcher)

  if (!data) return <div>loading...</div>

  return (
    <div className={styles.container}>
      <Head>
        <title>{`【Client】photo_id: ${photo_id}`}</title>
        <meta property="og:title" content={`【Client】photo_id: ${photo_id}`} />
        <meta property="og:description" content={data.title} />
        <meta property="og:image" content={data.url} />
        <meta name="twitter:card" content="summary"/>
      </Head>

      <main className={styles.main}>
        <h1>Photos - client</h1>
        <h2>
          Photo_Id: {photo_id}
        </h2>
        <img src={data.url} alt={data.title} />
      </main>
    </div>
  )
}

このケースでは記事の取得はクライアント側でおこなうので、サーバ側で実行される getServerSideProps は必要ありません。

そのうえで、クライアント側で取得したデータを元にメタタグを設定しています。

<title>{`【Client】photo_id: ${photo_id}`}</title>
<meta property="og:title" content={`【Client】photo_id: ${photo_id}`} />
<meta property="og:description" content={data.title} />
<meta property="og:image" content={data.url} />
<meta name="twitter:card" content="summary"/>

で、これを実際にビルドしてみるとソースコードはどうなるかというと、当然 <Head> タグ内の記述はありません。
記事のデータ取得はブラウザで実行されているので、ソースコードを生成する段階ではまだ存在しないんですね。

もちろん card validator で検証した結果も、以下のように Not Found ということになります。

ただし、一方でブラウザ側では取得された記事データをもとにメタタグが設定されていることが分かります。

サイト全体に共通するメタタグを設定する

上記のクライアントサイドで記事を取得する例だと、そのページをシェアしたりする際に何の情報も表示されないので、それはそれで不便です。なので、そういうときは全体設定のための _app.js ないし、 _document.js を活用するのがいいでしょう。

// _app.js

import React from 'react'
import Head from 'next/head'

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Head>
        <title>Photo Appliation</title>
        <meta property="og:title" content="Photo Appliation" />
        <meta property="og:description" content="This is Photo Appliation." />
      </Head>
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

なお、<title> タグは _document.js 内に書くと Warning が出るので、注意が必要です。

参考: <title> should not be used in _document.js’s <Head>

また、その他のページコンポーネントにおいてタグの重複が発生する場合は、 key プロパティを設定することで回避できます。

参考: next/head | Next.js