We run Rongtaan, a Next.js App Router e-commerce app, on Cloudflare Workers via OpenNext. Cloudflare recently shipped vinext, a way to run Next.js apps on Vite + Workers directly instead of through OpenNext's webpack-based build. I cloned the prod repo into a throwaway fork and spent an afternoon migrating it, mostly to see how far "just swap the build tool" would get before something broke.

It took twelve commits. Most of the interesting bugs only showed up on the real deployed Worker, never in local dev, which made this a slower debugging process than it should have been. Here's what actually happened.

The easy part: swapping the build tool

Installing vinext and @vinext/cloudflare, writing the vite.config.ts, and pointing dev/build/start scripts at the vinext CLI took minutes. The first snag was wrangler deploy silently redirecting into opennextjs-cloudflare deploy and failing with "Could not find compiled Open Next config" — it turned out wrangler auto-detects OpenNext projects just by the presence of open-next.config.ts in the repo, regardless of which build tool you actually used. Renamed it to .bak, deploy went through.

The crash that only happens on real deploy

Cloudflare validates a Worker's script before accepting it for deployment, by executing the built code in a sandbox with no bindings or environment variables attached. If any module reads env.SOMETHING at the top level (not inside a function), that read throws immediately: Cannot read properties of undefined (reading 'DB_MIGRATING'). The deploy just fails, with an error code (10021) that doesn't say which file did it.

Our DB client module had exactly this: a top-level if (env.DB_MIGRATING) guarding client construction. Same shape in the auth config, which called betterAuth({ secret: env.BETTER_AUTH_SECRET, ... }) directly at module scope. Fixed both by deferring construction into a lazy singleton wrapped in a Proxy, so existing call sites like client.end() and auth.api.getSession() didn't need touching.

The bug that passed everything and broke production anyway

This was the big one. Deploy succeeded. Local dev worked. Local vinext start after a real build worked. Every page on the actual deployed Worker returned a 500 with a redacted RSC digest and zero useful detail — Next.js's production error redaction strips the message and stack before it reaches the client, and it can strip it before it reaches Cloudflare Observability too, if the framework's own error boundary catches it first.

Bisection: strip layout.tsx to a bare skeleton, it works. Add back the data-fetching call, it 500s, and the 500 happens before my own console.log prints — so it's not in application logic at all, it's something failing before user code even runs. That pointed at the DB layer.

Then I noticed the repo had a .dev.vars file but no .env file at all. .dev.vars is Wrangler/Miniflare-specific — read by vinext dev's Workers emulation and by real deployed Workers (via Cloudflare secrets). But vinext start and vinext build run as plain Node processes, and Node reads .env/.env.production per ordinary Vite convention, never .dev.vars. So locally, DATABASE_URL was silently undefined the entire time I was testing "local production mode," and every DB call threw immediately, redacted into an unhelpful digest. Copying .dev.vars into .env fixed local vinext start completely.

But the deployed Worker was still 500ing. Different bug, same symptom.

Chasing the deployed-only 500

With local .env fixed, the split became clean: local dev and local vinext start both worked, only the actual Cloudflare deployment 500'd. That ruled out DATABASE_URL entirely (confirmed the secret was set on the Worker via the Cloudflare API). What's actually different about the real deployed Worker versus a local vinext start?

Two things, both NODE_ENV === "production"-gated code that never runs locally:

  1. Our DB module's Hyperdrive path called require("@opennextjs/cloudflare") to look up Cloudflare context, guarded behind NODE_ENV === "production". Locally that guard is always false, so the require line is never even reached. On the real Worker it's true, and the require pulled in an OpenNext-specific API this vinext deploy had no business calling (no Hyperdrive binding configured for it either). Disabled the Hyperdrive lookup entirely, fell through to Neon.
  2. vinext's KV data-cache adapter. Locally there's no KV binding at all, so this adapter always silently falls back with a logged warning — meaning the real KV-backed code path had never actually been exercised in any local test. Temporarily disabled it as an isolation test.

Both were plausible, both got pushed as fixes. Neither was the actual root cause.

The actual root cause: proxies without has traps

I'd wrapped db, adminDb, client, and auth in lazy Proxy objects to fix the top-level env-read crash (see above), each with a get trap that forwarded property access to the real lazily-built instance. What none of them had was a has trap.

"someProp" in proxy without a custom has trap falls through to the proxy's own target object — which, for a lazy proxy, is an empty {}. So every in check against these proxies silently returned false, no matter what the real underlying object actually had. This type-checks fine and looks completely correct in every other respect.

better-auth's toNextJsHandler does exactly this kind of check internally — "handler" in auth — to decide how to invoke the auth instance. With it returning false, better-auth called auth(request) directly instead of auth.handler(request), which threw "auth is not a function" (minified in production to something like 'e is not a function'). That broke every auth-related POST request: OTP sending, sign-in, sign-out — on the deployed Worker only, since local vinext start doesn't validate the same strict Worker sandbox and the auth flow paths weren't fully exercised in local dev in a way that surfaced it.

Adding a has trap to every lazy proxy — has(_, prop) { return prop in getInstance(); } — fixed it immediately. In hindsight this is the kind of bug you'd only catch by knowing that Proxy traps aren't independent conveniences layered onto the same behavior; each one you don't implement falls back to matching against the literal target object, not the "real" object the proxy conceptually represents.

