homeIcon

Next.js の Draft Mode を利用して microCMS のプレビュー表示を実装する

フロントエンド
2025.09.18
2025.09.18

はじめに

microCMSのようなヘッドレスCMSは見た目を持たないため、従来型のCMSとはプレビューの仕組みが根本的に異なります。
プレビューを実施するためには、フロントエンド側でCMSから下書き状態のコンテンツを取得し、レンダリングする必要があります。

そこで今回は、Next.js の Draft Mode を利用して、microCMS で管理している公開前の記事をプレビュー表示できるようにしていきたいと思います。
その他のプレビュー機能の設計パターンについては、以下の記事にまとまっていますので、気になる方は見てみてください。

microCMSにおけるプレビュー機能の設計パターンについて

microCMSを活用したプレビュー機能の実装方法を解説。本番環境やプレビュー環境を利用した設計パターン、CSR・SSRの活用例、Next.jsのDraft Modeを採用した具体的な実装方法を紹介します。

blog.microcms.io

OGP Image

Next.js の Draft Mode とは?

Next.js の Draft Mode (旧 Preview Mode)は、CMS でまだ公開していない下書き内容を、本番と同じ URL・見た目のまま即座に確認できる機能です。
専用のエンドポイントに秘密トークン付きでアクセスすると、ブラウザにドラフト用のクッキーが設定され、その間は通常の SSG/ISR キャッシュをバイパスして毎回サーバ側で最新データを取得・描画します。
これにより「記事を保存 → プレビューを開く → 本番と同じ挙動で確認」というフローが実現し、ビルドを待たずに内容やレイアウトをチェックできます。
ON/OFF はユーザー(クッキー)単位で切り替え、終了用のエンドポイントにアクセスすれば元に戻ります。

Guides: Draft Mode | Next.js

Next.js has draft mode to toggle between static and dynamic pages. You can learn how it works with App Router here.

nextjs.org

OGP Image

処理の流れ

Next.js の Draft Mode について上記のように説明しましたが、今回は本番環境が SG による静的サイトのため、確認環境をローカルとして実装を行います。

下書き記事のプレビュー表示

下書きの記事を表示させるまでの流れは以下になります。

画像1
  1. ユーザー(コンテンツ管理者)がプレビューリンクをクリック。
  2. microCMSからNext.jsにプレビューのリクエストを送信(クエリパラメータには記事のcontentId, draftKeyを含む)
  3. Draft Mode 有効化 & リダイレクト(クッキーにcontentId, draftKeyを保存)
  4. 保存されたcontentId, draftKeyを使用して、下書きコンテンツを取得。
  5. 取得したコンテンツを表示。

プレビュー表示の終了時

プレビューを終了する際には、関連するクッキーを削除するAPIを呼び出し、通常のページにリダイレクトします。
これにより、Draft Mode を無効化し通常のコンテンツ閲覧状態に戻すことができます。

画像2

microCMS側の設定

まずは、microCMSの管理画面でプレビュー用のURLを設定します。
対象APIの「API設定」>「画面プレビュー」に移動し、以下のようにURLを設定します。

http://localhost:3000/api/preview?type=blog&secret=miyazaki-profile-secret&id={CONTENT_ID}&draftKey={DRAFT_KEY}
画像3
  • {CONTENT_ID}{DRAFT_KEY} は、microCMSが自動でコンテンツIDと下書きキーに置換してくれます。
  • &type=〇〇 の部分ですが、ここにAPIのendpoint名(例: portfolio, blog など)をハードコードしておくことで、Next側でどのコンテンツのプレビューなのかを判別できるようになります。
  • &secret=miyazaki-profile-secretは、不正アクセスを防ぐためのシークレットです。

Next.js側の実装

プレビュー表示APIの作成

まず、microCMS のプレビューボタンから叩かれる GET API を作成します。
大まかな処理内容としては、リクエストを検証し contentIddraftKey をクッキーに保存して、下書きページにリダイレクトをします。
このプロセスを通じて、 Draft Mode が有効化された状態で記事のパスにリダイレクトされ、mcms_draftKey_〇〇mcms_contentId_〇〇というcookieがブラウザに付与されます。
これにより、Next.jsのフロントエンド側で Draft Mode が有効であることを検知し、下書き状態のコンテンツを取得して表示することが可能になります。

