% cd ..

Keystatic を Vercel Preview にデプロイする

Keystatic を Vercel Preview にデプロイする

ローカル開発サーバー(next dev)だけで使う分には 前回のローカル編 の設定で完結します。 ただ、モバイルや外出先から記事を編集したいという気持ちもあります(っていうか、これが Keystatic に手を出した本命)。

という訳で、Vercel 上でも Keystatic が使えるようにセットアップしました。 ローカル編でのセットアップが一通り済んだ後でも、本番環境の扱いや認証周りで考えることが多かったので、順番にまとめていきます。

storage モードの切り替え

Keystatic の storage は local / github / cloud の 3 モードがあります。 ローカル開発では local モード(ファイルシステム直接編集)を使っていましたが、Vercel では github モード(GitHub API 経由でリポジトリにコミット)に切り替えます。

storage: process.env.NODE_ENV === "development"
  ? { kind: "local" }
  : { kind: "github", repo: "dazydayz/dazy-blog" }

Vercel 上では main ブランチが本番環境になるだけでなく、それ以外のブランチでは Preview モードでの表示が可能です。私のリポジトリでは(直球ですが)preview ブランチで Vercel 上でプレビュー表示ができるようにしています。
Keystatic で主に編集したいのは公開前のドラフト記事になりますので、Preview 上で Keystatic を利用可能にし、本番では Keystatic は無効にしておくのがベストです。 Preview だけ有効にしたいので直感的には VERCEL_ENV === "preview" で分岐すればよさそうですが、これが罠でした。 結果的には、上記のように判定に NODE_ENV を使うのがポイントです。

keystatic.config.ts はサーバー側とクライアント側の両方で評価されます。VERCEL_ENV は Next.js の NEXT_PUBLIC_* プレフィックスがないとブラウザに露出しないので、Preview 環境のクライアント側では process.env.VERCEL_ENVundefined になります。結果としてサーバーは github モードで routes を生成するのに、クライアントは local モードと思い込んで /api/keystatic/tree を叩き、404 Not Found が返る、という食い違いが発生しました。

NODE_ENV は Next.js がビルド時にクライアント・サーバー両方にインライン化するため、両側で一貫した値が取れます。Keystatic のドキュメントでも NODE_ENV ベースの判定が標準パターンでした。

本番 URL では /keystatic をブロック

上記の通り、github モードは本番環境でも動いてしまうので、本番の /keystatic URL は middleware で 404 でブロックします。

