跳到正文
DEV Community· Nick Fasulo·· 2 小時前AI 評分57

Vercel 費用過高,作者將 Next.js 網站 Galaxy Movies 遷移至 Cloudflare Workers

Vercel Was Too Expensive, So I Migrated My Next.js App to Cloudflare Workers

AI 導讀

作者將其 Next.js 電影及電視節目探索網站 Galaxy Movies 的正式流量,從 Vercel 遷移至 Cloudflare Workers,並維持同一程式碼庫。

正文

I recently moved production traffic for Galaxy Movies, my Next.js movie/TV discovery site, off Vercel and onto Cloudflare Workers. This isn't a Vercel hit piece: Vercel is a great platform, and the app ran happily there for a long time. But I was paying for a Pro plan for what is ultimately a side project, my DNS was already on Cloudflare, and the entire backend surface is fetch-based (Upstash REST, OpenAI, Resend, TMDB); no databases, no TCP. That's about as close to a best-case scenario for running Next.js on workers as you can get.

The site is Next.js 16 Pages Router, React 19, Chakra UI, with ISR catalog pages, a pile of API routes, four Vercel cron jobs, and dynamic OG images via next/og. Here's what the migration actually looked like, including the parts that fought back.

The adapter: @opennextjs/cloudflare
The OpenNext Cloudflare adapter takes a standard next build output and rebundles it into a worker. Setup is two files. First, open-next.config.ts. The interesting bit is the incremental cache override, which points ISR's cache at an R2 bucket:

import { defineCloudflareConfig } from '@opennextjs/cloudflare'

import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'

export default defineCloudflareConfig({

  default: { experimentalBundledNextServer: true },

  incrementalCache: r2IncrementalCache

})

Then wrangler.jsonc:

{

  "main": ".open-next/entry.js",

  "name": "galaxy-movies",

  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],

  "assets": { "directory": ".open-next/assets", "binding": "ASSETS" },

  "services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "galaxy-movies" }],

  "r2_buckets": [

    { "binding": "NEXT_INC_CACHE_R2_BUCKET", "bucket_name": "galaxy-movies-inc-cache" }

  ],

  "triggers": { "crons": ["0 6 * * *", "30 6 * * *", "0 7 * * *", "0 16 * * 5"] },

  "find_additional_modules": true,

  "rules": [

    { "type": "Data", "globs": ["public/fonts/**", "public/backdrop_fallback.jpg"], "fallthrough": true }

  ]

}

Deploy is two commands: opennextjs-cloudflare build then wrangler deploy. Everything below is the gap between "two commands" and "actually works in production."

Gotcha 1: Next's dev-only require()s break the bundle
The adapter bundles the Next server with esbuild, and esbuild wants every require() to resolve, including dev-only code paths inside Next's own dist files (next-dev-server, setup-dev-bundler, an upgrade nudge) and an RSC-only path (react-server-dom-webpack) that a Pages Router app never executes. They're dead code in production, but dead code still has to resolve.

I fixed it with patch-package to mark them external in the adapter's bundle step, plus a postbuild script that drops tiny stub modules where the runtime resolution lands:

// scripts/opennext-postbuild.js: runs after opennextjs-cloudflare build

fs.writeFileSync('.../dev/next-dev-server.js', 'module.exports = {}\n')

fs.writeFileSync('.../router-utils/setup-dev-bundler.js', 'module.exports = {}\n')

// ...etc

The patch also adds preview-props.json to the manifest glob the adapter uses, which the Pages Router build emits, and the adapter wasn't collecting. Not elegant, but it's contained in patches/ and reapplied on every npm install by patch-package. If the upstream adapter fixes these, the patch just stops applying cleanly, and I delete it.

Gotcha 2: next/og picks the wrong runtime
This one took the longest. I generate OG images with next/og, which wraps @vercel/og. The library has two entries: a Node build that does readFileSync on a bundled font and calls WebAssembly.instantiate at runtime (both disallowed on workerd), and an edge build that doesn't.

The problem: OpenNext forces NEXT_RUNTIME="nodejs" at bundle time, so next/og always resolves the Node entry, even inside a worker. The workaround is sniffing the runtime myself and importing the edge build directly:

const { ImageResponse } = (await (globalThis.navigator?.userAgent === 'Cloudflare-Workers'

  ? import(/* webpackIgnore: true */ 'next/dist/compiled/@vercel/og/index.edge.js')

  : import('next/og'))) as { ImageResponse: typeof ImageResponseType }

Navigator.userAgent === 'Cloudflare-Workers' is genuinely how you detect workerd. The webpackIgnore comment keeps webpack from trying to bundle the path at build time, and I pin the whole @vercel/og compiled directory into the output via outputFileTracingIncludes in next.config.ts so the edge build and its resvg wasm actually ship.

