Next.js 12 のサイトを静的書き出しで Cloudflare Pages に移した記録|ISR・末尾スラッシュ・ファイル数でつまずいた所

 Next.js 

 Cloudflare 

 Vercel 

作成日:2026/11/12

はじめに

このブログは Next.js 12 で作っていて、ずっと Vercel の Hobby プランで動かしていました。

ところが、ブログの下にデータのページやブラウザゲームを足していくうちに、Hobby プランの利用条件(個人・非商用向け。広告を載せたサイトは対象外)に合わなくなっていることに気づきました。

選択肢は、Vercel の有料プランにするか、別の場所に移すか。結局、Cloudflare Pages(無料プランでも商用利用OK)に移すことにしました。

この記事は、そのときに実際にやった作業と、つまずいた所の記録です。

📌
この記事の前提 ・Next.js 12.1(pages ディレクトリ) ・記事は Notion API から getStaticProps で取得、ISR(revalidate: 86400)を使っていた ・public/ に静的なページ(ゲームやデータページ)が約6,000ファイル ・URLは末尾スラッシュなし(/toei/ku/足立区 のような形)で検索エンジンに登録済み

方針:next export で静的書き出しにする

Cloudflare Pages で Next.js を動かす方法はいくつかありますが、このサイトはほぼ全部が静的なページです。サーバーで動かす必要があるのは ISR くらいでした。

そこで、Next.js のサーバー機能は使わず、 next export  で全部を静的な HTML に書き出して、それを Pages に置く方針にしました。いちばん単純で、動かなくなる部品が少ない方法です。

Vercel(移行前)Cloudflare Pages(移行後)
ビルド next build  next build  →  next export 
記事の更新ISR(1日ごとに再生成)毎朝デプロイフックで丸ごとビルド
 /games/x  →  index.html next.config.js の rewrites書き出し後に  x.html  に移す
末尾スラッシュなしなし(後処理で維持)
上限Hobby の利用条件1デプロイ 20,000ファイル・月500ビルド

ビルドの流れ

最終的に、Pages のビルドコマンドは  yarn build:cf  1つにまとめました。

next buildSTATIC_EXPORT=1
▶
next-sitemapサイトマップ
▶
next exportout/ に HTML
▶
cf-postexportindex.html → foo.html
{
  "scripts": {
    "build": "next build && yarn postbuild",
    "build:cf": "STATIC_EXPORT=1 next build && next-sitemap && next export -o out && node scripts/cf-postexport.mjs out"
  }
}

Vercel 用の  yarn build  は残しておきました。移行が終わるまでは、どちらでもビルドできる状態にしておきたかったからです。

Pages の設定

フレームワーク プリセット:なし

ビルドコマンド: yarn build:cf 

出力ディレクトリ: out 

環境変数: NODE_VERSION=18 、Notion のトークンとデータベースID、計測や広告のID(Vercel の環境変数と同じもの)

つまずいた所1:ISR が使えない

 next export  では ISR( revalidate )が使えません。 revalidate  を書いたままだとエラーになります。

かといって消してしまうと、Vercel 用のビルドで ISR が効かなくなります。そこで、環境変数で切り替えるようにしました。

export const getStaticProps = async () => {
  const database = await fetchDatabase(databaseId);
  return {
    props: { posts: database },
    // Cloudflare 用の静的書き出し(yarn build:cf)では ISR を使わない
    revalidate: process.env.STATIC_EXPORT ? undefined : 86400,
  };
};

ISR がなくなると、Notion で記事を直しても、次に push するまでサイトに反映されません。

その代わりに、GitHub Actions で毎朝 Pages の「デプロイフック」を叩いて、丸ごとビルドし直すようにしました。

name: cf-daily-rebuild
on:
  schedule:
    - cron: "0 21 * * *" # 毎日 6:00 JST
  workflow_dispatch:
jobs:
  rebuild:
    runs-on: ubuntu-latest
    steps:
      - name: trigger Cloudflare Pages build
        env:
          HOOK: ${{ secrets.CF_DEPLOY_HOOK }}
        run: |
          if [ -z "$HOOK" ]; then echo "CF_DEPLOY_HOOK が未設定なのでスキップ"; exit 0; fi
          curl -fsS -X POST "$HOOK" > /dev/null && echo "triggered"
💡
記事の反映は「最大1日遅れ」になりますが、個人ブログなら十分でした。急ぎのときは Actions の画面から手で実行(workflow_dispatch)できます。

つまずいた所2:末尾スラッシュが付いてしまう

いちばん悩んだのがこれです。

 next export  は、 /toei/ku/足立区  のページを  out/toei/ku/足立区/index.html  として書き出します。Cloudflare Pages はこれを  /toei/ku/足立区/ (末尾スラッシュ付き)で返し、スラッシュなしで来たアクセスはスラッシュ付きにリダイレクトします。

このサイトのURLは末尾スラッシュなしで検索エンジンに登録されています。全ページがリダイレクトになると、検索の評価が付け直しになるかもしれません。それは避けたいところでした。

調べてみると、Pages は  /foo  へのアクセスに  foo.html  があればそれを返してくれます。そこで、書き出したあとに  foo/index.html  を  foo.html  に移す後処理を足しました。

// scripts/cf-postexport.mjs(抜粋)
// out/**/foo/index.html を out/**/foo.html に移す。Pages は /foo を foo.html で返すので、URLは今と同じになる。
const walk = (dir) => {
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    const p = path.join(dir, e.name);
    if (e.isDirectory()) walk(p);
    else files++;
  }
  const idx = path.join(dir, "index.html");
  if (dir !== OUT && fs.existsSync(idx)) {
    const dest = `${dir}.html`;
    if (fs.existsSync(dest)) throw new Error(`衝突: ${dest} と ${idx} の両方がある`);
    fs.renameSync(idx, dest);
    if (!fs.readdirSync(dir).length) fs.rmdirSync(dir);
  }
};

