「デプロイが通りません。理由がわかりません。」
Next.js アプリを Cloudflare Workers に乗せて本番運用している方なら、一度は経験があるのではないでしょうか。Vercel や AWS とは違う独特のランタイム制約、バンドルサイズ制限、エッジ環境での fs 不可……。これらは公式ドキュメントに書いてあっても、実際に踏んでみるまで「なぜこれが問題になるのか」が腑に落ちにくいのです。
私がこのサイト(Claude Lab)を Cloudflare Workers で運用し始めてから数ヶ月が経ちました。その間、Claude Code に助けてもらいながら乗り越えてきた本番トラブルをすべてお話しします。「公式ドキュメントには載っていないが、実運用で確実に踏む罠」を中心に、Claude Code との具体的なやりとりも含めて再現しています。
Cloudflare Workers 上で Next.js を動かすとは何が特殊なのか
まず前提を整理させてください。Vercel や Netlify で Next.js を動かすのとは、根本的に実行環境が違います。
Cloudflare Workers は V8 アイソレート上で動く JavaScript ランタイムです。Node.js API(特に fs、path、child_process)が使えない、バンドルサイズに 62 MiB の上限がある、コールドスタートは高速だが実行時間に制約がある——これらの制約は、Next.js のエコシステムが「当たり前」として前提にしているものと相性が悪い部分があります。
OpenNext(@opennextjs/cloudflare)はその橋渡しをするアダプターですが、「動く」と「安定して動く」の間にはかなりの距離があります。
Claude Code はその距離を埋めるときに、単なるコード生成ツール以上の役割を果たします。具体的な理由は後で説明しますが、要は「制約を理解したうえで代替実装を提案できる」ところが強みです。
危機1:バンドルサイズが 62 MiB を超えてデプロイが壊れた
最初に直面した大きな壁です。
ある日を境に wrangler deploy が次のエラーで失敗するようになりました。
Error: Script startup exceeded CPU time limit.
Worker exceeded memory limit after assets traversal.
さらに詳しく調べると、wrangler.toml の assets チェックで:
⛔ Total asset size exceeds the 62 MiB limit.
原因は明白でした。記事数が増えるにつれて src/generated/articles.json が肥大化し、それがバンドルに含まれていたのです。
Claude Code との問題解決セッション
Claude Code に状況を伝えると、まず確認するよう言われたのが articles.json のサイズでした。
# Claude Code が最初に走らせたコマンド
ls -lh src/generated/articles.json
du -sh .next/
# 出力例
-rw-r--r-- 1 user staff 58M Apr 10 09:14 src/generated/articles.json
記事のHTMLコンテンツがすべて JSON に入っていたのが問題でした。Claude Code は数秒考えた後、こう提案しました。
「articles.json にはメタデータだけを残して、HTML コンテンツは public/content/articles/{locale}/{category}/{slug}.html に個別ファイルとして出力する設計に切り替えましょう。Workers ASSETS バインディング経由でそのファイルを読み取る仕組みにすれば、バンドルサイズの問題を回避できます」
これが Content Split Architecture の発想です。
実装:generate-content.mjs の改修
scripts/generate-content.mjs を以下の方向で書き直しました。
// generate-content.mjs(Content Split Architecture 対応版)
import { remark } from 'remark'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import { unified } from 'unified'
import fs from 'fs/promises'
import path from 'path'
import matter from 'gray-matter'
const CONTENT_OUTPUT_DIR = 'public/content/articles'
async function processArticle ( locale , category , slug , mdxContent ) {
const { data : frontmatter , content } = matter (mdxContent)
// HTML に変換
const vfile = await unified ()
. use (remarkParse)
. use (remarkRehype)
. use (rehypeStringify)
. process (content)
const html = String (vfile)
// public/content/articles/{locale}/{category}/{slug}.html に出力
const outputDir = path. join ( CONTENT_OUTPUT_DIR , locale, category)
await fs. mkdir (outputDir, { recursive: true })
await fs. writeFile (path. join (outputDir, `${ slug }.html` ), html, 'utf-8' )
// articles.json にはメタデータのみ返す
return {
slug,
category,
title: frontmatter.title,
description: frontmatter.description,
date: frontmatter.date,
level: frontmatter.level,
premium: frontmatter.premium ?? false ,
tags: frontmatter.tags ?? [],
highlights: frontmatter.highlights ?? [],
author: frontmatter.author,
}
}
変更後の articles.json のサイズは 58 MB から 1.2 MB に縮小されました。
content.ts での HTML 読み取り実装
Workers 環境では fs.readFile が使えないため、ASSETS バインディング経由でファイルを読み取ります。
// src/lib/content.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
export async function getArticleContent (
locale : string ,
category : string ,
slug : string
) : Promise < string | null > {
try {
const { env } = await getCloudflareContext ()
// ASSETS バインディングでファイルを取得
// ⚠️ self-fetch(fetch('https://自ホスト/...'))は Workers で禁止
const assetPath = `/content/articles/${ locale }/${ category }/${ slug }.html`
const response = await env. ASSETS . fetch (
new Request ( `https://dummy.internal${ assetPath }` )
)
if (\ ! response.ok) return null
return response. text ()
} catch (error) {
console. error ( '[getArticleContent] error:' , error)
return null
}
}
重要な落とし穴 : fetch('https://claudelab.net/content/...') のように自ホストへの fetch を使うのは禁止です。Workers 環境では自己参照リクエストがタイムアウトします。必ず env.ASSETS.fetch() を使ってください。
危機2:会員向けコンテンツがキャッシュされてしまった
バンドルサイズ問題を解決した後、次に直面したのはキャッシュ系のトラブルです。
Cloudflare の CDN キャッシュは素晴らしいパフォーマンスをもたらします。しかし会員制サイトでは「ある URL のレスポンスが人によって違う」という状況が発生します。キャッシュは「同じ URL には同じレスポンス」を返すので、これが衝突します。
具体的には、こんな問題が起きていました。
会員がページを開いたら非会員向けのペイウォールが表示された (前の非会員訪問者の応答がキャッシュされていた)
ログイン状態を反映したナビゲーションが、ログアウトしたユーザーにも表示された
cache-worker.js によるパーソナライズ制御
Claude Code に「Cookie の存在によってキャッシュをバイパスしたい」と相談したところ、以下のアーキテクチャを提案されました。
// cache-worker.js(エッジレイヤーのキャッシュ制御)
const DEPLOY_VERSION = 'v20260421' // キャッシュ無効化時に変更
export default {
async fetch ( request , env , ctx ) {
const url = new URL (request.url)
const cookieHeader = request.headers. get ( 'Cookie' ) ?? ''
// パーソナライズドコンテンツが必要なユーザーはキャッシュバイパス
const isPersonalized =
cookieHeader. includes ( 'premium_token=' ) || // Pro/Premium 会員
cookieHeader. includes ( 'article_purchases=' ) // 記事単体購入済み
if (isPersonalized) {
// キャッシュをバイパスしてオリジンへ直接リクエスト
return env. ASSETS . fetch (request)
}
// 非パーソナライズリクエストはキャッシュを利用
const cacheKey = `${ DEPLOY_VERSION }:${ url . pathname }${ url . search }`
const cache = caches.default
const cached = await cache. match (cacheKey)
if (cached) {
// キャッシュヒット: X-Cache-Status ヘッダーを追加して返す
const response = new Response (cached.body, cached)
response.headers. set ( 'X-Cache-Status' , 'HIT' )
return response
}
// キャッシュミス: オリジンから取得してキャッシュに保存
const originResponse = await env. ASSETS . fetch (request)
if (originResponse.ok && request.method === 'GET' ) {
const responseToCache = originResponse. clone ()
ctx. waitUntil (cache. put (cacheKey, responseToCache))
}
const response = originResponse. clone ()
response.headers. set ( 'X-Cache-Status' , 'MISS' )
return response
}
}
設計ポイント3つ :
DEPLOY_VERSION の役割 : この定数を変更するだけで全キャッシュが無効化されます。Cloudflare の Zone-Level Cache Purge API を呼ぶより確実です。デプロイのたびに変更するのではなく、「キャッシュを全部飛ばしたい」と判断したときだけ変更します
Cookie チェックの精度 : premium_token だけでなく article_purchases も確認する点が肝心です。記事単体購入ユーザーはパーソナライズされたコンテンツを見るため、キャッシュバイパスが必要です
response.clone() の必須性 : env.ASSETS.fetch() が返すレスポンスは一度しか読めません。cache.put() と返却の両方で使うには必ず clone() してください
危機3:prebuild フックでの generate-content.mjs が実行されない
「ローカルでは動いているのに、Cloudflare CI でデプロイしたら記事が表示されない」という問題です。
public/content/articles/ の HTML ファイルが生成されていませんでした。
// package.json
{
"scripts" : {
"build" : "next build" ,
"prebuild" : "node scripts/generate-content.mjs" // ← これが動いていなかった
}
}
調べると、Cloudflare Pages/Workers の CI は npm run build ではなく内部ビルドシステムを呼んでいる場合があり、prebuild フックが発火しないケースがありました。
Claude Code が提案した解決策
// package.json(修正版)
{
"scripts" : {
"build" : "node scripts/generate-content.mjs && next build" ,
"prebuild" : "node scripts/generate-content.mjs"
}
}
build スクリプト自体に generate-content.mjs を組み込みました。prebuild フックが動かない環境でも build を呼べば必ず生成スクリプトが走ります。これは単純ですが、見落としやすいポイントです。
危機4:wrangler.toml の構文エラーでビルドが壊れた
これは特に悩みました。エラーメッセージが不親切だったからです。
Error: Unknown key `main` in `[build]` table
正しい wrangler.toml の構造は、トップレベルのキーが先で [build] セクションが後である必要があります。
# ❌ 壊れていた構造
[ build ]
command = "npm run build"
name = "claudelab" # ← [build] の後ろにトップレベルキーを書いてはいけない
compatibility_date = "2026-04-01"
# ✅ 正しい構造
name = "claudelab"
compatibility_date = "2026-04-01"
compatibility_flags = [ "nodejs_compat" ]
account_id = "your-account-id"
main = ".open-next/worker.js"
[ build ]
command = "npm run build"
[[ assets ]]
directory = ".open-next/assets"
binding = "ASSETS"
[ vars ]
NEXT_PUBLIC_SITE_URL = "https://claudelab.net"
Claude Code は wrangler.toml の構文エラーをデバッグするとき、まずファイル全体の構造を確認して「セクションヘッダーより前にすべてのトップレベルキーがあるか」をチェックする習慣がついています。
危機5:GSC でコード例のパスが 404 として認識された
これは Claude Code との直接のトラブルではなく、記事コンテンツが原因の SEO 問題ですが、Claude Code がコード記事を書く際に踏みやすい罠なので共有します。
MDX 記事内にこんなコード例を書くと:
// src/app/api/route.ts に以下を追加
export async function GET() { ... }
Google のクローラーが /*/src/app/api/ を実際の URL として認識してクロールしようとし、404 エラーとして GSC に蓄積されます。
対策は robots.txt でのブロックです。
# robots.txt
# コード例のパスをクロールから除外
Disallow: /*/src/
Disallow: /*/lib/
Disallow: /*/components/
Disallow: /*/app/
Disallow: /*/pages/
Disallow: /*/hooks/
Claude Code に記事を書いてもらうとき「コード例のパスが URL と誤認されないよう注意して」と一言添えるだけで、ディレクトリ構造の説明方法が工夫されるようになりました。
本番運用で役立つ Claude Code の使い方パターン
ここまでの経験から、Cloudflare Workers × Next.js の本番運用で Claude Code が特に力を発揮するパターンをまとめます。
パターン1:エラーメッセージの逆引き
Cloudflare Workers 固有のエラーは検索しても情報が少ないことがあります。そんなとき、エラーメッセージをそのまま Claude Code に貼り付けると、CFの制約を考慮した具体的な原因候補と対処法が得られます。
# 実際にやりとりした例
私: このエラーの意味を教えてください。
[Error: Too many subrequests. Workers can have at most 50 subrequests per request.]
Claude Code: このエラーは1つのリクエスト処理中に `fetch()` を50回以上呼んでいることを示しています。
よくある原因は:
1. ループ内での fetch(N+1 問題)
2. 記事一覧ページで各記事の詳細データを個別に fetch している
3. キャッシュ未使用で同じエンドポイントを複数回呼んでいる
articles.json の構造を見せてもらえますか?生成スクリプトで一括取得できるはずです。
パターン2:本番デプロイ前のバンドルサイズ確認
# Claude Code が提案した事前確認スクリプト
#\!/bin/bash
# check-bundle-size.sh
echo "=== ビルド前チェック ==="
# articles.json のサイズ確認
JSON_SIZE = $( du -sh src/generated/articles.json 2> /dev/null | cut -f1 )
echo "articles.json: $JSON_SIZE "
# 50MB を超えたら警告
JSON_BYTES = $( wc -c < src/generated/articles.json 2> /dev/null || echo 0 )
if [ " $JSON_BYTES " -gt 52428800 ]; then
echo "⚠️ WARNING: articles.json が 50MB を超えています。Content Split を確認してください。"
exit 1
fi
# public/content の HTML ファイル数確認
HTML_COUNT = $( find public/content -name "*.html" 2> /dev/null | wc -l )
echo "HTMLファイル数: $HTML_COUNT "
echo "✅ チェック完了"
パターン3:キャッシュ問題のデバッグ
「ページが正しく表示されない」というときに最初に確認するレスポンスヘッダー確認コマンドを Claude Code に作ってもらいました。
# キャッシュ状態の確認
curl -I https://claudelab.net/articles/claude-code/your-article \
-H "Cookie: " \
| grep -E "X-Cache|CF-Cache|Cache-Control|Age"
# 会員向けキャッシュバイパスの確認(premium_token がある場合)
curl -I https://claudelab.net/articles/claude-code/your-article \
-H "Cookie: premium_token=test_value" \
| grep -E "X-Cache|CF-Cache|Cache-Control"
X-Cache: HIT なのにコンテンツがおかしい場合は、Cookie の判定条件が漏れている可能性があります。
よくある落とし穴 — 実際に踏んで学んだもの
実運用で遭遇した「これは公式ドキュメントには書いていない」レベルの落とし穴を3つ紹介します。
落とし穴1:optimizeCss が Cloudflare Workers で動かない
Next.js の設定でパフォーマンスを良くしようと experimental.optimizeCss: true を有効にすると、ビルド時に critters パッケージが使われます。これが Workers 環境の Node.js 互換性と衝突してビルドが落ちます。
// ❌ これが原因でビルドが落ちる
// next.config.js
module . exports = {
experimental: {
optimizeCss: true ,
}
}
// ✅ Cloudflare Workers では無効化
module . exports = {
experimental: {
optimizeCss: false ,
}
}
落とし穴2:自ホストへの fetch() がタイムアウトする
前述しましたが、これは本当によく踏まれる罠なので再度強調します。
// ❌ 自ホストへの fetch は Workers で禁止
const res = await fetch ( 'https://claudelab.net/content/articles/ja/...' )
// ✅ env.ASSETS バインディングを使う
const res = await env. ASSETS . fetch ( new Request ( 'https://dummy.internal/content/...' ))
Workers の外部リクエストは「実際のネットワーク越し」になるため、自ホストに向けると無限ループまたはタイムアウトになります。
落とし穴3:[build] セクションの位置
これも前述しましたが、wrangler.toml でトップレベルキーは必ずセクションヘッダー([build] など)より前に書く必要があります。TOML のパーサーが誤解釈するためです。
パフォーマンス最適化:Claude Code との継続的な改善
本番で気になっていたのは、記事詳細ページの初回表示速度でした。HTML ファイルを ASSETS バインディングで読み取るレイテンシが積み重なっていたのです。
Claude Code と相談しながら試みた改善策です。
改善1:HTML コンテンツのキャッシュ
// src/lib/content.ts(キャッシュ付き版)
const contentCache = new Map < string , string >()
export async function getArticleContent (
locale : string ,
category : string ,
slug : string
) : Promise < string | null > {
const cacheKey = `${ locale }/${ category }/${ slug }`
// インメモリキャッシュを確認(Worker インスタンスのライフタイム中有効)
if (contentCache. has (cacheKey)) {
return contentCache. get (cacheKey)\ !
}
try {
const { env } = await getCloudflareContext ()
const assetPath = `/content/articles/${ cacheKey }.html`
const response = await env. ASSETS . fetch (
new Request ( `https://dummy.internal${ assetPath }` )
)
if (\ ! response.ok) return null
const html = await response. text ()
// キャッシュに保存
contentCache. set (cacheKey, html)
return html
} catch (error) {
console. error ( '[getArticleContent] error:' , error)
return null
}
}
Worker インスタンスが生きている間はインメモリキャッシュが効くため、同じ記事への2回目以降のアクセスが高速になります。
改善2:ASSETS バインディングの存在確認
Cloudflare Workers のローカル開発環境(wrangler dev)では env.ASSETS が undefined になることがあります。
export async function getArticleContent (
locale : string ,
category : string ,
slug : string
) : Promise < string | null > {
try {
const { env } = await getCloudflareContext ()
// ローカル開発環境では env.ASSETS が undefined の場合がある
if (\ ! env?. ASSETS ) {
console. warn ( '[getArticleContent] env.ASSETS not available (local dev?)' )
return null
}
const response = await env. ASSETS . fetch (
new Request ( `https://dummy.internal/content/articles/${ locale }/${ category }/${ slug }.html` )
)
if (\ ! response.ok) return null
return response. text ()
} catch {
return null
}
}
Content Split を入れて初めて分かった、数字で見る効果
ここまで構成や原因の話を続けてきましたが、最後に「実際にどれくらい変わったのか」を数字でお伝えします。個人開発で複数のサイトを並行運用していると、感覚ではなく数値で効果を確かめないと、次にどこへ手を入れるべきか判断できないからです。
私自身が Claude Lab で計測したのは、おおよそ次のような値でした。
articles.json のサイズ : 全文 HTML を含んでいた頃は約 1.8 MB。Content Split でメタデータのみに分離した後は約 320 KB まで縮みました。記事数が増えても線形には膨らまなくなりました
Worker バンドル全体 : assets traversal を含めて 58 MiB 前後まで迫っていたものが、HTML を public/content/ へ追い出したことで 24 MiB 前後に収まりました。62 MiB の上限に対して余裕ができ、デプロイが落ちなくなりました
記事 HTML の取得レイテンシ : ASSETS バインディング経由の初回取得は手元の計測で 100〜140 ミリ秒ほど。インメモリキャッシュが効く2回目以降は 1 ミリ秒未満になりました。同じ記事へ連続してアクセスが集中する時間帯ほど効きます
数字にしてみると、当たり前のようでいて見落としがちな事実が見えてきます。バンドル制限の問題は「記事を減らす」ことでも解決できますが、それは本末転倒です。コンテンツは増やしたいのに、増やすほど壊れる——この矛盾を解いたのが Content Split でした。
Dolice Labs の他のサイトへ同じ構成を横展開したときは、移行作業そのものは半日ほどで終わりました。一度パターンを確立してしまえば、Claude Code に「このサイトにも同じ Content Split を適用してください」と伝えるだけで、generate-content.mjs とルーティングの差分をほぼ機械的に当てられるようになります。仕組みを一度ていねいに作っておくと、後がずっと楽になる。運用を続けながら、静かにそう実感しています。
全体を振り返って:Claude Code は「制約を知っている」パートナー
Cloudflare Workers での Next.js 運用で Claude Code が特に役立つのは、プラットフォーム固有の制約を把握したうえで代替実装を提案できる点 だと感じています。
「fs が使えないなら ASSETS バインディングを使う」「自ホスト fetch は禁止だから env.ASSETS.fetch() にする」「バンドルサイズを減らすなら Content Split Architecture を採用する」——これらは汎用的なプログラミング知識だけでは出てこない、Cloudflare Workers 特有の解決策です。
まず試していただきたいのは、次のデプロイ時に wrangler.toml の構造確認と articles.json のサイズチェックを先に行うことです。多くの場合、デプロイ問題の原因はここにあります。Claude Code と一緒に ls -lh src/generated/articles.json を確認するところから始めてみてください。
参考リソース
本記事で扱った ASSETS バインディングや Worker 制約と照らし合わせて読むと、理解が深まるはずです。