0 views

Next.js上のPayload CMSでリアルタイムpreviewを組み立てる

payload-cms-live-preview

headless CMSの編集画面はコンテンツの入力フォームであって、公開ページのデザインでは描画されない。書きながら見た目を確かめるには、保存して公開側を別タブで開き直す往復になる。Payload CMSにはlive previewという機能があり、管理画面の編集フォームの右側に公開ページをiframeで埋め込み、編集内容をその場で映せる。公開サイト側のfrontendもNext.jsで組む前提。

draftがiframeに映るまで

Payloadの管理画面は編集中のドキュメントごとにlivePreview.url callbackを呼び、preview URLをiframeのsrcに入れる。iframeはそのURLを開き、frontendのpreview APIが認可を確認したうえで記事ページへredirectする。記事ページはPayloadのREST APIをdraft=trueで叩き、未公開版を取ってiframeに描画する。

Payloadと公開サイトのfrontendは別のNext.jsプロジェクトだ。以降のコード例は、payload.config.tsだけがPayload側、それ以外のpages/lib/はfrontend側にある。

sequenceDiagram participant Admin as Payload管理画面 participant iframe participant API as preview API participant Page as 記事ページ participant REST as Payload REST Admin->>iframe: livePreview.url callback が preview URL を返す iframe->>API: GET /api/preview?secret&slug API->>iframe: Set-Cookie __prerender_bypass と 307 redirect iframe->>Page: GET /posts/slug (Cookie 付き) Page->>REST: GET /api/posts?slug&draft=true REST->>Page: 最新の draft version Page->>iframe: draft 版の HTML

PayloadのlivePreview.url callbackでpreview URLを組み立てる

configのadmin.livePreviewに設定を書く。全体設定のほか、collection単位やglobal単位でも同じ形で上書きできる。

中心はurlプロパティで、関数を渡すことができる。関数にはdata、locale、collectionConfig、globalConfig、reqの5つが渡り、戻り値がiframeに表示するURLになる。nullかundefinedを返すと、そのドキュメントではiframe自体を表示しない。

// payload.config.ts (Payload側)
import { buildConfig } from "payload";

const frontendOrigin =
  process.env.FRONTEND_ORIGIN ?? "https://front.other-site.test:3002";
const previewSecret = process.env.PREVIEW_SECRET ?? "preview-secret-for-poc";

export default buildConfig({
  admin: {
    livePreview: {
      // ツールバーで切り替える iframe の画面幅プリセット
      breakpoints: [
        { label: "Desktop", name: "desktop", width: 1280, height: 720 },
        { label: "Mobile", name: "mobile", width: 390, height: 844 },
      ],
      // 編集中ドキュメントに対して iframe に出す URL を返す
      url: ({ data, collectionConfig }) => {
        // posts 以外の collection は preview を出さない
        if (collectionConfig?.slug !== "posts") {
          return null;
        }

        // 保存前で slug が空なら iframe を出さない
        const slug = typeof data?.slug === "string" ? data.slug : "";
        if (!slug) {
          return null;
        }

        // secret と slug を frontend の preview API に渡す
        // cookieMode=none は Set-Cookie の書き換えを有効にする合図
        const params = new URLSearchParams({
          cookieMode: "none",
          secret: previewSecret,
          slug,
        });

        return `${frontendOrigin}/api/preview?${params.toString()}`;
      },
    },
  },
  // collections / db / editor は通常どおり定義する
});

postsコレクションのdraftを、frontendのpreview APIへ流している。URLにはslugと、URLを知っている人だけpreviewを開けるようにするsecretを埋める。cookieModeはpreview API側の動作切替パラメータで、Set-Cookieの属性書き換えを有効にする値を渡す。

preview対象を増やすときはこの関数に分岐を足し、出したくないcollectionはnullで落とす。breakpointsはツールバーで切り替える画面幅プリセットだ。

iframeの中の描画はpostMessageで切り替わる

iframeに公開ページを出しただけでは、編集内容は反映されない。Payloadの管理画面とiframe内のfrontendはwindow.postMessageで通信していて、フォームの状態が変わるたびに管理画面側からiframeへイベントが飛ぶ。frontend側がこのイベントをどう受けるかで、実装は2系統に分かれる。

1つはserver-side approachで、frontendにRefreshRouteOnSaveを置いて保存イベントでrouter.refreshを呼ぶ。サーバ側の描画コードがdraftを取り直すので、frontendは「draftを取得して描画する」処理を1本持てば済む。公式docsは、React Server Componentsを使えるframeworkにはこちらを推奨する。

