App RouterでmicroCMSのプレビューを表示する方法 [Next.js]
App RouterでmicroCMSの「プレビュー」機能を実装します。
ドラフトモードとは
その前に、今までのプレビュー実装方法を思い出しましょう。「Preview Mode」を使っていたはずです。
Next.js v13.4では、Preview Modeの後継「Draft Mode(ドラフトモード)」 が正式実装されました。
前提知識として、App Routerのレンダリングには
- 静的(スタティック)描画モード(デフォルト)
- 動的(ダイナミック)描画モード
があります。前者は下書きのような場面に適しません。
Draft Modeが有効な間は、ルートが動的描画モードになり、プレビューの実装を助けます。
なお、従来のプレビューモードは「レガシー」扱いとなっており、App Routerでは使えません。
今回実装する手法の背景を解説
...が、ドラフトモードはmicroCMSと相性が悪いため、使いません。
従来のプレビュー手法が実現できない理由
従来のPreview ModeとmiroCMSを使うチュートリアルでは、以下の流れでプレビューをしていました。
- microCMSでプレビューボタンを押すと、APIルートに飛ぶ
- APIルートでPreview Modeを有効化し、
draftKey
をローカルストレージに保存 - Preview Modeが有効なら、
draftKey
を取り出して記事をフェッチ - 下書きが表示される
ただし、ドラフトモードではこの流れが使えません。
機能 | Preview Mode (Legacy) | Draft Mode |
---|---|---|
使えるver | 大昔 | 13.4~ |
Pages | ✅ | ✅ |
App Router | ❌ (使えない) | ✅ |
previewData |
✅ | ❌ (データの入れ物が存在しない) |
previewData
が使えない = draftKey
を保存できない ため、同じルートでプレビューするのは非常に難しいです。
なぜドキュメントではドラフトモードを使っているのか
const url = isEnabled
? 'https://draft.example.com'
: 'https://production.example.com';
ドキュメントでは、そもそもCMSのドメインが異なるという想定がされています。残念ながら、microCMSで同様の運用をする場合、別途BFFを挟む必要が出てきます。microCMSのBFFを運用している方はいるにはいますが、今回はそこまでしません。
BFFなしで実現する方法(非現実的)
方法があるにはありますが...
- 下書きの全取得ができるAPIキーを作成する
- プレビュー遷移先URLに任意のシークレットパラメータを付与する
- APIルートでシークレットを照合し、正しければDraft Modeを有効化
- 記事ページでは、Draft Modeが有効ならフェッチ先のクライアントを変更することで、違うAPIキーを使う
ただしこの方法には以下の問題点があり、解説していません。
- 余分にシークレットの管理が必要になり面倒
- そもそも遷移先URLには
draftKey
が付けられるのに、無駄
- そもそも遷移先URLには
- プレビューURLを第三者に共有すると、シークレットを変更しない限り、別の記事のプレビューも閲覧できてしまう(
draftKey
の存在理由がなくなる)- Pages Directoryのプレビューモードでは、ローカルストレージに保存された
draftKey
のおかげで、閲覧対象を制限できました。
- Pages Directoryのプレビューモードでは、ローカルストレージに保存された
draftKeyがあればドラフトモードは必要ない
そもそも「プレビューを押した人にだけ、下書きが表示される」という要件を満たせばいいのです。
searchParams is a Dynamic API whose values cannot be known ahead of time. Using it will opt the page into dynamic rendering at request time.
例えば、microCMSのクエリパラメータ ?draftKey={DRAFT_KEY}
を searchParams
で受け取ったルートは、ダイナミックモードに切り替わります。
更に、fetch
オプションに cache: 'no-store'
を指定すればキャッシュされないため、draftKey
が不変でも随時変更が反映されます。これでドラフトモードと同等の挙動が実現できます。
上記のことから、
-
/draft?draftKey={DRAFT_KEY}
というルートを下書き用に作り - キャッシュを無視して下書き記事を取得すれば
要件を満たすことがわかります。
環境
create-next-app@13.4.4
で検証。
1. 環境変数の定義
上記記事に従って、環境変数を定義してください。
2. microCMSとの通信部分の実装
npm i microcms-js-sdk
import { createClient } from 'microcms-js-sdk';
import { env } from '@/env.mjs';
export const microCmsClient = createClient({
serviceDomain: env.MICROCMS_SERVICE_DOMAIN,
apiKey: env.MICROCMS_API_KEY,
});
import { Metadata } from 'next';
import { MicroCMSListContent, MicroCMSQueries } from 'microcms-js-sdk';
import { microCmsClient } from './micro-cms';
const endpoint = 'article';
export interface Article extends MicroCMSListContent {
title: string;
body: string;
}
export async function getArticles(queries?: MicroCMSQueries) {
return await microCmsClient
.getList<Article>({
endpoint,
queries: {
fields: ['title'],
limit: 100,
...queries,
},
})
.catch((e) => {
console.error(e);
return null;
});
}
export async function getArticle(contentId: string, queries?: MicroCMSQueries) {
return await microCmsClient
.get<Article>({
endpoint,
contentId,
queries,
})
.catch((e) => {
console.error(e);
return null;
});
}
今回は例として article
というエンドポイントを設置しました。内容はサイト構造に合わせてください。
記事のプレビュー取得部分
// ...前略
/**
* 記事のプレビューを取得
* 結果は例外的にキャッシュされない
*/
export async function getArticleDraft(
contentId: string,
queries: MicroCMSQueries & { draftKey: string }
) {
return await microCmsClient
.get<Article>({
endpoint: 'article',
contentId,
queries,
// draftKeyが不変でも内容は変わるためキャッシュを無視
customRequestInit: { cache: 'no-store' },
})
.catch(() => {
return null;
});
}
上記の article.ts
に、キャッシュを無視して下書きを取得する 関数を追加します。
customRequestInit: { cache: 'no-store' },
/draft?draftKey=
が同じなら、Nextはレスポンスをキャッシュします。ただし、microCMSで続けて編集してもdraftKeyは変わりません。 そこで、キャッシュ無視オプションを使います。
{ draftKey, ...queries }: MicroCMSQueries & { draftKey: string }
また、引数の型に付加する形で draftKey
を必須にしています。
メタデータ生成部分
// ...前略
/** 記事ページのメタデータを生成 */
export async function generateArticleMetadata(
articleId: string,
draftKey?: string | string[]
): Promise<Metadata | void> {
let article: Article | null = null;
const isDraft = typeof draftKey === 'string';
if (isDraft) {
article = await getArticleDraft(articleId, { draftKey });
} else {
article = await getArticle(articleId);
}
if (article) {
const { title } = article;
return { title: isDraft ? `[プレビュー] ${title}` : title };
}
}
OG画像などもここで指定します。なお、本番とプレビューでタイトルを切り替えています。
3. コンポーネントとページの実装
記事詳細コンポーネント
import { Article } from '@/lib/article';
interface ArticleDetailProps {
article: Article;
}
export default async function ArticleDetail({ article }: ArticleDetailProps) {
return (
<div>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.body }} />
</div>
);
}
ここは適宜デザインしてください。
通常記事ページ
import { notFound } from 'next/navigation';
import {
generateArticleMetadata,
getArticle,
getArticles,
} from '@/lib/article';
import ArticleDetail from '@/components/ArticleDetail';
// ここはお好みで
export const revalidate = 600;
type Params = {
params: { articleId: string };
};
export async function generateStaticParams() {
const articles = await getArticles();
return articles
? articles.contents.map(({ id: articleId }) => ({
articleId,
}))
: [];
}
export async function generateMetadata({ params: { articleId } }: Params) {
return await generateArticleMetadata(articleId);
}
export default async function ArticlePage({ params: { articleId } }: Params) {
const article = await getArticle(articleId);
if (!article) {
notFound();
}
return (
<main>
<ArticleDetail article={article} />
</main>
);
}
通常記事ページでは、パスパラメータのみを使うため、静的に描画されます。 generateStaticParams
も忘れずにセットしてください。
下書きプレビューページの実装
クエリパラメータを使うため、通常記事と違うページを作ります。 /articles/[articleId]/draft
となります。
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { generateArticleMetadata, getArticleDraft } from '@/lib/article';
import ArticleDetail from '@/components/ArticleDetail';
type Params = {
params: { articleId: string };
searchParams: { draftKey: string | string[] };
};
export async function generateMetadata({
params: { articleId },
searchParams: { draftKey },
}: Params) {
// 下書き用メタデータを生成
return await generateArticleMetadata(articleId, draftKey);
}
export default async function ArticlePage({
params: { articleId },
searchParams: { draftKey },
}: Params) {
// 変更せずにプレビューすると空になる
if (typeof draftKey !== 'string' || draftKey === '') {
redirect(`/articles/${articleId}`);
}
// ここではキャッシュが無視される
const article = await getArticleDraft(articleId, { draftKey });
if (!article) {
notFound();
}
return (
<main>
<ArticleDetail article={article} />
{draftKey && (
<Link href={`/articles/${articleId}`}>プレビューを終了</Link>
)}
</main>
);
}
ここでは searchParams
によって動的レンダリングが行われます。また、下書きは専用の関数で取得することで、常に最新の状態にします。
なお、変更せずプレビューすると「下書きキーなし」の状態になることも忘れないでください。 ここでは公開ページに307 Temporary Redirectしています。
4. microCMSの設定変更
API設定→「画面プレビュー」の「遷移先URL」を以下のように設定します。
https://<Next.jsのドメイン>/articles/{CONTENT_ID}/draft?draftKey={DRAFT_KEY}
今まではAPIルートを指定していたかもしれませんが、microCMSの方式ならこれで十分です。
プレビューボタンを押して、変更が反映されることを確認してください。お疲れ様でした。
OG画像などがSNSでどう見えるかプレビューする
※このデモでは画像を設定していません
なお、generateArticleMetadata
に説明文やOG画像の設定を追加し、VercelのOGチェッカー等にURLを入れることで、アイキャッチ画像のSNS上の動作プレビューも可能になります。
Discussion