Next.js 部署到 Cloudflare Workers:OpenNext 設定教學與 5 個踩坑

從 CPU Time Limit 10ms 超標到 0ms 靜態回應,紀錄 Next.js 搬上 Cloudflare Workers 的架構優化與踩坑過程。

Leo Chiu · · 9 min

前言

Next.js 可以部署到 Cloudflare Workers,但是要靠 @opennextjs/cloudflare(OpenNext)把 next build 的結果重新打包成 Worker。

免費方案有 10ms 的 CPU 時間上限,沒設 cache 的話,SSG 頁面也會因為重新渲染而超標。

你現在看的這個 blog 是基於 Next.js,跑在 Cloudflare Workers 上。

前一篇 用 Cloudflare 免費方案架 Blog:服務盤點與免費額度避坑指南整理了用了哪些服務和免費額度。

這篇記錄整合過程中踩到的坑。Cloudflare 對 Next.js 的支援沒有 Vercel 那麼無縫,如果你習慣用 Vercel 部署,這篇應該用得上。

以下是我在架設部落格時的套件版本對照:

套件版本
next16.3.2
@opennextjs/cloudflare1.20.2
wrangler4.125.0
react19.2.8
compatibility_date2026-08-20

@opennextjs/cloudflare 的設定檔架構

Workers 底層的 runtime 是 workerd,不是 Node.js。

它沒有完整的 Node API,沒有能跨請求存活的檔案系統,也沒有常駐的 process。但 Next.js 預設的 next start 背後需要一台完整的 Node 伺服器。

要把 Next.js 跟 Cloudflare Worker 接起來,就需要 @opennextjs/cloudflare 這個套件。它把 next build 的結果重新打包成 workerd 能執行的 Worker,並獨立出一包靜態檔。

wrangler.jsonc

這個檔案就是 worker 的設定檔,像是進入點、靜態檔案等等的資訊:

// wrangler.jsonc
{
  "main": ".open-next/worker.js",
  "name": "blog",
  "compatibility_date": "2026-08-20",
  "compatibility_flags": ["global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS",
  },
}

請求進來的時候,Cloudflare 會先去看 .open-next/assets 裡面有沒有現成檔案,有的話就直接回傳,就不用算請求次數。沒有時才會去看 .open-next/worker.js ,把 worker 叫起來。

open-next.config.ts

另一個檔案 open-next.config.ts 負責告訴 OpenNext 怎麼打包出這個 Worker:

import { defineCloudflareConfig } from "@opennextjs/cloudflare";
 
const config = defineCloudflareConfig({ /** config */ });

踩坑一:opennextjs-cloudflare build 的 build command 無窮迴圈

沒設定 buildCommand 的話,OpenNext 預設會跑去執行 package.json 裡的 build 指令。

但我們在 package.json 寫的 build 本身就是 opennextjs-cloudflare build,然後就會形成無窮迴圈。

最終就會長這樣:

pnpm build
  └─► package.json "build"
        └─► opennextjs-cloudflare build
              └─► buildCommand 沒設,那就跑 package.json 的 build
                    └─► package.json "build"
                          └─► opennextjs-cloudflare build
                                └─► ⋯⋯ 直到機器放棄

解法是在 open-next.config.ts 裡顯式指定編譯指令:

// open-next.config.ts
config.buildCommand = "next build";

設了 buildCommand 之後,才能正常的編譯 Next.js 專案:

pnpm build
  └─► package.json "build"
        └─► opennextjs-cloudflare build
              └─► buildCommand = "next build"
                    └─► next build
                          └─► .open-next/

踩坑二:next dev 摸不到 Cloudflare Bindings(D1、R2、KV)

next dev 跑在標準 Node.js 環境裡,預設讀不到 env.NEWSLETTER_DB、env.IMAGES 這些 Cloudflare 專屬的 binding。

要解決這個問題,需要在 next.config.ts 最後補上一行初始化呼叫:

// next.config.ts
initOpenNextCloudflareForDev();

這行會在背景啟動一個 workerd 的 proxy,把原本抓不到的 bindings 注入回 next dev 環境中。此時 D1 預設會自動指向本地端的 SQLite 檔案,完全不會動到線上的 production 資料。

附帶一個 Turbopack 的雷

我這個站有一個僅限開發環境使用的訂閱者後台,會 import wrangler 的 getPlatformProxy 去讀線上 D1。

結果一 import,直接把 workerd 那包平台專屬的原生檔案拉進 bundle 裡,導致 Turbopack 無法順利解析,dev server 直接噴 500——甚至連隨便打開一個 README 都會跟著 500。

解法是告訴 Next.js 開發時別把它打包進去,等到 runtime 再以 require 載入即可:

// next.config.ts
serverExternalPackages: ["wrangler"],