もう1つはclient-side approachで、iframe内のコンポーネントがuseLivePreviewフックでpostMessageを直接購読する。フォームの状態がそのままフックから返り、保存を待たず入力のたびに画面が変わる。代わりにdepthオプション(relationを何階層まで展開するか)を初期取得と揃え、フォーム値からrelationを再解決するコードをクライアントに持つ。

観点 server-side client-side
画面が変わるタイミング 保存/autosave/publishの時点 入力のたび
draftデータの出どころ サーバ側の再取得 postMessageで届くフォーム状態
実装の中心 preview経路の認可とdraft取得 フック購読とdepth管理

公開ページ側のNext.jsのdraftModeとCookie

Next.jsで組んだ公開ページは、通常はビルド時に静的生成された公開版を返す。編集中のdraftを見せるには、この静的生成をリクエストごとに一時停止して、動的取得に切り替える必要がある。そのために用意されているのがdraftModeだ。

draftModeが有効なリクエストでは、getStaticPropsgetServerSidePropsが毎回サーバ側で実行され、内部のfetchもキャッシュを迂回する。有効化の合図はCookieで、Next.jsは__prerender_bypassという名前で発行する。Cookieを持たないリクエストは今までどおり静的キャッシュから公開版が返り、Cookieを持つリクエストだけがdraftを取れる経路に入る。

preview APIがCookieを発行する

Payloadから直接記事ページのURLを返さず、間にpreview APIを挟むのは、draftMode CookieがAPI routeの中でしか発行できないからだ。setDraftModeはresponseにSet-Cookieヘッダを付ける操作で、静的生成される記事ページのgetStaticProps側からは呼べない。preview APIがCookieを発行してから記事ページへredirectすると、遷移先はCookie付きのリクエストとして届き、そこで初めてdraftModeが有効になる。secretの検証とslugの実在確認も、毎リクエスト走るAPI routeでまとめてやる。

Pages RouterではresponseのsetDraftModeを呼ぶとSet-Cookieヘッダが付く。判定側はgetStaticPropsgetServerSidePropsに渡るcontextのdraftModeを見る。App RouterではdraftMode関数が返すオブジェクトのenableとisEnabledで同じ扱いになる。

// pages/api/preview.ts (frontend側)
import type { NextApiRequest, NextApiResponse } from "next";

import { isValidPreviewSecret } from "../../lib/secret";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  // secret を定数時間比較する。合わなければ 401 を返してここで終える
  if (!isValidPreviewSecret(req.query.secret)) {
    res.status(401).json({ error: "invalid secret" });
    return;
  }

  const slug = typeof req.query.slug === "string" ? req.query.slug : "";
  if (!slug) {
    res.status(400).json({ error: "missing slug" });
    return;
  }

  // __prerender_bypass Cookie を発行する
  res.setDraftMode({ enable: true });

  // cross-site iframe から来たときは Set-Cookie を SameSite=None; Secure に書き換える
  if (req.query.cookieMode === "none") {
    res.setHeader(
      "Set-Cookie",
      rewriteSetCookieForThirdPartyPreview(res.getHeader("Set-Cookie")),
    );
  }

  // query の値ではなく、自前で組み立てた path へ redirect (open redirect 回避)
  res.redirect(307, `/posts/${encodeURIComponent(slug)}`);
}

secretを検証し、draftModeを有効化し、記事ページへredirectする。redirect先はリクエストのslugの生の値ではなく、記事ページのpathへ組み立て直している。外部から任意のURLへ飛ばされるopen redirectを避けるためで、公式ガイドもCMSから渡された値をそのままredirectに使わないよう注意している。cookieModeがnoneのときの書き換えはcross-site対策だ。

記事ページはdraftModeで取得を切り替える

受け側の記事ページは、contextのdraftModeを見て取得先を切り替える。

// pages/posts/[slug].tsx の getStaticProps (frontend側)
export const getStaticProps: GetStaticProps<PageProps> = async (context) => {
  const slug = String(context.params?.slug ?? "");
  // preview API が __prerender_bypass Cookie を発行していれば true
  const draft = context.draftMode === true;
  // draft のときだけ Payload REST を draft=true で叩き、未公開版を取る
  const post = await fetchPostBySlug({ draft, slug });

  return {
    props: {
      mode: draft ? "draft" : "published",
      post,
    },
    revalidate: 1,
  };
};

PayloadのREST APIはdraft=trueを付けると、公開済みかどうかに関わらず最新のversionを返す。編集中のdraftがあればそれが返るので、fetchPostBySlugでこのパラメータを付け替えるだけだ。draft指定のfetchには、CMS側で発行したAPIキーを認証ヘッダに添えて、公開読み取りと権限を分ける。

