📘

App RouterでmicroCMSのプレビューを表示する方法 [Next.js]

2023/05/05に公開

App RouterでmicroCMSの「プレビュー」機能を実装します。

ドラフトモードとは

その前に、今までのプレビュー実装方法を思い出しましょう。「Preview Mode」を使っていたはずです。

https://nextjs.org/docs/app/api-reference/file-conventions/page

Next.js v13.4では、Preview Modeの後継「Draft Mode(ドラフトモード)」 が正式実装されました。

前提知識として、App Routerのレンダリングには

  • 静的(スタティック)描画モード(デフォルト)
  • 動的(ダイナミック)描画モード

があります。前者は下書きのような場面に適しません。

https://nextjs.org/docs/app/building-your-application/configuring/draft-mode

Draft Modeが有効な間は、ルートが動的描画モードになり、プレビューの実装を助けます。

なお、従来のプレビューモードは「レガシー」扱いとなっており、App Routerでは使えません。

今回実装する手法の背景を解説

...が、ドラフトモードはmicroCMSと相性が悪いため、使いません。

従来のプレビュー手法が実現できない理由

従来のPreview ModeとmiroCMSを使うチュートリアルでは、以下の流れでプレビューをしていました。

  1. microCMSでプレビューボタンを押すと、APIルートに飛ぶ
  2. APIルートでPreview Modeを有効化し、draftKeyをローカルストレージに保存
  3. Preview Modeが有効なら、draftKeyを取り出して記事をフェッチ
  4. 下書きが表示される

ただし、ドラフトモードではこの流れが使えません。

機能 Preview Mode (Legacy) Draft Mode
使えるver 大昔 13.4~
Pages
App Router ❌ (使えない)
previewData ❌ (データの入れ物が存在しない)

previewDataが使えない = draftKeyを保存できない ため、同じルートでプレビューするのは非常に難しいです。

なぜドキュメントではドラフトモードを使っているのか

https://nextjs.org/docs/app/building-your-application/configuring/draft-mode

  const url = isEnabled
    ? 'https://draft.example.com'
    : 'https://production.example.com';

ドキュメントでは、そもそもCMSのドメインが異なるという想定がされています。残念ながら、microCMSで同様の運用をする場合、別途BFFを挟む必要が出てきます。microCMSのBFFを運用している方はいるにはいますが、今回はそこまでしません。

BFFなしで実現する方法(非現実的)

方法があるにはありますが...

  1. 下書きの全取得ができるAPIキーを作成する
  2. プレビュー遷移先URLに任意のシークレットパラメータを付与する
  3. APIルートでシークレットを照合し、正しければDraft Modeを有効化
  4. 記事ページでは、Draft Modeが有効ならフェッチ先のクライアントを変更することで、違うAPIキーを使う

ただしこの方法には以下の問題点があり、解説していません。

  • 余分にシークレットの管理が必要になり面倒
    • そもそも遷移先URLにはdraftKeyが付けられるのに、無駄
  • プレビューURLを第三者に共有すると、シークレットを変更しない限り、別の記事のプレビューも閲覧できてしまう(draftKeyの存在理由がなくなる)
    • Pages Directoryのプレビューモードでは、ローカルストレージに保存されたdraftKeyのおかげで、閲覧対象を制限できました。

draftKeyがあればドラフトモードは必要ない

そもそも「プレビューを押した人にだけ、下書きが表示される」という要件を満たせばいいのです。

https://nextjs.org/docs/app/api-reference/file-conventions/page

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で受け取ったルートは、ダイナミックモードに切り替わります。

https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic

更に、fetch オプションに cache: 'no-store' を指定すればキャッシュされないため、draftKeyが不変でも随時変更が反映されます。これでドラフトモードと同等の挙動が実現できます。

上記のことから、

  1. /draft?draftKey={DRAFT_KEY} というルートを下書き用に作り
  2. キャッシュを無視して下書き記事を取得すれば

要件を満たすことがわかります。

環境

create-next-app@13.4.4 で検証。

1. 環境変数の定義

https://zenn.dev/temasaguru/articles/406189a014b656

上記記事に従って、環境変数を定義してください。

2. microCMSとの通信部分の実装

npm i microcms-js-sdk
@/lib/micro-cms.ts
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,
});

@/lib/article.ts
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 というエンドポイントを設置しました。内容はサイト構造に合わせてください。

記事のプレビュー取得部分

@/lib/article.ts
// ...前略

/**
 * 記事のプレビューを取得
 * 結果は例外的にキャッシュされない
 */
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 を必須にしています。

メタデータ生成部分

@/lib/article.ts
// ...前略

/** 記事ページのメタデータを生成 */
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. コンポーネントとページの実装

記事詳細コンポーネント

@/components/ArticleDetail.tsx
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>
  );
}

ここは適宜デザインしてください。

通常記事ページ

@/app/articles/[articleId]/page.tsx
import { notFound } from 'next/navigation';
import {
  generateArticleMetadata,
  getArticle,
  getArticles,
} from '@/lib/article';
import ArticleDetail from '@/components/ArticleDetail';

// ここはお好みで
export const revalidate = 600;

type Props = {
  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 } }: Props) {
  return await generateArticleMetadata(articleId);
}

export default async function ArticlePage({ params: { articleId } }: Props) {
  const article = await getArticle(articleId);
  if (!article) {
    notFound();
  }
  return (
    <main>
      <ArticleDetail article={article} />
    </main>
  );
}

通常記事ページでは、パスパラメータのみを使うため、静的に描画されます。 generateStaticParams も忘れずにセットしてください。

下書きプレビューページの実装

クエリパラメータを使うため、通常記事と違うページを作ります。 /articles/[articleId]/draft となります。

@/app/articles/[articleId]/draft/page.tsx
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { generateArticleMetadata, getArticleDraft } from '@/lib/article';
import ArticleDetail from '@/components/ArticleDetail';

type Props = {
  params: { articleId: string };
  searchParams: { draftKey: string | string[] };
};

export async function generateMetadata({
  params: { articleId },
  searchParams: { draftKey },
}: Props) {
  // 下書き用メタデータを生成
  return await generateArticleMetadata(articleId, draftKey);
}

export default async function ArticlePage({
  params: { articleId },
  searchParams: { draftKey },
}: Props) {
  // 変更せずにプレビューすると空になる
  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でどう見えるかプレビューする

Vercelのチェッカー画面
※このデモでは画像を設定していません

なお、generateArticleMetadata に説明文やOG画像の設定を追加し、VercelのOGチェッカー等にURLを入れることで、アイキャッチ画像のSNS上の動作プレビューも可能になります。

Discussion