為什麼線上 D1 要拆成另一份設定檔?

那個後台存取線上 D1 時,用的是獨立的 wrangler.send.jsonc,裡面的 binding 特別標註了 remote: true;而主要設定檔的 binding 則刻意不標,這是在本機開發時防止意外碰觸真實資料的隔離機制。

為什麼不用 wrangler 的 named environment?

Wrangler 的 named environment 是 Cloudflare 官方提供用來區分不同部署或開發環境(例如 staging、production 或 qa)的機制。

當你將所有環境的設定都寫在同一份 wrangler.jsonc(或 wrangler.toml)時,就可以透過 named environment 在同一個檔案內定義不同環境的 override 參數。

但是環境區塊內不會自動繼承頂層所定義的 binding,如果你的頂層設定宣告了 KV、D1、R2 三個 binding,但在 env.staging 裡面只寫了 D1,那麼當你執行 wrangler deploy --env staging 時,staging 環境的 Worker 完全抓不到 KV 與 R2。

這也是為什麼在某些較複雜的專案中,開發者會選擇直接拆分成兩份獨立的設定檔,例如 wrangler.jsonc 與 wrangler.send.jsonc,並透過 wrangler --config 指向指定檔案,來避免 named environment 沒對齊 binding 的問題。


踩坑三:CPU Time Limit 超標(10ms 限制與 SSG 快取失效)

第一版部署上去後,網站看似運作正常,但只要瞬間流量一進來就會跳出 Exceeded CPU Time Limits。

進 Dashboard 檢查才發現,median CPU 時間落在 28 毫秒左右,超過免費方案給的 10 毫秒上限。

甚至只要連續重新整理幾次就會開始報錯

先釐清這個額度的計算方式,它只看 CPU 實際運算時間,不包含掛在背景等待的時間。去等外部 API、等待 D1 回傳資料都不會扣這筆額度,可以慢慢等 response,但 CPU 運算不能拉長。

為什麼已經用 Static Site Generation(SSG)還是會踩到 10ms 的上限?

1. 預設的 incrementalCache 是 "dummy"

Next.js 在傳統 Node.js 環境中,SSG/ISR 的快取與預先渲染產物是寫在本機硬碟上的。

但 Cloudflare Workers 底層的 workerd 是 Serverless 環境,沒有跨請求的硬碟。OpenNext 在沒特別設定時,incrementalCache 預設會退回到 "dummy",也就是「假的快取」。

當快取實作是空的,即便你在 build 階段預先算好了 HTML,Worker 找不到快取備份,就會認為「這頁需要即時渲染」,進而在每次 request 進來時重新執行 React 伺服器端渲染。

2. Workers Assets 沒有拿到 SSG 的產物

在預設部署下,Cloudflare 只有 public/ 目錄下的靜態資產(如圖片、字型、CSS/JS chunk)會被自動丟進 Workers Assets(完全不耗費 CPU 的靜態檔案層)。

Next.js SSG 產出的 HTML 頁面如果沒有透過 @opennextjs/cloudflare 特定的 override 把它們複製到 static assets 目錄,Cloudflare 的邊緣節點就不會把它們當成「靜態檔案」直接吐給使用者,而是繼續把請求丟給 worker.js 去運算。

把 SSG 與 Workers 串起來

這就是為什麼需要在 open-next.config.ts 掛載 staticAssetsIncrementalCache 與開啟 enableCacheInterception:

// open-next.config.ts
import staticAssetsIncrementalCache
  from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";
 
const config = defineCloudflareConfig({
  incrementalCache: staticAssetsIncrementalCache,
  enableCacheInterception: true,
});

設定完成後,SSG 產出的 HTML 就會真正回到 Workers Assets 這一層:

  • 設定前: 請求 ➔ 打到 Worker ➔ 無法讀取硬碟快取 ➔ 重新執行 React SSR ➔ 消耗 28ms CPU。
  • 設定後: 請求 ➔ 打到 Workers Assets ➔ 發現現成 static HTML ➔ 直接回傳(0ms CPU、不扣免費額度)。

Static Assets Incremental Cache

負責「把快取放在哪裡(Storage)」

staticAssetsIncrementalCache 會把 prerender 好的 HTML 直接塞進 Workers Assets,跟一般靜態資產堆在一起。這樣請求一到就單純是回應靜態檔案,Worker 幾乎不需要耗費 CPU 去運算。

Enable Cache Interception

負責「什麼時候讀取快取(Execution Layer)」

開啟 enableCacheInterception: true 後,@opennextjs/cloudflare 會在 Worker 接到 Request 的第一時間(尚未載入/執行 Next.js 完整 Routing 前),直接去檢查是否有符合該路徑的快取(如 prerender 的 HTML / RSC payload)。