Cookieの値はnext buildを走らせるたびにNext.jsが新しく発行するので、値を推測して外からdraftを覗く経路にはならない。ブラウザを閉じるとCookieが消え、preview状態は次のブラウザ起動には持ち越されない。加えて公式ガイドは末尾に「HTTPでローカルテストするには、ブラウザがthird-party Cookieを許可している必要がある」と注記していて、iframeで詰まる原因もここだ。

iframe cross-siteでCookieが消える現象に対処する

Payloadの管理画面と公開サイトが別の登録可能ドメイン(registrable domain)に立っていると、ここまでの経路ではCookieが正しく届かない。管理画面と公開サイトを同じ登録可能ドメインの中(例えば管理画面をサブドメインに置く形)にまとめるならCookieはそのまま届くので、この節の対策は要らない。

ドメインが分かれている前提で先に進む。ブラウザのアドレスバーからpreview URLを直接叩けばdraft版が出るのに、同じURLを管理画面のiframeで開くと公開版のまま止まる。

sequenceDiagram participant Admin as 管理画面のiframe participant API as preview API participant Page as 記事ページ Admin->>API: GET /api/preview?secret&slug API->>Admin: Set-Cookie __prerender_bypass と 307 redirect Admin->>Page: GET /posts/slug Page->>Admin: draft版 または 公開版

SameSite既定のLaxはcross-siteでは使えない

原因はSameSite属性だ。ブラウザはCookieに「どのサイト間リクエストで使ってよいか」の制約を付けていて、SameSite属性が未指定のCookieはLax相当で扱われる。Laxは別サイト起点のリクエストには乗らない。

ここでのサイトはドメイン名の登録可能な単位、registrable domainで判定される。管理画面と公開ページが別ドメインにあると、iframe内のfrontendはcross-site側になる。__prerender_bypassがLaxで発行されていると、この場面では保存されず、redirect先のリクエストにも乗らない。draftModeは有効化されず、記事ページは公開版を返す。

この属性はNext.jsが決めて付けており、バージョンと実行環境で変わる。開発サーバのnext devSameSite=Laxを明示して発行し、production buildでは最初からSameSite=None; Secureを付けるバージョンもある。previewが公開版で止まったら、まずSet-Cookieヘッダの実物を確認する。

Set-CookieをSameSite=Noneに書き換える

Laxのままなら、preview API側でSet-CookieをSameSite=Noneに書き換える。NoneはSecure属性とセットでのみ有効なので、両方の属性を設定し直す。

// pages/api/preview.ts (frontend側)
// Set-Cookie ヘッダは 1 本 (string) にも複数 (string[]) にもなる
function toCookieArray(value: number | string | string[] | undefined): string[] {
  if (Array.isArray(value)) {
    return value;
  }
  if (typeof value === "string") {
    return [value];
  }
  return [];
}

// 各 Set-Cookie の SameSite と Secure を剥がし、SameSite=None; Secure を強制する
export function rewriteSetCookieForThirdPartyPreview(
  value: number | string | string[] | undefined,
) {
  return toCookieArray(value).map((cookie) => {
    // 既存の SameSite= と Secure だけ落とし、name=value や他属性は残す
    const attributes = cookie
      .split(";")
      .map((part) => part.trim())
      .filter(
        (part) =>
          !part.toLowerCase().startsWith("samesite=") &&
          part.toLowerCase() !== "secure",
      );

    // cross-site iframe で送るための属性を末尾に付け直す
    return [...attributes, "SameSite=None", "Secure"].join("; ");
  });
}

setDraftModeが付けたSet-Cookieヘッダを読み出し、SameSiteとSecureをいったん剥がして設定し直している。書き換えの有無で結果はこう分かれる。

cookie-lax: preview published
cookie-none: preview draft

Laxのままではiframeの中は公開版で止まり、SameSite=None; Secureへ書き換えるとdraft版が描画される。

HTTPS配信とthird-party Cookie許可が要る

Secure属性はHTTPS配信が要る。Secure付きのCookieはHTTPSで配信されたページでしか保存されないので、frontendがHTTPのままだと、SameSiteをNoneに書き換えても保存されない。開発環境ならNext.jsのnext dev --experimental-httpsで自己署名証明書のHTTPS配信に切り替える。ブラウザに安全な接続として扱わせる起動フラグもあるが、Secure Cookieの保存までは通らない。

