Skip to main content

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