Migrating a production Next.js app off OpenNext to vinext
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:
- Our DB module's Hyperdrive path called
require("@opennextjs/cloudflare")to look up Cloudflare context, guarded behindNODE_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. - 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.tshook it relies on isn't invoked, and importing the client instrumentation in the wrong context pulled server-onlyprocess.env.NEXT_PHASE-referencing code into the client bundle, throwingprocess is not definedin the browser. Removed@sentry/nextjsentirely 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/distartifacts 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/.viteas 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.jsoncthat 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 unusedwrangler.opennext.jsoncreference file, not the active config. - Non-standard CDN image URLs need pre-wrapping. vinext's
next/imageshim 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 responsivesrcset— 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'snext/imagealias 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.