vinext

Vinext 1.2: middleware for public files, stricter caching, and broader ecosystem compatibility

6 min read

To upgrade:

npm install vinext@1.2.0 @vinext/cloudflare@1.1.1 @vitejs/plugin-rsc@^0.5.37

App Router projects now need @vitejs/plugin-rsc 0.5.37 or newer, and stop with an error on older versions. The reason is under Server Actions. Projects created with vinext init have it as a dev dependency, so upgrade it alongside Vinext.

Most of this release is fixes. Many came from checking Vinext's behaviour against Next.js 16, from the nightly Next.js deploy test suite, and from running next-intl's own tests against Vinext.

Middleware and public files on Workers

In Next.js, middleware runs for every path its matcher matches, including files in public/. On Workers, Static Assets serves those files before the Worker runs, so middleware never saw them.

vinext init now generates a runWorkerFirst setting that sends everything except the hashed /_next/static build output through the Worker:

assets: {
  htmlHandling: "none",
  notFoundHandling: "none",
  runWorkerFirst: ["/*", "!/_next/static/*", "!/*/_next/static/*"],
},

Middleware then runs before public/ files, and Vinext serves the file from the ASSETS binding with any request headers middleware changed. Existing projects aren't changed. If your middleware should cover public files, add these settings to your config. Keep both exclusions: runWorkerFirst: true also sends the build output to the Worker, which returns 404 for it. Middleware and static files has the details.

The image optimizer follows the same rules now. /_next/image requests go through middleware, and a local image source is fetched through middleware as an anonymous request, as Next.js does. A public image that middleware blocks can no longer be read through /_next/image?url=…. With a basePath, next/image also emits ${basePath}/_next/image, which fixes 404s for optimized images on Workers apps without an image optimizer.

Cache keys

Several fixes are for edge cases where two different requests could get the same cache key, so one caller's cached result could be served to another:

  • unstable_cache keys include the callback source even when you pass keyParts, as in Next.js, and are now a SHA-256 digest of an unambiguous encoding. Tuples such as ["a:b", "c"] and ["a", "b:c"] no longer share an entry.
  • An unstable_cache result is stored under the arguments its callback ran with, so changing a shared options object between calls no longer stores one user's data under another's key.
  • A cached fetch copies its URL, headers, cf and body when it is called, then keys and sends that copy. Bodies of every type are keyed, and on Workers so is cf.resolveOverride. The caching guide lists where this goes further than Next.js.
  • Long ISR paths and other oversized keys are shortened with SHA-256 rather than a non-cryptographic hash.
  • x-api-key counts as a credential, so responses to those requests are never stored in the shared Cloudflare caches.
  • revalidateTag() in the default in-memory cache no longer forgets an invalidation after 10,000 other tags.

Pages Router ISR

Stale Pages Router entries used to be regenerated by rendering only the page and splicing it into the cached HTML. _document, its getInitialProps and any CSS-in-JS styles stayed as they were on the first render. Regeneration now runs the full render, as Next.js does. Vinext also finds <Main /> with a placeholder that request data can't forge, instead of searching for marker text.

In production, Next.js never calls getStaticPaths() for a request. Vinext has no required prerender step, so it still checks new paths at request time, but it no longer calls getStaticPaths() for cache hits, stale responses, draft mode, or when Response Store or Workers Cache regenerates an entry it already holds. Before this, a dynamic getStaticProps page ran its full path list on every request unless you used the static assets cache.

A getStaticProps page whose _app or _document sends the response itself, for example an auth redirect with res.writeHead(302), is now still rendered and stored, as in Next.js.

next/dynamic

@Mauladen and @AhmedElBanna80 reported several ways next/dynamic differed from Next.js:

  • In the App Router, dynamic() only adds a <Suspense> boundary when you pass loading or ssr: false. Otherwise the component suspends to the nearest boundary above it, so content no longer pops in after the shell.
  • Components loaded from outside the Vite root, such as workspace packages in a monorepo, get their CSS and preload links during SSR instead of rendering unstyled until hydration. Packages in transpilePackages get them too.
  • useId() values inside a dynamic component match between the server and the client.
  • Text right before a dynamic component no longer gets an extra <!-- --> from its script preloads.

