Every issue listed here works fine on Next.js. They exist because vinext reimplements Next.js's framework layer on top of Vite, and these reimplementations have gaps. For each issue, we document what Next.js does differently and why the gap exists.
Responsibility: @vitejs/plugin-rsc Repo: https://github.com/vitejs/vite-plugin-react (packages/plugin-rsc)
What breaks: transformWrapExport uses output.move() to relocate
export { name } statements to the end of the file. When the source ends
with //# sourceMappingURL=… (no trailing newline), the moved export is
concatenated onto the comment line, making it invisible to Rolldown.
Why Next.js works: Next.js uses its own RSC transform implementation
(in packages/next/src/build/), not plugin-rsc. The bug is specific to
plugin-rsc's output.move() path.
Our workaround: payloadRscExportFix inserts a newline before any
export keyword found after a // comment on the same line.
Responsibility: @vitejs/plugin-rsc / Rolldown (triggered by Payload UI module layout) Repo: https://github.com/vitejs/vite-plugin-react (packages/plugin-rsc) Upstream: not filed yet
What breaks: On Payload templates at payload@3.82.1 with
vinext 0.1.3 (not re-verified in isolation on 1.0.0-beta.2; the suites
pass with the workaround active), RSC build can fail with:
"getHTMLDiffComponents" is not exported by @payloadcms/ui/dist/elements/HTMLDiff/index.js.
The source module exports it, but the RSC build graph sees it as missing.
Why Next.js works: Next.js uses its own RSC/bundling pipeline, not
@vitejs/plugin-rsc + Rolldown.
Our workaround: payloadHtmlDiffExportFix patches
@payloadcms/ui/dist/exports/rsc/index.js on disk at build start to
replace the re-export with a fallback export for
getHTMLDiffComponents.
Responsibility: vinext
Repo: https://github.com/cloudflare/vinext
Upstream: cloudflare/vinext#237
Status: OPEN (re-checked 2026-06-15; still open against vinext 0.1.3. PR #250 only makes the throw clearer, it doesn't drop the value — workaround still required). Issue status not re-checked against 1.0.0-beta.2; the suites pass with the workaround active.
What breaks: React's RSC serializer throws when functions, RegExps,
or class instances cross the server/client boundary. Payload field configs
contain access functions, hooks, onInit, validation RegExps, etc.
Every admin page fails.
Why Next.js works: Next.js catches these throws in its RSC integration
layer (packages/next/src/server/app-render/) and silently drops the
values — they become undefined on the client. This is intentional
production behavior, not a bug suppression.
Our workaround: payloadRscRuntime patches the throw statements in
react-server-dom-webpack to return undefined, so rendering never
aborts. (Note: unlike Next.js, which catches the throw in its integration
layer, we prevent it at the source — the observable behavior matches.)
Responsibility: Payload CMS Repo: https://github.com/payloadcms/payload
What breaks: Some @payloadcms/* packages expose client components
through barrel files that don't carry 'use client' themselves. Vite's
optimizer merges the barrel and component into one chunk, stripping the
directive. The RSC transform can't detect the client boundary.
Why Next.js works: Next.js's bundler (webpack/Turbopack) propagates
'use client' semantics through re-export chains. This is a Next.js
convenience — the React spec says 'use client' is per-file.
Our workaround: payloadUseClientBarrel auto-detects affected
packages and excludes them from RSC optimizeDeps so files go through
the transform pipeline individually.
Responsibility: workerd (Cloudflare Workers runtime) Repo: https://github.com/cloudflare/workerd
What breaks: workerd defines console.createTask but throws when
called. React 19 dev mode checks for the method's existence and calls it
for async stack traces, causing a crash.
Why Next.js works: Next.js runs RSC in Node.js (or Edge Runtime),
neither of which has a broken console.createTask. This is specific to
workerd's polyfill.
Our workaround: payloadWorkerdCompat prepends a try/catch polyfill
that replaces the broken implementation with a no-op. Injected via both
Vite transform hook (for non-pre-bundled modules) and
optimizeDeps.rolldownOptions.plugins (for pre-bundled deps like React).
Responsibility: Rolldown / @cloudflare/vite-plugin Repo: https://github.com/rolldown/rolldown (Rolldown), https://github.com/cloudflare/workers-sdk (cloudflare plugin)
What breaks: The @cloudflare/vite-plugin resolves node:* imports to
unenv polyfills via a resolveId hook with a Rolldown filter option. When
a CJS module uses require('node:worker_threads') (as undici does in
lazy-loaded feature detectors), Rolldown may not fire the filtered hook
for the CJS require call. The node:* import goes unresolved.
Why Next.js works: Next.js externalizes Node.js builtins during SSR bundling. They resolve at runtime against the Node.js standard library.
Our workaround: payloadWorkerdCompat provides a filterless
resolveId fallback that catches any node:* specifier missed by the
cloudflare plugin and routes it to unenv/node/${bare}.
Responsibility: Rolldown (CJS→ESM interop)
What breaks: undici's runtime-features.js uses lazy loaders like
() => require('node:worker_threads') to detect runtime features.
Rolldown's CJS→ESM interop converts this to () => init_worker_threads()
— an ESM namespace initializer that returns void, not the module object.
detectRuntimeFeatureByExportedProperty then tries to access a property
on undefined, throwing a TypeError (markAsUncloneable is not a function).
Why Next.js works: Next.js externalizes undici during SSR. The
require() calls execute against real Node.js modules that return proper
objects.
Our workaround: payloadWorkerdCompat wraps
detectRuntimeFeatureByExportedProperty in a try-catch so the detection
returns false and undici falls back to its no-op stubs.
Responsibility: workerd (Cloudflare Workers runtime) Repo: https://github.com/cloudflare/workerd
What breaks: Bundled asset modules deployed to workerd may have
import.meta.url as undefined. Packages like Payload use
fileURLToPath(import.meta.url) or createRequire(import.meta.url) at
module scope to derive __dirname or load native addons. These crash
during Cloudflare's upload validation step (which executes the module to
verify it has event handlers). import.meta.dirname is also undefined.
Why Next.js works: Next.js SSR runs in Node.js where import.meta.url
is always set to the module's file:// URL. Even during production builds,
Next.js resolves __dirname at build time via webpack's __dirname shim.
Our workaround: payloadWorkerdCompat transforms both
fileURLToPath(import.meta.url) and createRequire(import.meta.url) to
use import.meta.url ?? "file:///". The dummy URL produces "/" — any
filesystem operation using this path will fail, but those code paths
aren't reached in Workers.
Responsibility: Vite (Environment API)
What breaks: Vite's ssr.external configuration only applies to the
environment named "ssr". When the @cloudflare/vite-plugin manages a
"rsc" environment (with viteEnvironment: { name: "rsc" }), packages
listed in ssr.external are not externalized from the RSC build. This
causes unresolvable imports (e.g., blake3-wasm's ./node.js platform
file) in the RSC bundle.
resolve.external cannot be used either, because the cloudflare plugin
validates and rejects it on all environments it manages.
Why Next.js works: Next.js has a single SSR bundling pass that handles both RSC and SSR. Its externals configuration applies uniformly to all server-side modules.
Our workaround: payloadServerExternals uses configEnvironment to set
build.rolldownOptions.external on both the "ssr" and "rsc" environments.
This bypasses the ssr.external naming limitation and the cloudflare
plugin's resolve.external validation.
Responsibility: workerd / Payload packaging
What breaks: file-type (a direct dependency of payload itself —
dist/uploads/getFileByPath.js — so every Payload project pulls it in)
and drizzle-kit/api (via @payloadcms/db-d1-sqlite) use Node.js
fs/streams APIs that don't exist in workerd. pnpm's strict isolation also prevents the
optimizer from finding them during pre-bundling.
Why Next.js works: Next.js runs in Node.js where fs/streams exist. webpack traverses the filesystem directly, bypassing pnpm's symlink layout. And these deps are only used during build/migration, not at request time — Next.js's tree-shaking removes them from the runtime bundle.
Our workaround: payloadRscRuntime stubs both packages. file-type
is handled via resolveId → static stub file. drizzle-kit/api is
loaded via createRequire(import.meta.url) which bypasses all bundler
resolution hooks, so we use a transform hook to replace the
require('drizzle-kit/api') call with inline no-op stubs during
pre-bundling. They're never invoked during RSC rendering — file-type
is for upload detection, drizzle-kit/api is for migrations.
Responsibility: Payload cloudflare template pattern / Rolldown resolution Repo: https://github.com/payloadcms/payload (templates/with-cloudflare-d1), https://github.com/rolldown/rolldown
What breaks: the cloudflare templates' payload.config.ts (and our
init rewrite of it) reads bindings via
import('cloudflare:workers') with a catch fallback to
import('wrangler') → getPlatformProxy(). The fallback only executes
under plain Node (payload CLI), but it must resolve in every Vite
environment that processes the config. Anywhere it gets bundled or
optimizer-scanned, wrangler's entire CLI comes with it — including
blake3-wasm, whose export * from './node.js' Rolldown cannot resolve.
Dev failed during dependency optimization
([UNRESOLVED_IMPORT] Could not resolve './node.js') and vinext build
failed on the same chain
(payload.config.ts → wrangler/wrangler-dist/cli.js → blake3-wasm).
wrangler in SSR_EXTERNAL / OPTIMIZE_DEPS_EXCLUDE does not help: the
cloudflare worker build reads neither.
Why Next.js works: the equivalent template uses @opennextjs/cloudflare,
whose build pipeline never feeds payload.config.ts through a bundler
environment that tries to resolve the wrangler fallback.
Our workaround: payloadCliStubs stubs wrangler with a
getPlatformProxy that throws on call, not on import — inside workerd
cloudflare:workers always resolves so the stub is dead code, and a genuine
Node-side use fails loudly instead of silently.
Resolved 2026-07-18; see
d1-dev-boot-investigation.md for the full
story (a second, stacked cause — the init matcher missing vinext 1.0's
config shape — masked this one).
Responsibility: vinext
Repo: https://github.com/cloudflare/vinext
Upstream: cloudflare/vinext#666
Status: OPEN (re-checked 2026-06-15; still open against vinext 0.1.3. PR #665 fixes Pages Router only; App Router CJS-through-plugin-rsc is unaddressed — workaround still required). Issue status not re-checked against 1.0.0-beta.2; the suites pass with the workaround active.
What breaks: App Router forces noExternal: true for RSC/SSR
environments, so raw CommonJS packages from node_modules are pushed
through Vite/plugin-rsc transforms. CJS patterns like module.exports
and this-as-global cause parse errors ("A module cannot have multiple
default exports") or runtime failures.
Why Next.js works: Next.js externalizes most node_modules during
SSR and uses webpack's CJS interop layer for the rest. The bundler
natively understands module.exports and this-as-global.
Our workaround: payloadCjsTransform rewrites this → globalThis
in UMD/CJS wrappers and wraps module.exports with ESM scaffolding.
Skips React/ReactDOM to avoid double-wrapping.
Remove when: vinext fixes #666 natively. PR #665 fixes Pages Router only; App Router needs separate work.
We previously claimed vinext's next/navigation shim served fallback
values ("/", {}, empty search) during hydration because its
getServerSnapshot reads a server context that is null on the
client, and shipped payloadNextNavigationFix to patch the shim on
disk.
Retracted: the patch had never applied. vinext deliberately keeps
next/navigation out of resolve.alias (shims marked
reactServer: true resolve through a resolveId hook instead), so
the plugin's alias lookup found nothing and it was a silent no-op —
on 0.1.3 as well as 1.0.0-beta.2. With the patch inert, the admin e2e
suite passes including an explicit no-hydration-errors assertion:
the browser entry calls setClientParams() before hydrateRoot(),
so the client store is already correct at hydration time. The plugin
was removed; no workaround is needed.
Responsibility: vinext Repo: https://github.com/cloudflare/vinext Upstream: not filed (low priority; architectural choice in vinext)
What breaks: vinext's browser entry imports the navigation shim via
from "../shims/navigation.js" (relative path) instead of the aliased
from "next/navigation". Vite serves the raw file alongside the
pre-bundled version — two module instances, two React copies, split
state. On cold cache, the optimizer discovers the new module and
triggers a full page reload.
Why Next.js works: Next.js bundles the browser entry as a single
webpack/Turbopack chunk. All next/navigation imports resolve to the
same module instance. There's no optimizer or alias indirection.
Our workaround: payloadServerActionFix rewrites the relative
import to the aliased specifier. payloadOptimizeDeps auto-discovers
all next/* alias specifiers and pre-includes them so the optimizer
doesn't discover them at runtime.
Responsibility: vinext Repo: https://github.com/cloudflare/vinext Upstream: not filed (related: cloudflare/vinext#620)
What breaks: vinext applies the new RSC tree before checking
result.returnValue. Every server action — including data-returning ones
like getFormState — triggers a full re-render. For Payload, this resets
form state: array field rows flash and disappear.
The bug has moved across vinext versions:
- ≤0.0.46:
getReactRoot().render(result.root)runs inapp-browser-entrybefore thereturnValuecheck. - 0.0.47–0.0.49: refactored —
commitSameUrlNavigatePayloadinapp-browser-navigation-controllerunconditionally callsdispatchApprovedVisibleCommitbefore returning the action's data. - 0.0.50–0.0.55: same site as 0.0.47–0.0.49, but the call was renamed to
dispatchSynchronousVisibleCommit(a thinner sync wrapper), still a one-lineif ($COND) dispatch(…). - ≥0.1.0: the dispatch moved into a block body
(
if (latestApproval.approvedCommit) { dispatch(…); syncHistory(…); }, with anelsebranch), so the one-line gate no longer matches.
Why Next.js works: Next.js's server action handler (app-router.js)
checks returnValue first and only re-renders for void mutations. Data-
returning actions pass the value back to the caller without touching
the React tree.
Our workaround: payloadServerActionFix uses ast-grep. On ≤0.0.46 it
moves the render call after the returnValue check. On 0.0.47–0.0.55 it ANDs
!returnValue onto the one-line dispatch's condition (matching either
dispatcher name). On ≥0.1.0, where the dispatch is a bare statement inside a
block, it wraps the call as if (!returnValue) dispatch(…) (gating the outer
if would wrongly trip the else branch). A drift detector unit test
transforms the real installed vinext file so a future shape change fails
loudly.
Responsibility: vinext (navigation shim) / Payload (conditional rendering)
What breaks: Payload components (DefaultNavClient, DocumentTabLink)
conditionally render <Link> or <div> based on usePathname() /
useParams(). When vinext's navigation shim returns different values
during hydration (see "Navigation shim" above), React 19 detects an
element-type mismatch and discards the entire server-rendered subtree.
Why Next.js works: Next.js's navigation hooks return consistent values during SSR and hydration (via React context), so the conditional rendering always produces the same element type on both sides.
Our workaround: payloadNavComponentFix uses ast-grep to force
consistent rendering: ternaries with "link" / "div" alternatives
become "link", pathname === href becomes false, etc.
Responsibility: vinext
Repo: https://github.com/cloudflare/vinext
Upstream: fixed by cloudflare/vinext#1742,
cloudflare/vinext#1878 and
cloudflare/vinext#2000 (all merged June 2026)
Status: RESOLVED — workaround removed at the vinext 0.1.3 bump;
still resolved on 1.0.0-beta.2 (re-checked 2026-07-18).
Earlier revisions of this section cited vinext #654 as the tracking issue. That was wrong: #654 is "RSC parity gap: action redirects use hard navigation instead of soft RSC navigation", a separate concern that is still open. It never tracked the
NEXT_REDIRECTleak described here.
What used to break: Payload uses redirect() for auth checks. On
vinext 0.0.x, a redirect() thrown during async rendering inside
renderToReadableStream wasn't intercepted — the error entered the RSC
stream and surfaced as an uncaught NEXT_REDIRECT on the client. The
former payloadRedirectFix injected a client-side script that caught the
leaked error and performed location.replace().
What fixed it: vinext 0.1.x handles page-level redirects natively.
dist/server/app-page-execution.js (verified in 0.1.3) converts a
thrown redirect() into a proper response — new Response(null, { Location, status }) (HTTP 302/307) for document requests, a meta-refresh HTML
fallback, and an RSC-encoded redirect (buildRscRedirectFlightStream +
client RedirectErrorBoundary) for flight navigations. Release note #1742
("hard navigate streamed redirects", 0.1.0) covers the streaming case the
workaround targeted; #2000 and #1878 round out fallback/console handling.
Verified empirically: the admin e2e auth redirect (/admin →
create-first-user) passes with the plugin removed and no leaked
NEXT_REDIRECT / hydration errors.
Responsibility: Rolldown / @cloudflare/vite-plugin Repo: https://github.com/rolldown/rolldown, https://github.com/cloudflare/workers-sdk Upstream:
- cloudflare/workers-sdk#10213 (original report, closed)
- cloudflare/workers-sdk#10544 (fix:
preserveEntrySignatures: "strict") - rolldown/rolldown#3500 (
preserveEntrySignaturesfeature request) - rolldown/rolldown#6449 (strict mode validation)
Status: workers-sdk #10213 CLOSED, workers-sdk #10544 MERGED, rolldown #3500 CLOSED, rolldown #6449 CLOSED (re-checked 2026-06-15 against rolldown
1.0.3/ Vite8.0.16). Still needed:preserveEntrySignatures: "strict"governs named exports (it stops extra exports being hoisted onto the entry — the #10213 case), not the shape of the default-export value. vinext0.1.3still emits the{ fetch }object entry (dist/server/app-router-entry.js), and Rolldown can still collapse that object on large bundles. No upstream issue covers default-export-object inlining. On1.0.0-beta.2(verified against a real cloudflare-target build, 2026-07-18) the entry chunk's default export survives as a{ fetch }object — the workaround's rewrite applies but its runtime wrapper passes the object through untouched, so it currently has no effect on the emitted output and only guards against the collapse recurring.
What breaks: vinext's app-router-entry.js exports
{ async fetch(request, env, ctx) { return handleRequest(request, env, ctx) } }
— the Workers module handler format. When Payload's large dependency
graph (500KB+ RSC bundle) is bundled with Rolldown on Vite 8, Rolldown
inlines the wrapper and exports the RSC handler as a bare
async function handler(request, ctx). Cloudflare Workers expects the
default export to be an object with a fetch method; a bare function
produces error 10068: "no registered event handlers".
The @cloudflare/vite-plugin sets preserveEntrySignatures: "strict"
(PR #10544, fixing #10213). On Vite 8 this goes into rolldownOptions, but
strict only preserves the entry's named exports — it does not pin the
shape of the default-export value, so the { fetch } wrapper object still
gets inlined away on large bundles. Small apps aren't affected because
Rolldown doesn't need to inline their entry modules.
Why Next.js works: Next.js doesn't target Cloudflare Workers directly. Its server entry runs in Node.js or Edge Runtime, neither of which validates the shape of the default export.
Our workaround: payloadWorkerdEntry adds a generateBundle
hook that inspects the RSC entry chunk. If the default export is a
function declaration (not already a { fetch } object), it rewrites
the export to wrap it:
{ fetch: (request, env, ctx) => handler(request, ctx) }.
Remove when: Rolldown fully enforces preserveEntrySignatures: "strict"
for entry module inlining, preventing the wrapper from being optimized away.