また、APIの冒頭に「静的ビルド時は 404 の固定レスポンスを返す」ように設定していますが、これは 静的サイトとして出力しているときは、このプレビューAPIを“使えないもの”として明示的に止める ための保護です。
今回の本番構成は AWS S3/CloudFront を用いた静的配信を行っており、これを設定しないとビルド時にエラーが出るため、静的ビルド時はわざと固定レスポンスを返すようにしています。

/src/app/api/preview/route.ts
/**
 * Preview API (GET) — microCMS プレビュー開始エンドポイント
 */
 
import { NextRequest } from "next/server";
import type { ContentType } from "@/config/preview-cookies";
 
export const revalidate = 60;
 
export async function GET(req: NextRequest) {
  // 静的ビルド(S3/CloudFront用)では固定レスポンスを返す(=静的化対応)
  if (process.env.STATIC_EXPORT === "true") {
    return new Response("Draft preview is disabled on static builds.", {
      status: 404,
      headers: { "Content-Type": "text/plain" },
    });
  }
 
  // 動的 import
  const { draftMode, cookies } = await import("next/headers");
  const { redirect } = await import("next/navigation");
  const { getBlogById, getPortfolioById } = await import("@/libs/microcms");
  const { cookieNames, isContentType } = await import(
    "@/config/preview-cookies"
  );
 
  // type(blog/portfolio)に応じたリゾルバ
  const resolver = {
    blog: {
      getById: getBlogById,
      toPath: (slug: string) => `/blog/${slug}`,
    },
    portfolio: {
      getById: getPortfolioById,
      toPath: (slug: string) => `/portfolio/${slug}`,
    },
  } as const;
 
  // クエリからパラメータを取り出しつつ、余計な空白を除去
  const { searchParams } = new URL(req.url);
  const secret = (searchParams.get("secret") ?? "").trim();
  const rawType = (searchParams.get("type") ?? "").trim();
  const contentId = (searchParams.get("id") ?? "").trim();
  const draftKey = (searchParams.get("draftKey") ?? "").trim();
 
  // バリデーション
  if (secret !== process.env.MICROCMS_PREVIEW_SECRET)
    return new Response("Invalid token", { status: 401 });
  if (!contentId || !isContentType(rawType))
    return new Response("Invalid params", { status: 400 });
  const type: ContentType = rawType;
 
  // 下書き記事を取得して slug を解決
  const article = await resolver[type].getById(contentId, draftKey);
  const slug = article?.articleSlug;
  if (!slug) return new Response("Slug not found", { status: 404 });
 
  // Draft Mode を ON
  const d = await draftMode();
  d.enable();
 
  // Cookie セット
  const cookieStore = await cookies();
  const names = cookieNames(type);
  const path = resolver[type].toPath(slug);
 
  // draftKey
  if (draftKey) {
    cookieStore.set({
      name: names.draftKey,
      value: draftKey,
      httpOnly: true,
      secure: false,
      sameSite: "lax",
      maxAge: 60 * 30,
      path: path,
    });
  }
 
  // contentId
  cookieStore.set({
    name: names.contentId,
    value: contentId,
    httpOnly: true,
    secure: false,
    sameSite: "lax",
    maxAge: 60 * 30,
    path: path,
  });
 
  // 対象ページにリダイレクト(/blog/[slug] or /portfolio/[slug])
  redirect(path);
}

プレビュー表示処理

プレビューを表示するページでは、静的ビルド or プレビュー表示かで場合分けを行い、プレビューを表示する場合であれば Draft Mode を有効にして下書きの取得を行います。
上記のAPIにより記事ページにリダイレクトされた後、クッキーに保存されている contentIddraftKey を用いて下書きコンテンツを取得し、表示します。

また問題点として、Cookieを利用して制御しているため、下書きコンテンツを閲覧しているのか公開コンテンツを閲覧しているかが、わかりにくい点が挙げられます。
その対処として、下書き中の場合にのみ特定のコンポーネントを出力するような処理を加える工夫をしています。