Smaller things along the way

  • Sentry's Next.js SDK doesn't fit vinext. The server instrumentation.ts hook it relies on isn't invoked, and importing the client instrumentation in the wrong context pulled server-only process.env.NEXT_PHASE-referencing code into the client bundle, throwing process is not defined in the browser. Removed @sentry/nextjs entirely rather than fight it; will reintroduce via Sentry's Vite plugin later.
  • Build output caching, both locally and in Cloudflare's CI, kept serving stale .vinext/dist artifacts across builds independent of source changes — most visibly as a font-cache CSS file with an absolute filesystem path baked in from whichever machine last built it, which obviously breaks on any other checkout. Fixed by cleaning .vinext dist .wrangler node_modules/.vite as a mandatory pre-build step, not something to remember to do manually.
  • Cloudflare dashboard settings get wiped on deploy if they're not also in the wrangler.jsonc that the deploy actually reads. We'd turned on 100% observability sampling via the dashboard UI, then wondered why it disappeared after the next deploy — because it was only ever pinned into the unused wrangler.opennext.jsonc reference file, not the active config.
  • Non-standard CDN image URLs need pre-wrapping. vinext's next/image shim delegates remote-image handling to @unpic/react, which only recognizes URLs matching known provider path patterns (Cloudflare's is /cdn-cgi/image/...). Our R2-backed CDN URLs didn't match that shape, so images served full-resolution PNG with no responsive srcset — a regression from the AVIF-with-srcset the old OpenNext deploy produced. Fixed with a small wrapper that rewrites our CDN URLs into the /cdn-cgi/image/format=auto,quality=85/... form before handing them to vinext's shim, since vinext's next/image alias can't be overridden by a Vite plugin — every call site's import had to change instead.

Postscript: upgrading beta.0 → beta.4

Bumping vinext and @vinext/cloudflare from 1.0.0-beta.0 to 1.0.0-beta.4 produced three consecutive startup failures, none of which named the responsible package anywhere in the stack trace.

TypeError: __vite_ssr_import_9__.xor is not a function. I'd seen this one before during the original migration and written it off as a Vite SSR bundling artifact around the postgres driver's Cloudflare build. That was wrong. grep -rlE "[^a-zA-Z_$]xor\(" node_modules/.pnpm/*/node_modules/*/dist/ found it in one line of better-auth's admin plugin: z.xor(...), a zod API added in 4.2. The app pinned zod@^4.1.13. better-auth declares zod@^4.3.6, but Vite dedupes a package to one copy across the whole graph, so better-auth got the app's older zod and called a method that didn't exist yet. Bumping the app's pin to 4.4.3 fixed it. The generalizable lesson: a bare TypeError: X.y is not a function inside a __vite_ssr_import_N__ reference is almost always version drift, not a bundler bug, and the method name is enough to grep your way to the caller in one command.

Calling require for "react" in an environment that doesn't expose the require function. The vite.config.ts carried ssr: { external: true }, inherited from the original scaffold. That externalizes every dependency in the ssr environment, react included, so the pre-bundled react-dom and react-server-dom-webpack in node_modules/.vite/deps_ssr/ emitted require("react"), and workerd has no require. It had been harmless under beta.0 and became fatal under beta.4. vinext's own documented config has no such override; deleting it was the whole fix.

ipaddr.js ... does not provide an export named 'default'. New in beta.4: vinext's next/image shim default-imports ipaddr.js to do the private-IP check on remote patterns. ipaddr.js is pure CJS with no exports field and lives nested under vinext in the pnpm store, so Vite never saw it as a client optimize entry and served it raw. The component stack pointed at our root layout and providers, which is exactly the wrong place to look. Fixed with optimizeDeps: { include: ["vinext > ipaddr.js"] }. This one looks like a genuine vinext packaging bug: the plugin should register that include itself.

postcss ... does not provide an export named 'default'. Then the same failure again, one layer out in our own dependency graph. sanitize-html default-imports postcss, and pnpm gives it a nested copy distinct from the postcss in our devDependencies, so Vite didn't treat it as a client entry either. It was in the browser graph at all because two "use client" components call our sanitizeDescriptionHtml() helper. Same one-line fix, optimizeDeps: { include: ["sanitize-html > postcss"] }, though the honest fix is to sanitize on the server and pass the result down as a prop, so neither package ships to the browser.

Common thread: on a beta framework, four failures that all read as "the framework is broken" were one stale config line, one version pin, and two missing optimize entries. Clearing node_modules/.vite between attempts is mandatory; half of these don't reproduce against a warm cache.

What I'd do differently next time

Every bug that actually reached production had the same shape: it only manifested under conditions that local testing structurally couldn't reproduce — Cloudflare's Worker-upload validation sandbox, real KV/Hyperdrive bindings, NODE_ENV=production set the way a real Worker sets it, or a runtime in check a type-checker can't see. "Passes locally" turned out to be weak evidence for this particular migration. The fix isn't more local testing, it's recognizing which category a bug falls into before writing the code: any singleton wrapping something env-dependent needs a has trap from the start, not after production breaks; any env.X read needs to be inside a function from the start, not hoisted to module scope by habit. I've folded all of this into a migration checklist kept in the repo, to run through up front next time, instead of discovering each item in production one deploy at a time.