本記事では、Next.jsを使用した microCMS におけるプレビュー機能の実装の説明を行います。
プレビュー機能の実装パターンに関しては、microCMSさんのブログで紹介されている以下の記事が参考になります。
microCMSにおけるプレビュー機能の設計パターンについて | microCMSブログ
現状の実装
使用しているフレームワークは Next.js v15(Pages Router) で、Vercel でホスティングを行っています。
記事ページは ISR(Incremental Static Regeneration)を使用しており、再検証は30分を設定しています。
当時は、実装コストや microCMS のテスト環境の有無を考慮した上で、公開用の記事ページとプレビュー用の記事ページのURLを分けて実装を行なっていました。
pages/
└── blog/
├── preview/
│ └── [id].tsx // プレビュー用
└── [id].tsx // 公開用
上記実装のメリットは、公開用の記事ページは ISR、プレビュー用の記事ページではデータ更新が即座に反映される SSR(Server-side Rendering)を使用したいという要望を簡単に実装することが可能な点です。
しかし、運用していく中で
- 修正が入るたびに、公開用の記事ページとプレビュー用の記事ページの2ページを更新する必要がある
という辛さを徐々に感じてきました。
主にプレビュー用の記事ページの修正漏れがあり、対策として Linter でルールを作成して、pre-commit などで自動的に修正漏れを検知する仕組みを導入するなど検討したのですが、その前にプレビュー機能の実装方針を変えることで解決できないか考えました。
調べる中で、次章で説明する Next.js の Draft Mode を使用することで解決できることが分かりました。
Draft Mode を使用した実装
プレビュー機能の実装において満たしたい条件は以下です。
- プレビュー(下書き)はSSR、公開記事はSSGを行いたい
- テスト環境(クライアント・microCMS)を複数用意したくない
- 同一URLで実装したい
特に、同一URLでの実装は既存の課題を解決するものです。
これらをすべて満たすために、Next.js の Draft Mode を使用することを検討しました。
Draft Mode とは、SSGでビルド時に静的生成しているページに対して、「プレビューしたいときだけ」静的生成をバイパスしてリクエスト時レンダリングに切り替える仕組みです。Headless CMS で下書き作成して、公開せずにプレビューとして表示確認したい用途を想定しています。
Guides: Draft Mode | Next.js
厳密には SSR ではありませんが、挙動は同じく「プレビュー用の記事ページではデータ更新を即座に反映したい」という要望は実現できます。
ちなみに
以前は Pages Router用に Preview Mode 機能が存在していましたが、現在は App Router・Pages Router のどちらとも Draft Mode を使用するように推奨されています。
Guides: Preview Mode | Next.js
実装
公式ドキュメントに則って進めていきます。
手順は大きく2つで、
- API ルート内で検証を行い、問題なければ Draft Mode を有効にして、記事ページへリダイレクトする
- 記事ページにて、Draft Mode であれば下書きコンテンツをすべて取得できる API キーを使用して記事の詳細情報を取得する
の流れになります。
初めにAPI ルートの作成を行い、そのルートで、setDraftMode を呼び出し Draft Mode を有効にします。
この過程で安全に、かつ正しいプレビュー用の記事ページかどうかを検証します。
Next.js の公式では、① Next.js アプリケーションとヘッドレス CMS のみが認識できる秘密トークンを使用して安全にアクセスすることを推奨しています。その検証が問題なければ、② 正しい draftKey を持ったプレビュー用の記事ページが存在するかを確認します。存在する場合に、③ Draft Mode を有効にして、リダイレクトします。
以下が全体のコードです。
// pages/api/draft.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { getArticleDetail } from '~/lib/microcms/api'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const secret = req.query.secret as string
const slug = req.query.slug as string
const articleId = slug.split('/').pop() as string
const draftKey = req.query.draftKey as string
// ① トークン検証
if (secret !== process.env.DRAFT_MODE_SECRET || !slug) {
return res.status(401).json({ message: 'Invalid token' })
}
// ② プレビュー用の記事ページの取得
// getArticleDetailはリクエスト層の抽象化を行った関数
const article = await getArticleDetail(articleId, { draftKey })
if (!article) {
return res.status(404).json({ message: 'Article not found' })
}
// ③ Draft Mode を有効にして、pages/blog/[id].tsx へリダイレクトする
res.setDraftMode({ enable: true })
res.redirect(`/blog/${article.id}`)
}
次に、pages/blog/[id].tsx ページの getStaticProps の中で ① Draft Mode が有効かを判断します。② 有効であれば、下書き記事を取得する API キーを使用したインスタンスを、有効ではない場合、本番公開記事のみを取得する API キーを使用したインスタンスでリクエストを行います。
有効かどうかは、 getStaticProps のcontext オブジェクトからアクセス可能です。
以下が全体のコードです。
// pages/blog/[id].tsx
import type { GetStaticProps, GetStaticPaths } from 'next'
import { getArticleDetail, getArticleDetailDraft } from '~/lib/microcms/api'
import type { Article } from '~/lib/microcms/types'
type Props = {
article: Article
isPreview: boolean
}
export default function MagazineArticlePage({ article, isPreview }: Props) {
return (
<>
{isPreview && (
<p>
プレビュー環境
</p>
)}
<main style={{ paddingTop: isPreview ? 40 : 0 }}>
<h1>{article.title}</h1>
</main>
</>
)
}
export const getStaticPaths: GetStaticPaths = async () => {
return { paths: [], fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps<Props> = async (context) => {
const articleId = context.params?.id as string | undefined
if (!articleId) return { notFound: true }
// ① Draft Mode が有効かを判断
const isPreview = context.draftMode ?? false
// ② 有効であれば、下書き記事を取得するAPI キーを使用したインスタンスを使用する。有効ではない場合は、本番公開記事のみを取得するAPI キーを使用したインスタンスを使用
const article = isPreview
? await getArticleDetailDraft(articleId)
: await getArticleDetail(articleId)
if (!article) return { notFound: true, revalidate: 1 }
return {
props: { article, isPreview },
revalidate: 1800,
}
}
App Routerを使用している場合、大まかな流れは同じで、詳細の実装が若干異なります。
特に Route Handler 内で draftKey を Cookie に保存して、記事ページにて保存した draftKey を使用してリクエストすることが可能です。
この場合は、インスタンスを使い分ける必要がないので、個人的には、コードがよりシンプルになって良いと思いました。
まとめ
Draft Mode を採用することで、公開ページの ISR/SSG の恩恵を維持しつつ、プレビュー時だけ更新を即座に反映できるようになりました。URL を分ける実装と比べて、UI やコンポーネントの変更を 1つのページに集約できるため、修正漏れのリスクが下がり、運用がシンプルになります。
また、プレビュー開始時は「secret の検証 → draftKey を用いた存在確認 → Draft Mode 有効化」という流れにしておくことで、意図しないアクセスを弾きつつ、安全にプレビューへ誘導できます。