▼ 記事取得の関数

/src/libs/microcms.ts
...
 
// contentId, draftKey を使用し、下書き記事を取得(プレビュー用)
export async function getPortfolioById(
  contentId: string,
  draftKey: string
): Promise<PortfolioDetailProps | null> {
  const data = await client.getListDetail<PortfolioDetailProps>({
    endpoint: "portfolio",
    contentId,
    queries: {
      draftKey: draftKey || undefined,
    },
    customRequestInit: {
      cache: "no-store" as const,
    },
  });
 
  return data ?? null;
}
 
...

▼ ページ表示処理

/src/app/(PortfolioAndBlog)/blog/[slug]/page.tsx
...
 
export const revalidate = 60;
 
// 静的ビルド(export)の判定フラグ
const isStaticBuild = Boolean(process.env.STATIC_EXPORT === "true");
 
type PageProps = {
  params: Promise<{ slug: string }>;
};
 
...
 
/**
 * ページ本体
 * - 開発時:Draft Mode を考慮して“下書き本文”を優先して取得
 * - 静的ビルド時:公開データのみを取得
 */
export default async function BlogContentPage({ params }: PageProps) {
  const { slug } = await params;
 
  // 表示データ本体 & プレビューバナー表示可否
  let blogContentData:
    | Awaited<ReturnType<typeof getBlogBySlug>>
    | Awaited<ReturnType<typeof getBlogById>>
    | null = null;
  let showBanner = false;
 
  if (!isStaticBuild) {
    // 動的 import
    const { draftMode, cookies } = await import("next/headers");
 
    // Draft Mode を ON
    const d = await draftMode();
    const isEnabled = d.isEnabled;
 
    // Cookie名の取得
    const names = cookieNames("blog");
    const cookieStore = await cookies();
 
    // プレビューで付与した Cookie を読む(存在しなければ undefined)
    const draftKey = isEnabled
      ? cookieStore.get(names.draftKey)?.value
      : undefined;
    const contentId = isEnabled
      ? cookieStore.get(names.contentId)?.value
      : undefined;
 
    // Draftモードで draftKey と contentId が揃っていれば下書きを取得
    if (isEnabled && draftKey && contentId) {
      const draft = await getBlogById(contentId, draftKey);
      // 別記事の Cookie が残っている可能性を考慮し、slug 一致時のみ採用
      if (draft?.articleSlug === slug) {
        blogContentData = draft;
        showBanner = true;
      }
    } else {
      // プレビュー無効/不足時は公開本文にフォールバック
      blogContentData = await getBlogBySlug(slug);
    }
  } else {
    // 静的ビルド:cookies/draftMode には触れず、公開本文を取得
    blogContentData = await getBlogBySlug(slug);
  }
 
  // データが無ければ 404
  if (!blogContentData) return notFound();
 
  ...
 
  return (
    <>
      {/* プレビュー時のみ上部にバナーを表示 */}
      {showBanner && <PreviewBanner type="blog" />}
 
      <BlogClientWrapper blogContent={blogContentData}>
        ...
      </BlogClientWrapper>
    </>
  );
}

▼ プレビュー時のみ表示されるコンポーネント

/src/components/PortfolioAndBlog/PreviewBanner/index.tsx
...
 
export default function PreviewBanner({ type }: PreviewBannerProps) {
  const disableHref = `/api/draft-disable?type=${type}`;
 
  return (
    <div className={style.container}>
      プレビュー表示中
      <Link href={disableHref} className={style.link}>
        解除して一覧へ
      </Link>
    </div>
  );
}

プレビュー表示の終了

プレビューの終了時には、上記のプレビュー中に表示されるコンポーネントの「解除」ボタンを押下すると実行されるAPIを実行します。
このAPIは、Draft Mode を解除して、関連するクッキーを削除した後に、対応する一覧ページにリダイレクトする内容となっています。
これについても表示APIと同様に、静的ビルド時は 404 の固定レスポンスを返すように設定しています。

