Vinext is a Vite plugin that reimplements the Next.js API surface on top of Vite’s infrastructure, with Cloudflare Workers as the primary deployment target. This guide explains the architectural decisions, implementation patterns, and internal mechanics.
Vinext is implemented as a standard Vite plugin that hooks into Vite’s lifecycle:
Resolves all next/* imports to local shim modules
Scans pages/ and app/ directories to build file-system routes
Generates virtual entry modules for RSC, SSR, and browser environments
Integrates with @vitejs/plugin-rsc for React Server Components
From packages/vinext/src/index.ts:
export default function vinext(options: VinextOptions = {}): Plugin[] { let root: string; let pagesDir: string; let appDir: string; let hasAppDir = false; let hasPagesDir = false; let nextConfig: ResolvedNextConfig; let middlewarePath: string | null = null; // Shim alias map for resolving next/* imports let nextShimMap: Record<string, string> = {}; // ...}
Request → Vite dev server middleware → Route match → getServerSideProps/getStaticProps → renderToReadableStream(App + Page) → HTML with __NEXT_DATA__ → Client hydration
This is the single most important architectural detail. Understanding this prevents the most common bugs.
The RSC environment and SSR environment are separate Vite module graphs with separate module instances.If you set state in a module in the RSC environment (e.g., setNavigationContext() in next/navigation), the SSR environment’s copy of that module is unaffected.Rule of thumb: Any per-request state that "use client" components need during SSR must be explicitly passed from the RSC entry to the SSR entry via the handleSsr(rscStream, navContext) call.From packages/vinext/src/server/app-dev-server.ts:
// RSC environment: Set navigation contextsetNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params: {},});// Build RSC streamconst rscStream = renderToReadableStream(element, { onError: rscOnError });// Collect font data from RSC environmentconst fontData = { links: _getSSRFontLinks(), styles: _getSSRFontStyles(), preloads: _getSSRFontPreloads(),};// Delegate to SSR environment with contextconst ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index");const htmlStream = await ssrEntry.handleSsr( rscStream, _getNavigationContext(), // Explicitly pass context fontData);
The SSR entry receives this context and calls the setter in its own module instance before rendering client components.
Virtual modules have no real file location, so all imports within them must use absolute paths.
Build-time root prefix:
Vite prefixes virtual module IDs with the project root path when resolving SSR build entries. The resolveId hook must handle both:
virtual:vinext-server-entry
<root>/virtual:vinext-server-entry
\0 prefix in client environment:
When the RSC plugin generates its browser entry, it imports virtual modules using the already-resolved \0-prefixed ID. Vite’s import-analysis plugin can’t resolve this. Fix: strip the \0 prefix before matching.From packages/vinext/src/index.ts:
const VIRTUAL_RSC_ENTRY = "virtual:vinext-rsc-entry";const RESOLVED_RSC_ENTRY = "\0" + VIRTUAL_RSC_ENTRY;// In resolveId hook:resolveId(id) { // Handle both prefixed and unprefixed const normalized = id.replace(/^\0/, "").replace(root + "/", ""); if (normalized === VIRTUAL_RSC_ENTRY) return RESOLVED_RSC_ENTRY; // ...}
Each route tracks per-layout error boundaries and not-found pages:
// layoutErrorPaths: aligned with layouts array// Each entry is the error.tsx at that layout's directorylayoutErrorPaths: (string | null)[];// notFoundPaths: per-layout not-found boundariesnotFoundPaths: (string | null)[];
This enables proper error boundary nesting: errors from layout N are caught by the boundary at layout N-1, matching Next.js behavior.