Cache Hit 時,Worker 直接回傳快取的 Response,立即結束請求。完全不執行 Next.js 的路由邏輯,CPU 消耗幾乎降為 0ms。


踩坑四:Next.js 16.3 prefetchInlining 導致 Request 無窮迴圈

Next 16.3 預設啟用了 experimental.prefetchInlining,這個設定一撞上 OpenNext 的 cache interception 就會出問題:每次發送 Next-Router-Segment-Prefetch 請求時,後端都吐回整頁完整的 RSC payload。

前端 client 判定剛剛要的 prefetch 沒拿齊,轉頭又再要一次,結果兩邊直接陷入無窮請求迴圈。

我的解法是在 production 環境直接把它關了:

// next.config.ts
...(process.env.NODE_ENV === "development" ? {} : { prefetchInlining: false }),

只在 production 關閉是有原因的。

這個迴圈源自 OpenNext 的 cache interception,而該機制只有在部署後的 Worker 才會生效。反過來如果在本地開發環境把它關了,會打壞 Next.js 的 global-not-found 處理。

此時 Router 會額外去請求該 URL 的 segment payload,但在開發環境拿不到 404 頁面,只能拿到 404 HTML,導致 Router 不斷觸發全頁重載。

追蹤這個 issue 可以看 opennextjs-cloudflare#1334。


踩坑五:OpenNext 一直在追趕 Next.js 的版本相容性

上面 prefetch 的問題並非特例,而是這套架構的常態:OpenNext 永遠在後面追趕 Next.js 的新版本。當雙方的預設值產生衝突時,通常不會反應在 build 階段的 log 裡,而是要等到部署上線後才會暴露出來。

因此部署完成後,可能需要關注一下 Workers Logs,Workers Logs 免費方案每天能處理 200,000 個 event、資料保留 3 天,3 天對這種情境已相當足夠。


常見問題

Next.js 可以部署到 Cloudflare Workers 嗎?

可以,但要透過 @opennextjs/cloudflare。Workers 的 runtime 是 workerd,不是 Node.js,所以 Next.js 的 next start 沒辦法直接跑,需要 OpenNext 把 build 結果重新打包成 Worker 加一包靜態檔。

Cloudflare Workers 免費方案的 10ms CPU 限制,Next.js 夠用嗎?

動態渲染不不夠用,我的 blog 在第一版部署時,median CPU 就落在 28 毫秒,連續重新整理幾次就會報 Exceeded CPU Time Limits。

把 SSG 頁面的 HTML 放進 Workers Assets,並開啟 enableCacheInterception(見踩坑三)之後,命中快取的請求幾乎是 0ms CPU。

為什麼 next dev 讀不到 env.XXX 這類 Cloudflare binding?

next dev 跑在標準 Node.js 環境。

在 next.config.ts 呼叫 initOpenNextCloudflareForDev(),就會在背景啟動 workerd proxy,把 binding 注入回來,D1 則預設指向本機的 SQLite。細節見踩坑二。


總結

把 Next.js 搬上 Cloudflare Workers,最難的地方其實跟原本想的不太一樣。

原本我以為會先了解架一個部落格需要使用哪些服務,但是在架 Blog 的時候才發現,問題都是一些很瑣碎的事情:

  • 了解到 Cloudflare Workers 的底層不是 Node.js,而是 workerd。且免費方案有嚴格的 10ms CPU 時間限制。
  • Next.js 在整合上 Cloudflare Workers 時,要實作 Cache 機制不能依賴 CDN,而是要搞清楚 worker cache 的機制。
  • Next.js 跟 OpenNext 的版本差異也是潛在問題,不管是用 serverExternalPackages 避開 Turbopack 原生套件打包,還是指定 buildCommand 避免打包的無窮迴圈,都說明 Cloudflare 跟 Next.js 的整合並非這麼無縫。

但自己使用 Cloudflare 架設之後,也確實更了解了它提供的生態系,它提供的服務真的蠻完整的。

如果你有興趣使用 Cloudflare 來架設自己的部落格,也可以參考我的另外一篇文章:用 Cloudflare 免費方案架 Blog:服務盤點與免費額度避坑指南。


Reference

  1. OpenNext for Cloudflare
  2. Workers Pricing
  3. Static Assets Billing and Limitations
  4. opennextjs-cloudflare#1334
  5. OpenNext for Cloudflare — Caching

每兩週,分享寫作與所見。每天進步一點點,一起在終點遇見更好的自己。

受 Cloudflare Turnstile 保護 · 隱私說明

Leo Chiu 的照片

Leo ChiuSenior Software Engineer @ KKday

資深軟體工程師,任職於 KKday Growth Team,主導網站 SEO / AEO(AI 搜尋優化)策略落地。平常在團隊裡推動 AI agent 的開發流程與效能優化。

Read more