もう1つthird-party Cookieの許可も要る。SafariのITPは既定でブロックし、Chromeもユーザ設定やシークレットウィンドウでブロックする。ブロック環境ではSameSite=Noneでも保存されない。Cookie経路を選ぶなら、Storage Access APIのような明示的な許可要求で補う。

CookieなしでURL secretだけからdraftを取る

Cookie経路にはSet-Cookieの書き換え、HTTPS配信、third-party Cookieの許可が要る。揃えられないなら、URLのsecretだけで検証してdraftを取得する経路を使う。

// pages/preview/[slug].tsx (frontend側)
export const getServerSideProps: GetServerSideProps<PageProps> = async ({
  params,
  query,
  res,
}) => {
  // 静的キャッシュを迂回する (Cookie 経路の draftMode に頼らない)
  res.setHeader("Cache-Control", "no-store");

  // secret が合わなければ 404 を返し、正誤を応答の違いで漏らさない
  if (!isValidPreviewSecret(query.secret)) {
    return {
      notFound: true,
    };
  }

  const slug = String(params?.slug ?? "");
  // このリクエストの中だけで draft を直接取得する
  const post = await fetchPostBySlug({ draft: true, slug });

  return {
    props: {
      mode: "draft",
      post,
    },
  };
};

Payloadのcallback側は、この経路ならsecret付きの/preview/:slug形式のURLを返すよう分岐を変える。getServerSidePropsは毎リクエスト実行される。Cookie経路の静的キャッシュ迂回は、no-storeと常時draft取得で置き換わる。

secretの検証は素朴な文字列比較で済ませない。

// lib/secret.ts (frontend側)
import { timingSafeEqual } from "node:crypto";

const PREVIEW_SECRET = process.env.PREVIEW_SECRET ?? "preview-secret-for-poc";

export function isValidPreviewSecret(value: string | string[] | undefined) {
  if (typeof value !== "string") {
    return false;
  }

  const actual = Buffer.from(value);
  const expected = Buffer.from(PREVIEW_SECRET);

  // 長さが同じときだけ定数時間比較する (timingSafeEqual は長さ違いで例外を投げる)
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

比較にかかる時間から中身を推測されないよう、timingSafeEqualで定数時間比較にする。一致しない場合はnotFoundで返し、secretの正誤を応答の違いで教えない。

この経路はCookieを発行しないので、認可はURLに閉じる。preview内のリンクを1つ踏むと認可が消える。

cookieless: preview draft
navigation: cookie keeps draft
navigation: cookieless loses draft

cookieless経路でも、ページ内のリンク全てにsecretを引き回すか、初回検証後に短命のsessionを発行すれば遷移を保てる。ただし後者はCookie経路を作り直すことになるので、そこまで要るなら最初からCookie経路にする方が早い。

2経路をどう選ぶか

観測 Cookie経路 URL secret経路
iframe内のdraft描画 描画される 描画される
CookieがLaxのままのとき 公開版のまま 影響なし
iframe内でリンク遷移 draftを維持 公開版に戻る
配信の前提 frontendがHTTPS配信 Cookie属性の制約なし
ブラウザ設定への依存 third-party Cookie許可が必要 なし

選び方はこうだ。iframe内でsecretを引き回さずにページ遷移したいならCookie経路。Set-Cookieの書き換えとHTTPS配信とブラウザ許可のどれかを揃えられないならURL secret経路。secretがURLに乗るリスクと定期的な差し替えを受け入れられるなら、URL secret経路の方が軽い。露出の経路はRefererヘッダ、アクセスログ、画面共有と地味に多い。

どちらでもsecretは推測不能な乱数で生成し、差し替え可能にしておく。Referrer-Policyを絞り、secret付きURLが遷移先のRefererに漏れないようにする。iframeの埋め込み元は、CSPのframe-ancestorsで管理画面のオリジンに限定する。

third-party Cookieの扱いはブラウザ側で変わり続けるので、Cookie経路の前提も動く。previewは限定用途なので、URL secret経路を既定にして、回遊が要る編集チームだけCookie経路を足せばよい。

まとめ

  • livePreviewのurl callbackで対象collectionを絞り、対象外はnullを返す
  • preview APIでsecretを検証し、draftModeを有効化し、記事pathへredirectする
  • redirect先はリクエストの値ではなく、記事pathに組み立て直す
  • Cookie経路はSet-CookieをSameSite=None; Secureへ書き換え、frontendをHTTPSで配信する
  • URL secret経路は定数時間比較とno-storeを徹底し、Referrer-Policyを絞る

最小構成はNext.jsアプリ2つと数十行のpreview経路で組める。まずURL secret経路で映るところまで作り、回遊が要るならCookie経路を重ねればよい。