ついでに、Vercel で使っていた rewrites( /games/dot-rogue  →  /games/dot-rogue/index.html  のようなもの、73個)も、この後処理で不要になりました。 public/games/dot-rogue/index.html  が  dot-rogue.html  になるので、 /games/dot-rogue  でそのまま返ります。

⚠️
同じ名前の  foo.html  と  foo/index.html  が両方あると上書きしてしまうので、見つけたらビルドを止めるようにしています。黙って片方が消えるのがいちばん怖いので。

つまずいた所3:ファイル数の上限

Cloudflare Pages の無料プランは、1回のデプロイで 20,000 ファイルまでです。

このサイトは public/ だけで約6,500ファイルあり、データのページを足すたびに増えていきます。上限に気づかないまま超えると、ある日突然デプロイが失敗します。

そこで、後処理の最後でファイル数を数えて、8割を超えたら警告、上限を超えたらビルドを失敗させるようにしました。

const LIMIT = 20000; // Cloudflare Pages(無料プラン)の1デプロイあたりのファイル数上限
if (files > LIMIT) throw new Error(`ファイル数 ${files} が上限 ${LIMIT} を超えています`);
if (files > LIMIT * 0.8) console.warn(`警告: ファイル数 ${files} が上限 ${LIMIT} の8割を超えました`);

つまずいた所4:ローカルでは通るのに Pages で落ちる

設定を入れて最初のビルドを流すと、2回続けて失敗しました。どちらも Next.js とは関係のない所が原因でした。

.tool-versions の yarn

手元では asdf でバージョンを管理していて、 .tool-versions  に  nodejs  と  yarn  の行がありました。

Pages のビルド環境もこのファイルを読むのですが、yarn の行があるとビルドの準備で失敗します。yarn の行を消したら通りました。

 nodejs 24.13.1
-yarn 1.22.19

名前を間違えていた _documents.tsx

次は  next export  の途中で止まりました。原因は  src/pages/_documents.tsx  というファイルです。

本来は  _document.tsx (s なし)です。名前が違うので Next.js は特別なファイルとして扱わず、 /_documents  という普通のページとして書き出そうとして失敗していました。何年も前に作ったファイルで、Vercel のビルドでは問題にならなかったので、まったく気づいていませんでした。

今回は、書き出しの対象から外すことで対応しました。

module.exports = {
  // next export(Cloudflare 用)だけで使われる。_documents.tsx は名前違いでページ扱いになっていて書き出せないので外す。
  async exportPathMap(defaultPathMap) {
    delete defaultPathMap['/_documents'];
    return defaultPathMap;
  },
};

つまずいた所5:プレビュービルドと本番ビルド

移したあとも、細かいつまずきが続きました。

プルリクエストのプレビューが毎回失敗する

Pages はプルリクエストごとにプレビューのビルドを作ってくれるのですが、このサイトではプレビュー側の環境変数が足りず、毎回失敗します。

失敗しても本番には影響しないので放っておいたのですが、そのせいで「本番だけで落ちるビルド」に気づくのが遅れたことがありました。import の並び順の ESLint エラーで、 next build  が本番だけ止まっていたのです。今は、マージ前に手元で ESLint を通すようにしています。

ビルド回数の上限

Pages の無料プランは、月500ビルドまでです。失敗したプレビュービルドも回数に入ります。

毎朝の再ビルドで月30回、データの自動更新でも何回か使うので、細かい修正はまとめて1回で出すようにしています。

Vercel 側の後片付け

Cloudflare Pages で問題なく動くのを確かめてから、Vercel 側を片付けました。いきなり消さず、段階を踏んでいます。

DNS を Cloudflare に向ける(Vercel のプロジェクトはしばらく残す)

 vercel.json  で、push しても Vercel がビルドしないようにする

数日様子を見てから、Vercel のプロジェクトと古い DNS レコードを消す

{
  "git": {
    "deploymentEnabled": false
  }
}
💡
移行の途中で Vercel にもビルドが走り続けると、Hobby の回数上限を無駄に使います。プロジェクトを消す前でも、この設定で自動デプロイだけ止められます。

独自ドメインにしておいてよかった

今回の移行で、URLは1つも変わりませんでした。ホスティングを Vercel から Cloudflare Pages に替えても、検索エンジンや外部から張られたリンクはそのまま使えています。

これは、最初から独自ドメイン(nosuke-blog.site)で運用していたからです。もし  xxx.vercel.app  のURLのままだったら、移行のたびにURLが変わり、検索の評価もリンクも一からやり直しでした。

Cloudflare Pages も  xxx.pages.dev  のURLをくれますが、これから個人のサイトやブログを始めるなら、最初に独自ドメインを取っておくのがおすすめです。ホスティングを乗り換える自由が手に入ります。(以下、PRを含みます)

PR(広告)
ムームードメイン

▶ 独自ドメインを探すなら MuuMuu Domain!(ムームードメイン)

まとめ

ほぼ静的なサイトなら、 next export  + Cloudflare Pages はシンプルで扱いやすい組み合わせでした。

つまずいたのは、Next.js の機能そのものより、その周りでした。

ISR の代わりに、毎朝デプロイフックで丸ごとビルド

末尾スラッシュなしのURLは、書き出し後に  foo/index.html  →  foo.html 

ファイル数(20,000)とビルド回数(月500)の上限は、仕組みで見張る

 .tool-versions  や、名前を間違えた  _documents.tsx  のような「ローカルでは問題にならないもの」に注意

移行作業そのものは1日で終わりました。同じように Vercel から移したい人の参考になればうれしいです。