ヘッドレス CMS で記事を書いているとき、まだ公開していない下書きの状態を、実際のページのレイアウトで確認したいことがあります。Next.js(App Router)の Draft Mode(下書きモード) は、静的生成された本番ページの代わりに、下書きデータを取り込んだ動的なプレビューを表示するための機能です。この記事では、draftMode() で下書きモードの有効・無効を判定してデータ取得を分岐する方法、Route Handler でモードを切り替える実装、CMS のプレビューボタンから叩くエンドポイントの作り方、そして秘密トークンで保護するセキュリティ対策までを、Next.js 15 で draftMode() が非同期になった点も含めて解説します。
目次
Draft Mode は何のための機能か
App Router では、ブログ記事のようなページはビルド時に静的生成(プリレンダリング)しておくのが一般的です。静的なページは高速で配信できますが、その内容はビルド時点の「公開済みデータ」で固定されます。ここで困るのが、CMS 側で書いている下書き(まだ公開していない記事)を、本番と同じ見た目で確認したいケースです。静的ページのままでは下書きは反映されません。
Draft Mode は、この問題を解決するための仕組みです。有効にすると、対象のページが静的な配信からリクエストごとの動的レンダリング(オンデマンドレンダリング)に切り替わり、その中で「公開済みデータ」ではなく「下書きデータ」を取得するように分岐できます。これによって、閲覧者にはこれまでどおり静的な本番ページを見せつつ、編集者だけが下書きのプレビューを見られる、という状態を作れます。
draftMode() で有効・無効を判定する
Draft Mode の状態は、next/headers からインポートする draftMode() で読み取ります。返ってくるオブジェクトの isEnabled プロパティが、下書きモードが有効かどうかを表す真偽値です。Next.js 15 からは draftMode() が非同期関数になったため、呼び出すときは await を付けます。
import { draftMode } from 'next/headers';
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Next.js 15 では draftMode() は非同期なので await する
const { isEnabled } = await draftMode();
// 有効なら下書きデータ、無効なら公開済みデータを取得
const post = isEnabled
? await getDraftPost(slug)
: await getPublishedPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.body}</div>
</article>
);
}
ポイントは、isEnabled の値によってデータ取得の関数を切り替えている点です。下書きモードが有効なとき(編集者がプレビューしているとき)だけ getDraftPost() のような下書き用の取得処理を呼び、通常の閲覧では公開済みデータを取得します。getDraftPost() / getPublishedPost() の中身は、使っているヘッドレス CMS の API に合わせて実装します(多くの CMS が、下書きも含めて取得するためのプレビュー用トークンやパラメータを用意しています)。
なお、draftMode() をページ内で呼び出すと、そのページは動的レンダリングに切り替わります。これは Draft Mode がリクエストのクッキーを読む必要があるためで、リクエストごとにサーバーで描画されるようになります。閲覧者向けの通常アクセスでは、後述する仕組みによって下書きモードは無効のままなので、実運用上の静的配信の恩恵は保たれます。
Route Handler でモードを切り替える
下書きモードそのものを ON にするには、draftMode() が返すオブジェクトの enable() を呼びます。これは Route Handler(app/**/route.ts)の中で実行します。enable() を呼ぶと、ブラウザに Draft Mode 用のクッキーがセットされ、以降そのブラウザからのアクセスでは isEnabled が true になります。まずは仕組みが分かる最小の例を見てみましょう。
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
// Draft Mode を有効化する(クッキーがセットされる)
const draft = await draftMode();
draft.enable();
// 確認したいページへリダイレクト
redirect('/blog/hello-world');
}
このエンドポイントにアクセスすると Draft Mode が有効になり、リダイレクト先の記事ページで先ほどの isEnabled が true になって、下書きデータが表示されます。draftMode() の各メソッドの役割を整理しておきます。
| メンバー | 意味 |
|---|---|
isEnabled | Draft Mode が有効かどうかを表す真偽値。ページ側でデータ取得を分岐するのに使う |
enable() | Draft Mode を有効化する。Draft Mode 用のクッキーをセットする(Route Handler で呼ぶ) |
disable() | Draft Mode を無効化する。セットしたクッキーを削除して通常表示に戻す |
プレビューを終えて通常表示に戻したいときは、disable() を呼ぶ Route Handler を別に用意します。disable() はクッキーを削除するので、次のアクセスからは isEnabled が false になり、公開済みデータの静的ページに戻ります。
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET() {
// Draft Mode を無効化してクッキーを削除する
const draft = await draftMode();
draft.disable();
redirect('/blog/hello-world');
}
秘密トークンでエンドポイントを保護する
先ほどの最小例のように、誰でもアクセスするだけで下書きモードを有効化できてしまうと、公開前の内容を第三者に見られる恐れがあります。そこで実運用では、秘密トークン(シークレット)でエンドポイントを保護します。CMS 側にだけ知らせておいたトークンを URL のクエリに付けてもらい、Route Handler ではそれが一致するかを検証してから enable() を呼びます。
あわせて、プレビューするページの slug もクエリで受け取り、その記事が本当に存在するかを CMS に問い合わせてから遷移させると、より安全です。トークンは環境変数に置いて、コードに直接書かないようにします。
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
// 環境変数のトークンと照合する
if (secret !== process.env.DRAFT_SECRET || !slug) {
return new Response('Invalid token', { status: 401 });
}
// 対象の記事が存在するか CMS に確認する
const post = await getDraftPost(slug);
if (!post) {
return new Response('Not found', { status: 404 });
}
// 検証を通ったときだけ Draft Mode を有効化する
const draft = await draftMode();
draft.enable();
// 実在が確認できた slug へ遷移する(オープンリダイレクト対策)
redirect(`/blog/${post.slug}`);
}
ここでは、クエリの secret が環境変数 DRAFT_SECRET と一致しない場合に 401 を返して処理を止めています。リダイレクト先には、クエリの値をそのまま使うのではなく、CMS で存在を確認した post.slug を使っています。これは、外部から渡された文字列を無検証でリダイレクト先にすると、任意の URL へ飛ばされる余地が生まれるためです。
CMS のプレビューボタンから呼び出す
実際の運用では、この /api/draft エンドポイントを、ヘッドレス CMS の管理画面にある「プレビュー」ボタンのリンク先として設定します。多くの CMS は、プレビュー用の URL をテンプレートとして登録でき、記事の slug などを差し込めるようになっています。たとえば次のような URL を組み立てて開かせます。
https://example.com/api/draft?secret=<トークン>&slug=hello-world
編集者が CMS でこのボタンを押すと、トークン付きで /api/draft にアクセスし、Draft Mode が有効化されたうえで対象の記事ページへリダイレクトされます。そのブラウザではクッキーが効いている間、isEnabled が true になり続けるので、下書きを編集しては再読み込みして確認する、という作業がそのまま行えます。プレビューを終えるときは、先ほどの /api/disable-draft にアクセスすれば通常表示へ戻せます。
プレビューが表示されないとき
下書きが反映されない場合、まず確認したいのは draftMode() を await しているかどうかです。Next.js 15 では非同期になったため、await を忘れると isEnabled が期待どおりに読めず、分岐が正しく働きません。const { isEnabled } = await draftMode(); の形になっているかを見直してください。
次に多いのが、enable() を Server Component やページの描画中に呼ぼうとしているケースです。enable() と disable() はクッキーを書き込む操作なので、Route Handler や Server Action のようにレスポンスへの書き込みが許される場所で呼ぶ必要があります。ページ本体では isEnabled を読むだけにとどめ、モードの切り替えは専用のエンドポイントに分けるのが基本の形です。
クッキーはブラウザ単位で保持されるため、別のブラウザやシークレットウィンドウで開くと下書きは見えません。逆にプレビューを終えたつもりでも disable() を呼んでいなければ、そのブラウザでは下書きモードが残り続けます。意図せず下書きが表示され続けるときは、無効化のエンドポイントにアクセスしてクッキーを消してください。
まとめ
Draft Mode は、静的生成された本番ページを保ちながら、編集者だけがヘッドレス CMS の下書きを本番同様のレイアウトでプレビューするための機能です。ページ側では next/headers の draftMode() を await して isEnabled を読み、有効なら下書きデータ、無効なら公開済みデータを取得するように分岐します。モードの切り替えは Route Handler で行い、enable() で有効化、disable() で無効化します。enable() を呼んだページはリクエストごとの動的レンダリングになるため、下書きの最新状態を反映できます。エンドポイントは秘密トークンで保護し、リダイレクト先は検証済みの値を使うことで、公開前の内容の漏洩や不正なリダイレクトを防ぎます。Next.js 15 で draftMode() が非同期になった点だけ押さえておけば、既存の App Router に無理なく組み込めるはずです。