Documentation Index Fetch the complete documentation index at: https://mintlify.com/cloudflare/vinext/llms.txt
Use this file to discover all available pages before exploring further.
Virtual Module System
Vinext uses Vite’s virtual module system to generate dynamic entry points for different runtime environments. This guide explains how virtual modules work, how they’re generated, and the resolution patterns used.
What Are Virtual Modules?
Virtual modules are modules that don’t exist on disk. They’re generated at runtime by Vite plugins and resolved via the resolveId and load hooks.
Why use virtual modules?
Generate entry points based on file-system routing
Embed route metadata at build time
Create environment-specific entries (RSC vs SSR vs Client)
Avoid writing generated files to disk
Virtual Module IDs
Vinext defines several virtual modules:
Pages Router
const VIRTUAL_SERVER_ENTRY = "virtual:vinext-server-entry" ;
const RESOLVED_SERVER_ENTRY = " \0 " + VIRTUAL_SERVER_ENTRY ;
const VIRTUAL_CLIENT_ENTRY = "virtual:vinext-client-entry" ;
const RESOLVED_CLIENT_ENTRY = " \0 " + VIRTUAL_CLIENT_ENTRY ;
App Router
const VIRTUAL_RSC_ENTRY = "virtual:vinext-rsc-entry" ;
const RESOLVED_RSC_ENTRY = " \0 " + VIRTUAL_RSC_ENTRY ;
const VIRTUAL_APP_SSR_ENTRY = "virtual:vinext-app-ssr-entry" ;
const RESOLVED_APP_SSR_ENTRY = " \0 " + VIRTUAL_APP_SSR_ENTRY ;
const VIRTUAL_APP_BROWSER_ENTRY = "virtual:vinext-app-browser-entry" ;
const RESOLVED_APP_BROWSER_ENTRY = " \0 " + VIRTUAL_APP_BROWSER_ENTRY ;
The \0 prefix is a Rollup convention indicating a resolved virtual module. It prevents other plugins from attempting to resolve it further.
Resolution Hooks
From packages/vinext/src/index.ts:
resolveId ( id , importer , options ) {
// Handle build-time root prefix
// Vite prefixes virtual module IDs with project root when resolving SSR entries
const normalized = id . replace ( / ^ \0 / , "" ). replace ( root + "/" , "" );
if ( normalized === VIRTUAL_SERVER_ENTRY ) return RESOLVED_SERVER_ENTRY ;
if ( normalized === VIRTUAL_CLIENT_ENTRY ) return RESOLVED_CLIENT_ENTRY ;
if ( normalized === VIRTUAL_RSC_ENTRY ) return RESOLVED_RSC_ENTRY ;
if ( normalized === VIRTUAL_APP_SSR_ENTRY ) return RESOLVED_APP_SSR_ENTRY ;
if ( normalized === VIRTUAL_APP_BROWSER_ENTRY ) return RESOLVED_APP_BROWSER_ENTRY ;
// Handle next/* shim resolution
const shimId = resolveNextShim ( id );
if ( shimId ) return shimId ;
return null ;
},
load ( id ) {
if ( id === RESOLVED_SERVER_ENTRY ) {
return generateServerEntry ();
}
if ( id === RESOLVED_CLIENT_ENTRY ) {
return generateClientEntry ();
}
if ( id === RESOLVED_RSC_ENTRY ) {
return generateRscEntry (
appDir ,
routes ,
middlewarePath ,
metadataRoutes ,
globalErrorPath ,
basePath ,
trailingSlash ,
config ,
);
}
// ...
return null ;
},
Resolution Quirks
Vite has several edge cases in virtual module resolution that must be handled.
1. Build-time root prefix
During SSR builds, Vite prefixes virtual module IDs with the project root path:
Dev: virtual:vinext-server-entry
Build: /home/user/project/virtual:vinext-server-entry
The resolveId hook strips the root prefix before matching.
2. \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 pattern matching:
const normalized = id . replace ( / ^ \0 / , "" );
3. Absolute paths required
Virtual modules have no real file location. All imports within them must use absolute paths :
// ❌ Wrong - relative import
import { createElement } from "react" ;
// ✅ Correct - absolute path
import { createElement } from "/home/user/project/node_modules/react/index.js" ;
Vinext uses import.meta.url and path resolution to generate absolute paths:
const absPath = route . filePath . replace ( / \\ / g , "/" );
imports . push ( `import * as mod_ ${ i } from ${ JSON . stringify ( absPath ) } ;` );
Server Entry Generation
The Pages Router server entry (virtual:vinext-server-entry) is a self-contained SSR handler:
async function generateServerEntry () : Promise < string > {
const pageRoutes = await pagesRouter ( pagesDir );
const apiRoutes = await apiRouter ( pagesDir );
// Generate import statements using absolute paths
const pageImports = pageRoutes . map (( r : Route , i : number ) => {
const absPath = r . filePath . replace ( / \\ / g , "/" );
return `import * as page_ ${ i } from ${ JSON . stringify ( absPath ) } ;` ;
});
const apiImports = apiRoutes . map (( r : Route , i : number ) => {
const absPath = r . filePath . replace ( / \\ / g , "/" );
return `import * as api_ ${ i } from ${ JSON . stringify ( absPath ) } ;` ;
});
// Build route table
const pageRouteEntries = pageRoutes . map (( r : Route , i : number ) => {
const absPath = r . filePath . replace ( / \\ / g , "/" );
return ` {
pattern: ${ JSON . stringify ( r . pattern ) } ,
isDynamic: ${ r . isDynamic } ,
params: ${ JSON . stringify ( r . params ) } ,
module: page_ ${ i } ,
filePath: ${ JSON . stringify ( absPath ) }
}` ;
});
// Embed config at build time
const vinextConfigJson = JSON . stringify ({
basePath: nextConfig ?. basePath ?? "" ,
trailingSlash: nextConfig ?. trailingSlash ?? false ,
redirects: nextConfig ?. redirects ?? [],
rewrites: nextConfig ?. rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] },
headers: nextConfig ?. headers ?? [],
i18n: nextConfig ?. i18n ?? null ,
});
return `
import React from "react";
import { renderToReadableStream } from "react-dom/server.edge";
import { resetSSRHead, getSSRHeadHTML } from "next/head";
import { setSSRContext } from "next/router";
import { getCacheHandler } from "next/cache";
// ... more imports
${ pageImports . join ( " \n " ) }
${ apiImports . join ( " \n " ) }
const pageRoutes = [
${ pageRouteEntries . join ( ", \n " ) }
];
export const vinextConfig = ${ vinextConfigJson } ;
// ... route matching, ISR, rendering logic
export async function renderPage(request, url, manifest) {
// Full SSR implementation
}
` ;
}
Key features:
All page and API routes are imported and registered
Config is embedded at build time (no runtime file I/O)
Middleware matching logic is inlined
ISR cache helpers are included
Supports both development and production
RSC Entry Generation
The App Router RSC entry (virtual:vinext-rsc-entry) is the request handler:
From packages/vinext/src/server/app-dev-server.ts:
export function generateRscEntry (
appDir : string ,
routes : AppRoute [],
middlewarePath ?: string | null ,
metadataRoutes ?: MetadataFileRoute [],
globalErrorPath ?: string | null ,
basePath ?: string ,
trailingSlash ?: boolean ,
config ?: AppRouterConfig ,
) : string {
// Build import map for all route modules
const imports : string [] = [];
const importMap : Map < string , string > = new Map ();
let importIdx = 0 ;
function getImportVar ( filePath : string ) : string {
if ( importMap . has ( filePath )) return importMap . get ( filePath ) ! ;
const varName = `mod_ ${ importIdx ++ } ` ;
const absPath = filePath . replace ( / \\ / g , "/" );
imports . push ( `import * as ${ varName } from ${ JSON . stringify ( absPath ) } ;` );
importMap . set ( filePath , varName );
return varName ;
}
// Pre-register all modules
for ( const route of routes ) {
if ( route . pagePath ) getImportVar ( route . pagePath );
if ( route . routePath ) getImportVar ( route . routePath );
for ( const layout of route . layouts ) getImportVar ( layout );
for ( const tmpl of route . templates ) getImportVar ( tmpl );
// ... register all route modules
}
// Build route table as serialized JS
const routeEntries = routes . map (( route ) => {
const layoutVars = route . layouts . map (( l ) => getImportVar ( l ));
const templateVars = route . templates . map (( t ) => getImportVar ( t ));
return ` {
pattern: ${ JSON . stringify ( route . pattern ) } ,
isDynamic: ${ route . isDynamic } ,
params: ${ JSON . stringify ( route . params ) } ,
page: ${ route . pagePath ? getImportVar ( route . pagePath ) : "null" } ,
routeHandler: ${ route . routePath ? getImportVar ( route . routePath ) : "null" } ,
layouts: [ ${ layoutVars . join ( ", " ) } ],
templates: [ ${ templateVars . join ( ", " ) } ],
// ... all route metadata
}` ;
});
// Embed metadata files as base64
const metaRouteEntries = ( metadataRoutes || []). map (( mr ) => {
if ( mr . isDynamic ) {
return ` {
type: ${ JSON . stringify ( mr . type ) } ,
isDynamic: true,
servedUrl: ${ JSON . stringify ( mr . servedUrl ) } ,
contentType: ${ JSON . stringify ( mr . contentType ) } ,
module: ${ getImportVar ( mr . filePath ) } ,
}` ;
}
// Static: read and embed as base64
let fileDataBase64 = "" ;
try {
const buf = fs . readFileSync ( mr . filePath );
fileDataBase64 = buf . toString ( "base64" );
} catch {}
return ` {
type: ${ JSON . stringify ( mr . type ) } ,
isDynamic: false,
servedUrl: ${ JSON . stringify ( mr . servedUrl ) } ,
contentType: ${ JSON . stringify ( mr . contentType ) } ,
fileDataBase64: ${ JSON . stringify ( fileDataBase64 ) } ,
}` ;
});
return `
import {
renderToReadableStream,
decodeReply,
loadServerAction,
createTemporaryReferenceSet,
} from "@vitejs/plugin-rsc/rsc";
import { createElement, Suspense, Fragment } from "react";
import { setNavigationContext, getNavigationContext } from "next/navigation";
import { setHeadersContext, headersContextFromRequest, runWithHeadersContext } from "next/headers";
import { NextRequest } from "next/server";
import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
import { MetadataHead, ViewportHead } from "vinext/metadata";
// ... more imports
${ imports . join ( " \n " ) }
const routes = [
${ routeEntries . join ( ", \n " ) }
];
const metadataRoutes = [
${ metaRouteEntries . join ( ", \n " ) }
];
// ... route matching, component tree building, rendering
export default async function handler(request) {
return runWithHeadersContext(headersContextFromRequest(request), async () => {
return runWithFetchCache(async () => {
return _handleRequest(request);
});
});
}
` ;
}
Key features:
All routes are discovered and imported at build time
Metadata files are embedded as base64 (works on Workers with no filesystem)
Full middleware, rewrite, redirect, and header logic is inlined
Component tree building logic is included
Server action handling is built-in
Client Entry Generation
The Pages Router client entry (virtual:vinext-client-entry) bootstraps React hydration:
function generateClientEntry () : string {
return `
import { hydrateRoot } from "react-dom/client";
import React from "react";
// Hydrate the page
const root = document.getElementById("__next");
if (root) {
const pageData = window.__NEXT_DATA__;
const { page, props } = pageData;
// Dynamically import the page component
import(/* @vite-ignore */ page).then((mod) => {
const PageComponent = mod.default;
hydrateRoot(root, React.createElement(PageComponent, props));
});
}
` ;
}
The App Router browser entry (virtual:vinext-app-browser-entry) is generated by the RSC plugin and consumes the RSC stream for hydration.
SSR Entry Generation
The App Router SSR entry (virtual:vinext-app-ssr-entry) consumes the RSC stream and renders HTML:
export function generateSsrEntry () : string {
return `
import { renderToReadableStream } from "react-dom/server.edge";
import { createFromReadableStream } from "react-server-dom-webpack/client.edge";
import { setNavigationContext } from "next/navigation";
import { createElement } from "react";
export async function handleSsr(rscStream, navContext, fontData) {
// Set navigation context in SSR environment
setNavigationContext(navContext);
// Consume RSC stream to build React tree
const tree = await createFromReadableStream(rscStream);
// Inject font styles
const head = [
...fontData.styles.map(s => createElement("style", {
key: s.href,
dangerouslySetInnerHTML: { __html: s.content }
})),
];
const app = createElement("html", null,
createElement("head", null, ...head),
createElement("body", null,
createElement("div", { id: "__next" }, tree)
)
);
// Render to HTML stream
return renderToReadableStream(app);
}
` ;
}
Why separate SSR entry?
The RSC and SSR environments have different import conditions:
RSC uses react-server (server-only APIs)
SSR uses node (React client APIs for SSR)
They must use separate module graphs to avoid mixing incompatible APIs.
Shim Resolution
All next/* imports are resolved to shim modules:
function resolveNextShim ( id : string ) : string | null {
// Strip .js extension (some libraries import "next/navigation.js")
const normalized = id . replace ( / \. js $ / , "" );
if ( nextShimMap [ normalized ]) {
return nextShimMap [ normalized ];
}
return null ;
}
// In config hook:
nextShimMap = {
"next/link" : path . resolve ( shimsDir , "link.ts" ),
"next/image" : path . resolve ( shimsDir , "image.ts" ),
"next/head" : path . resolve ( shimsDir , "head.ts" ),
"next/router" : path . resolve ( shimsDir , "router.ts" ),
"next/navigation" : path . resolve ( shimsDir , "navigation.ts" ),
"next/server" : path . resolve ( shimsDir , "server.ts" ),
"next/headers" : path . resolve ( shimsDir , "headers.ts" ),
"next/dynamic" : path . resolve ( shimsDir , "dynamic.ts" ),
"next/script" : path . resolve ( shimsDir , "script.ts" ),
// ... 33 total shims
};
Shims are resolved to absolute paths so they work from any importer location.
Next Steps
Architecture Deep Dive Core architecture and patterns
RSC Integration React Server Components integration
Build Pipeline Production build pipeline
Contributing Contribute to Vinext