Gotcha 3: fs reads land in the wrong directory
My OG route also reads custom fonts and a fallback backdrop off disk with readFile(path.join(process.cwd(), 'public', ...)). On Vercel, process.cwd() is the project root. On workerd, the vfs cwd is /bundle (the .open-next output root), so public/fonts doesn't exist where the code looks for it.

The fix is a three-part move: the postbuild script copies public/fonts and backdrop_fallback.jpg into .open-next/public/, and wrangler's find_additional_modules + the Data rules in wrangler.jsonc ship those files in the worker's virtual filesystem. Same code path, files just exist where the worker looks.

Gotcha 4: packages hiding behind the workerd export condition
Some packages, in my case @emotion/* (Chakra's styling engine) and uncrypto, publish their ESM builds behind a workerd export condition. Next's file tracer only follows the CJS variants, so the worker bundle ends up with nothing it can resolve for those imports.

The fix is listing them in serverExternalPackages in next.config.ts, which tells the adapter to copy the packages whole instead of relying on tracing:

serverExternalPackages: [

  '@emotion/react', '@emotion/cache', '@emotion/serialize',

  '@emotion/sheet', '@emotion/weak-memoize', '@emotion/hash',

  '@emotion/memoize', '@emotion/unitless', '@emotion/utils',

  '@emotion/use-insertion-effect-with-fallbacks',

  'hoist-non-react-statics', 'uncrypto'

]

If you hit "cannot resolve module" errors on workerd that don't reproduce locally, this is the first thing to check.

Gotcha 5: Vercel crons don't exist on Workers
vercel.json crons are a Vercel platform feature; nothing in a Next.js build output describes them. On Workers the equivalent is triggers.crons + a scheduled() handler. But the adapter generates worker.js, so I wrap it in a custom entry that adds the handler and maps each cron expression back to the same endpoint Vercel used to hit:

// .open-next/entry.js, written by the postbuild script

import worker from './worker.js'

const CRON_ROUTES = {

  '0 6 * * *': '/api/cron/generate-content',

  '30 6 * * *': '/api/cron/streaming-changes',

  '0 7 * * *': '/api/cron/watchlist-alerts',

  '0 16 * * 5': '/api/cron/weekly-digest'

}

export default {

  ...worker,

  async scheduled(event, env, ctx) {

    const route = CRON_ROUTES[event.cron]

    if (!route) return

    ctx.waitUntil(

      env.WORKER_SELF_REFERENCE.fetch(

        new Request('https://worker.internal' + route, {

          headers: { authorization: 'Bearer ' + env.CRON_SECRET }

        })

      )

    )

  }

}

Two things make this nice. The WORKER_SELF_REFERENCE service binding lets the worker call itself as an internal fetch, no public round trip. And the endpoints needed zero changes: they already checked Authorization: Bearer $CRON_SECRET because that's how Vercel's cron caller authenticates too. Testing is wrangler dev --test-scheduled, then hit /__scheduled?cron=0 6 * * *.

Secrets
wrangler secret bulk takes a JSON file, so a 20-line script converts .env.local into .secrets.json, skipping NEXT_PUBLIC_* vars (those get inlined at build time anyway) and my local Cloudflare API token. npm run cf:secrets and done: no dashboard clicking through a dozen vars.

Cutover and rollback
The safest part of the whole migration was the cutover itself, because I deliberately ran both platforms in parallel:

npm run cf:deploy, then verify everything on the *.workers.dev URL (all routes, OG images, cron triggers registered).
Attach the galaxymovies.app + www custom domains to the worker. Since DNS was already on Cloudflare, this was instant, no propagation wait.
Keep Vercel as cold standby: git.deploymentEnabled.main: false in vercel.json stops main pushes from auto-deploying, and I disabled the Vercel crons in the dashboard so the two platforms don't double-fire jobs.

Rollback is re-adding two DNS records (A to Vercel's anycast IP, CNAME www to the Vercel DNS name). Traffic flips back immediately because the worker custom domains only take precedence while they're the only records on those names. That escape hatch made the whole thing low-stress: worst case is a DNS edit, not a redeploy.

What's actually running
Same codebase, same next build, no platform fork in the source. SSR, ISR (backed by R2), all API routes, the OG image route, and all four crons work identically to how they did on Vercel. The only code that knows which platform it's on is that one navigator.userAgent check in the OG route.

The honest summary: the adapter gets you ~80% there out of the box, and the remaining 20% is the kind of platform-shim work above. It's tractable, but expect an afternoon of debugging, not a lunch break. If your app leans on Node APIs that aren't fetch-shaped, the logic is different. But if you're already edge-friendly and your DNS is on Cloudflare anyway, the economics are hard to argue with, and Vercel sitting dormant as a free rollback target is a pretty nice safety net.

Repo notes worth taking if you're doing this: patch-package for the adapter externals, a postbuild script for stubs + vfs assets + the cron entry, serverExternalPackages for workerd-conditioned packages, and wrangler dev --test-scheduled for cron testing.

Happy to answer questions in the comments!

來源:DEV Community · dev.to