Server Actions

@vitejs/plugin-rsc 0.5.37 ties the encrypted values an inline Server Action closes over to that action, as Next.js does. On older versions, a client could send one action's encrypted closure to another action on the same route. Vinext now requires 0.5.37. The same kind of check now applies to inline "use cache" functions.

Errors from actions match Next.js too. An action called with fetch that throws rejects in the browser with a digest, and is reported to instrumentation.onRequestError. A form submitted without JavaScript whose action throws gets a plain 500, rather than the nearest error.tsx with a 200 status.

Action modules are now evaluated outside the request that first loads them, so a cookies() or headers() call at module scope can't keep the first user's values for later requests in the same isolate.

next-intl

next-intl's App Router playground and its canonical App Router example now run in our CI against a Workers production build, using next-intl's own Playwright tests. The playground runs in seven configurations, covering localized pathnames, basePath, trailing slashes, domains and locale cookies. It found three bugs, all fixed in this release: a middleware rewrite that also carried x-middleware-next was ignored, clientOutDir didn't apply to the client build, and next/image didn't use the basePath. vinext check now reports next-intl as supported.

Module Federation

The Module Federation team now publishes @module-federation/vinext, a wrapper around @module-federation/vite with defaults for Vinext. It shares React and React DOM as singletons and injects the host's federation startup into the client entry, since a Vinext app has no HTML entry to put it in. @Nsttt wrote the new Module Federation guide around it.

Getting a federated host and remote working turned up two fixes:

  • Files a plugin writes outside _next/static, such as a host's /remoteEntry.js, returned 404 from vinext start for App Router apps and from Workers using the runWorkerFirst setting above, so the page never hydrated. Vinext now serves files from the client build before middleware runs, which matches Cloudflare's default of serving assets first.
  • @vitejs/plugin-rsc 0.5.36 and 0.5.37 turn template-literal dynamic imports into glob imports during their scan build, and those fail to resolve. That broke federated builds. Vinext works around it on those two versions.

A federated host and remote now also run in CI against a Workers build.

Other changes

  • vinext init --name sets the Cloudflare Worker name. @MattieTK added it so tools like @cloudflare/autoconfig can pass the name the user picked.
  • @jeffreywescott fixed RSC streams carrying large Flight rows. On a page with ~1.4 MB of client component props, the Worker spent about a second of CPU per request rescanning the row.
  • vinext start and vite dev log uncaught errors and unhandled rejections instead of exiting, as next start does. @jgeurts stopped client disconnects from being reported to onRequestError as render failures.
  • <html data-scroll-behavior="smooth"> turns off smooth scrolling during route changes, as in Next.js. @williamrogersdev reported and fixed this for the App Router.
  • skipTrailingSlashRedirect was parsed but ignored, and now works.
  • @wxpartners-web reported that one cancelled request could stop the Static Assets response cache from serving prerendered pages for the rest of the isolate. That's fixed.
  • Typed cloudflare.config.ts projects using Response Store in self-contained mode turned on Workers Cache for the whole Worker, so Cloudflare could serve application responses from its cache before the Worker ran. Every config Vinext generates now keeps it off except for the cache's own entrypoint. Rebuild and deploy to pick up the fix.
  • Response Store answers a matching If-None-Match with a 304, as Next.js does, and a crawler's conditional request can no longer store an empty 304 for everyone else.
  • server-only and "use server" modules are handled in browser Web Workers as they are in the rest of the client build. A Pages Router page that imports from middleware or an API route can no longer pull a server-only module into the client bundle.
  • Pages Router builds work with vite preview on Node.
  • --warm-cache-include-fallbacks never warmed fallback shells, and is now a deprecated no-op.

Thanks to everyone who contributed to 1.2: @jeffreywescott, @jgeurts, @MattieTK, @Nsttt and @williamrogersdev, and to @AhmedElBanna80, @Mauladen and @wxpartners-web for their bug reports.

The full lists of changes are in the release notes for vinext 1.2.0, @vinext/cloudflare 1.1.1 and create-vinext-app 1.1.0.