// middleware.ts
export function middleware(request: NextRequest) {
  if (process.env.VERCEL_ENV === "production") {
    const url = request.nextUrl.clone();
    url.pathname = "/not-found";
    return NextResponse.rewrite(url, { status: 404 });
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/keystatic/:path*", "/api/keystatic/:path*"],
};

middleware はサーバーサイドで動くので VERCEL_ENV が正しく使えます。keystatic.config.ts との使い分けは「そのコードがどこで動くか(サーバー or クライアント)」を意識する必要がありました。

NextResponse.json({ error: "Not Found" }) で JSON を返す実装もできますが、誤って辿り着いたユーザーには通常の 404 ページを見せたかったので rewrite/not-found ルートに飛ばしています。

Preview 環境は Vercel が自動で保護してくれる

Preview は誰でもアクセスできると困るので、最初は Basic 認証を middleware に追加していました。 ただ、Vercel の Hobby プランでは Deployment Protection (Vercel Authentication) がデフォルトで有効になっており、Preview URL は Vercel アカウントのログインがないとそもそもアクセスできない仕様でした。 これなら、認証を通せる Vercel アカウントはプロジェクトに権限のあるアカウントに限られるので、Hobby プランでは実質「自分専用の Preview 環境」になります。

つまり Preview の保護は Vercel が最初から面倒を見てくれていて、自前の Basic 認証は二重になるだけで不要でした。 結局 Basic 認証の実装は全部取り除き、実際に必要なのは本番でのアクセス・ブロックだけでした。
初めて使うひとあるあるなのかもですが、Vercel の既定の挙動を知っていれば避けられた遠回りでした。

GitHub App のセットアップ

Keystatic の github モードは、GitHub API 経由でリポジトリにコミットするために GitHub App を必要とします。OAuth App ではなく GitHub App を使うのは、App の方がスコープを絞れる(特定リポジトリの Contents のみ、等)ためです。

手順の大筋:

  1. https://github.com/settings/apps/new で GitHub App を作成
  2. Callback URL に Preview URL の callback パスを登録
  3. Repository permissions > Contents: Read and write
  4. 作成後、Client ID と Client Secret を控える
  5. Vercel の環境変数(Preview と Production の両方)に以下を設定:
    KEYSTATIC_GITHUB_CLIENT_ID
    KEYSTATIC_GITHUB_CLIENT_SECRET
    KEYSTATIC_SECRET(セッション暗号化用、32 文字以上のランダム文字列)
    Preview は Keystatic が実際に動く環境なので本物の値が必要。Production ではアクセスをブロックしているので本当は不要なのですが、ビルド時の Keystatic API route の設定検証が走るため(ダミーでいいので)env vars の存在自体は必須でした。無いと本番ビルドが失敗します(ここがハマりポイント)。
    Production 側はダミー値(ランダム 32+ 文字)で構いません(defense-in-depth のため、あえて Preview と別値にしておくと安心)。
  6. Keystatic 公式ドキュメントには 4 つ目の環境変数として NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG も記載されていますが、基本機能には不要なようで、このブログでは未設定でも動作しています。
  7. 作成した GitHub App を対象リポジトリに Install する(これ大切・次節)

Authorize と Install は別物

GitHub App で最後にハマったのがここ。アクセスする時、似て非なる 2 つの概念があります。

  • Authorize: ユーザーが App に対して OAuth トークンを発行する行為
  • Install: リポジトリオーナーが App にリポジトリへのアクセスを許可する行為

Keystatic にログインすると GitHub が Authorize を求めてくるのでそれだけで完結した気になりますが、Install をしていないと App はリポジトリを読み書きできません。結果として OAuth 認証は通るのに、その後のコレクション取得などで 500 エラーが返る状態になります。

要は、作っただけで満足してちゃダメで、ちゃんとインストールしとけ、って事ですね。
Install は GitHub の Settings → Applications → Installed GitHub Apps で状態を確認できます。登録されていなければ、App のページから「Install App」を実行します。

KEYSTATIC_SECRET は 32 文字以上

短い文字列を設定すると KEYSTATIC_SECRET must be at least 32 characters long というエラーでサーバーが 500 を返します。

openssl rand -hex 32       # 64 文字の 16 進
openssl rand -base64 32    # 44 文字の Base64

16 進である必要はなく、ランダムで 32 文字以上あれば何でも OK。一度決めたらローテーションしないほうが無難(変えると既存セッションが無効化される)。

Callback URL は Preview のブランチ URL にする

Vercel の Preview デプロイには 2 種類の URL があります:

  • ブランチ URL: dazy-blog-git-preview-dazydayzs-projects.vercel.app(preview ブランチで固定)
  • デプロイ固有 URL: dazy-blog-abc123-dazydayzs-projects.vercel.app(コミットごとに変わる)

GitHub App の Callback URL はワイルドカードが使えず、登録した URL と OAuth 時のリクエスト URL が完全一致する必要があります。ブランチ URL は安定しているので、こちらだけ登録して「Keystatic へのアクセスは常にブランチ URL から」という運用にしました。

ブランチのデフォルトを preview にできない

Vercel Preview 上で Keystatic を開いて編集すると、デフォルトで default branch(この場合 main)にコミットされます。Preview 環境で動かしているのに本番ブランチに直接コミットしてしまうので、最初は気づかず main を汚してしまいました。

keystatic.config.tsstorage には pathPrefixbranchPrefix しか設定できず、「どのブランチを初期選択にするか」のオプションは存在しないことを型定義で確認しました。Keystatic は常にリポジトリの default branch を初期値にします。

やりたいこと実現手段
Keystatic の初期ブランチを preview にするconfig では不可
指定ブランチに切り替えるUI の BranchPicker から選択
最初から preview を開くURL に /keystatic/branch/preview を含める

当面の運用としては、https://<preview-url>/keystatic/branch/preview をブックマークして、常にこの URL から入ることで main を汚さないようにしています。UI の BranchPicker で切り替えれば済む話ではありますが、「Keystatic を開く = main に向いている」という既定の挙動は事故の温床です。

まとめ

最終的には以下のようなアクセス環境に落ち着きました。

環境storage/keystatic アクセス認証
ローカル(next devlocalOK(ファイル直編集)なし
Vercel PreviewgithubOK(BranchPicker で preview 選択)Vercel ログイン + GitHub App OAuth
Vercel 本番github(未使用)middleware で 404 ブロック

本番の storage が github になっているのは少し気持ち悪いですが、middleware で入口自体を塞いでいるので到達不能です。 多重防御として storage も本番で local に倒しておく案も検討しましたが、先に触れた通り、クライアント/サーバーの env 変数齟齬の問題があり諦めました。

Vercel に Keystatic を載せる作業は、GitHub App のセットアップと環境変数の切り分け、middleware でのアクセス制御と、Next.js + Vercel のエコシステム知識が一通り必要でした。 公式ドキュメントだけを追うと見落としがちな罠(NODE_ENV vs VERCEL_ENV、Authorize vs Install、ブランチ初期値)が多く、ローカル設定のシンプルさに比べると難易度が一段跳ね上がった印象です。

ただ、苦労の甲斐あって、当初の目的でもあったモバイルや外出先からブラウザだけでブログ記事を編集できる環境を無事手に入れることができました。 Vercel の Deployment Protection のおかげで認証も付いてくるので、Hobby プランでも自分専用の Web CMS として実用的に運用できます。

Keystatic の試用はこれで一通りなのですが、実は Front Matter CMS と併用することで、Markdoc 方言の round-trip や IndexedDB ドラフト仕様、SPA キャッシュの外部編集握りつぶしといった、また別種のハマりどころも見えてきました。 それらは別記事 Front Matter CMS と Keystatic を共用すると設計思想の違いが見えてきた にまとめています。