Breathnote
JamstackなNext.jsブログにプレビューモードを実装する

JamstackなNext.jsブログにプレビューモードを実装する

このブログにヘッドレスCMS (microCMS) と連携したプレビュー機能を実装したので、備忘録を残しておきます。

基本的に以下の記事を参考にしつつ、個人的に深掘りが必要だった部分を追加しています。

プレビュー用のAPIルートを作る

今回はNext.jsのAPIルートを利用してプレビュー機能を実装するため、pages/apiフォルダにpreview.tsを作成します。

preview.ts
import { NextApiRequest, NextApiResponse } from 'next'
import fetch from 'node-fetch'
import { API_ENDPOINT, API_KEY, DRAFT_TOKEN } from 'utils/env'

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const token = req.query.secret

  // 共通鍵を用いた簡易的なセキュリティチェック
  if (!token || token !== DRAFT_TOKEN) {
    res.writeHead(302, { Location: `/404` }).end()
  }

  // リクエストのパラメータをもとに、該当する下書き記事が存在するかヘッドレスCMSに確認する
  const draft = await fetch(`${API_ENDPOINT}/posts/${req.query.draftId}?fields=id&draftKey=${req.query.draftKey}`,
    { headers: { 'X-API-KEY': API_KEY } }
  )
    .then((res) => res.json())
    .catch((err) => res.status(500).end())

  // プレビュー用の情報を含めたCookieをブラウザに付与して記事ページにリダイレクト
  res.setPreviewData({
    draftId: draft.id,
    draftKey: req.query.draftKey,
  })

  res.writeHead(307, { Location: `/posts/${draft.id}` }).end()
}

このAPIが叩かれると以下の処理を実行します。

  1. トークンを用いたセキュリティチェック
  2. CMSのAPIから下書き記事を取得
  3. Cookieを付与して記事ページへリダイレクト

今回は共通鍵暗号方式でセキュリティチェックを行っています。DRAFT_TOKENの部分は別途.env.localなどで管理し、GitHubで閲覧出来ないようにしておきましょう。

プレビュー機能に関しては、最後に登場するsetPreviewData()というメソッドで実現しています。これを利用することにより以下の情報が記事ページのgetStaticProps()へ渡されます。

props
context: {
  preview: true,
  previewData: {
    // setPreviewDataで指定したデータが入る
    draftId: post.id,
    draftKey: req.query.draftKey,
  }
}

draftKeyにはreq.query.draftIdではなくpost.idを使用してリダイレクトしています。これはリダイレクト機能を悪用してユーザーを別サイトへ誘導するオープンリダイレクトの脆弱性を回避するために必要な処理です。

記事ページにプレビュー処理を追加

次にpreview.tsから受け取ったデータを用いて記事ページを更新します。このファイルには他にも色々と処理させてますが、今回は必要最低限のコードを抜き出して記載します。

Pages.tsx
export const getStaticProps: GetStaticProps = async (context) => {
  let post

  if (!context.preview) {
    // 公開済みの記事 (getStaticPaths経由)
    post = await getPost(context.params?.slug as string)
    // fallbackが有効なので手動でのエラーハンドリングが必要
    if (!post) return { notFound: true }
  } else if (context.preview) {
    // プレビュー記事 (setPreviewData経由)
    post = await fetch(
      `${process.env.API_ENDPOINT}/posts/${context.previewData.draftId}?draftKey=${context.previewData.draftKey}`,
      { headers: { 'X-API-KEY': process.env.API_KEY as string } }
    ).then((res) => res.json())
  }

  return {
    props: {
      post,
    },
  }
}

export const getStaticPaths: GetStaticPaths = async () => {
  const paths = await getAllPostPaths()

  return {
    paths,
    fallback: true, // pathsに存在しないページ (プレビューページなど) を自動でリダイレクトさせない
  }
}

今回はcontext.previewを用いて公開記事か下書き記事かの判定を行っています。この部分は皆さんの記事取得方法にあわせてよしなに変更してください。

Cookieを削除するAPIルートを作る

ここまでの処理でほぼプレビュー機能は出来ていますが、このままプレビュー用のCookieを放置したままにすると以下のような問題が起こり得ます。

  1. プレビュー画面を閲覧する
  2. そのまま他の公開済みの記事ページへ遷移する
  3. プレビュー画面が表示されてしまう

これはsetPreviewData()で付与したCookieがブラウザ終了まで維持される仕様が関係しています。Cookieを保持したまま他の記事ページへアクセスすると、getStaticProps()context.preview: trueが再度読み込まれ、プレビュー用の処理が走ってしまうのです。

そこで、プレビュー用に作成したAPIと同様にCookie削除用のAPIを作ります。pages/apiフォルダに任意のファイル名で以下のTypeScriptファイルを設置しましょう。

clear.ts
import { NextApiRequest, NextApiResponse } from 'next'

export default async (_req: NextApiRequest, res: NextApiResponse) => {
  res.clearPreviewData()
  res.writeHead(302, { Location: `/` }).end()
}

clearPreviewData()でプレビュー用のCookieを削除した後にトップページへリダイレクトしています。少し面倒ですが、プレビューモードを利用した後はこのAPIへアクセスすることでページの動作が元に戻ります。

この記事を書いてる途中で気づいたんですが、getStaticProps()で記事を取得した後にclearPreviewData()でCookieを削除してしまっても良いかもしれませんね。

APIへアクセスするためのURLを設定する

最後にヘッドレスCMSからAPIを叩くためのURLを設定します。私の場合は以下のようになりました。ちなみに、共通鍵として利用するセキュリティ用のトークンはpreview.tsのトークンと一致させておく必要があります。

URL
https://[Your Domain]/api/preview?secret=[セキュリティ用のトークン]&draftId={CONTENT_ID}&draftKey={DRAFT_KEY}

以上でプレビュー機能が完成しました。

所感

実際に上記のURLでプレビュー用APIへアクセスすると、少し読み込みを挟んでプレビュー記事が表示されたかと思います。プレビュー機能はサーバーレス関数を用いてSSRで動いているため、多少時間がかかるようです。

ちなみにnext build&next startで起動したローカル環境ではプレビュー機能が使えません。next devで起動するローカル環境か、サーバーレス関数をサポートしている本番環境へデプロイした後で確認してください。