/src/app/api/draft-disable/route.ts
/**
 * Draft Disable API (GET) — プレビュー終了エンドポイント
 */
 
import { NextRequest } from "next/server";
import type { ContentType } from "@/config/preview-cookies";
 
export const revalidate = 60;
 
export async function GET(req: NextRequest) {
  // 静的ビルド(S3/CloudFront用)では固定レスポンスを返す(=静的化対応)
  if (process.env.STATIC_EXPORT === "true") {
    return new Response("Draft preview is disabled on static builds.", {
      status: 404,
      headers: { "Content-Type": "text/plain" },
    });
  }
 
  // 動的 import
  const { draftMode, cookies } = await import("next/headers");
  const { redirect } = await import("next/navigation");
  const { cookieNames, isContentType } = await import(
    "@/config/preview-cookies"
  );
 
  // 一覧ページへの戻り先(リダイレクト先)を type ごとに定義
  const listPath: Record<ContentType, string> = {
    blog: "/blog",
    portfolio: "/portfolio",
  };
 
  // クエリから type を取得
  const { searchParams } = new URL(req.url);
  const rawType = (searchParams.get("type") ?? "").trim();
 
  // Draft Mode を OFF
  const d = await draftMode();
  d.disable();
 
  // Cookie の削除
  const cookieStore = await cookies();
 
  if (isContentType(rawType)) {
    // 指定タイプだけ削除(/blog or /portfolio の一覧に戻す)
    const names = cookieNames(rawType);
    cookieStore.delete(names.draftKey);
    cookieStore.delete(names.contentId);
    redirect(listPath[rawType]);
  } else {
    // type が不正なら全タイプ分まとめて削除し、トップへ戻す
    (Object.keys(listPath) as (keyof typeof listPath)[]).forEach((t) => {
      const names = cookieNames(t);
      cookieStore.delete(names.draftKey);
      cookieStore.delete(names.contentId);
    });
    redirect("/");
  }
}

動作確認

では実際に、下書きの記事を取得し、プレビュー表示ができるか確認します。
ローカルでNext.jsの開発サーバーを立ち上げ、microCMS記事ページの以下リンクよりプレビューを表示します。

画像4

無事に、下書き中の記事をプレビュー表示することができました!

画像5

「解除」リンクを押下するとドラフトモードが解除され、対応する一覧ページに戻ります。
この時、下書きの記事は表示されていません。

画像6

まとめ

今回は、microCMSとNext.jsの Draft Mode を使用して、下書き状態の記事をプレビュー表示する機能を実装しました。
これでよりサイト運営が楽になったので、今後はたくさんの記事を書いていこうと思います。
この記事が誰かの役に立てば嬉しいです。

参考

画面プレビュー

画面プレビューは、管理画面から登録したコンテンツを、公開前にウェブサイトにて確認するための機能です。プレビューの仕組み前提として、microCMSはヘッドレスCMSと呼ばれる見た目を持たないCMSになるため、従来型のCMSとはプレビューの仕組みが根本的に異なります。プレビューを実施するためには、フロントエンド側でmicroCMSから下書き状態のコンテンツを取得し、レンダリングする仕組みを実装する必...

document.microcms.io

OGP Image

Next.jsのPreview Modeを使ってmicroCMSのプレビュー機能を実装してみた

No description available.

zenn.dev

OGP Image

【Nuxt3(SSG) + microCMS】プレビュー機能の実装 - Qiita

はじめに microCMSのプレビュー機能について、意外と参考記事がなかったのと、忘れてしまいがちな機能だと思ったので記事を書くことにしました。 よく考えたらヘッドレスCMSはバックエンドとフロントエンドが分離しているので、管理画面からプレビューができないのは当たり前です...

qiita.com

OGP Image

Next.js(App Router)とmicroCMSでプレビュー画面を作った | blog.yutaaoki.jp

メディアサイトを作成中で、公開前に下書きの段階で確認するためにプレビュー画面が必要になりました。その手順を紹介します。前提今回のサイトは、以下のような構成で作成しています。Next.js (14.1.4)の App RouterCMSはmi

blog.yutaaoki.jp

OGP Image
Share