diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 862fd51b..e2056b92 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -8,19 +8,22 @@
* Pages Router:
* - Static pages → render to HTML
* - getStaticProps pages → call at build time, render with props
- * - Dynamic routes → call getStaticPaths (must be fallback: false), render each
+ * - Dynamic routes → call getStaticPaths, render each (fallback: false required)
+ * - Dynamic routes without getStaticPaths → warning, skipped
* - getServerSideProps → build error
* - API routes → skipped with warning
*
* App Router:
* - Static pages → run Server Components at build time, render to HTML
* - Dynamic routes → call generateStaticParams(), render each
- * - Dynamic routes without generateStaticParams → build error
+ * - Dynamic routes without generateStaticParams → warning, skipped
*/
import type { ViteDevServer } from "vite";
import type { Route } from "../routing/pages-router.js";
import type { AppRoute } from "../routing/app-router.js";
-import type { ResolvedNextConfig } from "../config/next-config.js";
+import { loadNextConfig, resolveNextConfig, type ResolvedNextConfig, type NextConfig } from "../config/next-config.js";
+import { pagesRouter, apiRouter } from "../routing/pages-router.js";
+import { appRouter } from "../routing/app-router.js";
import { safeJsonStringify } from "../server/html.js";
import { escapeAttr } from "../shims/head.js";
import path from "node:path";
@@ -29,6 +32,36 @@ import React from "react";
import { renderToReadableStream } from "react-dom/server.edge";
import { createValidFileMatcher, type ValidFileMatcher } from "../routing/file-matcher.js";
+/**
+ * Create a temporary Vite dev server for a project root.
+ *
+ * Always uses configFile: false with vinext loaded directly from this
+ * package. Loading from the user's vite.config causes module resolution
+ * issues: the config-file vinext instance resolves @vitejs/plugin-rsc
+ * from a different path than the inline instance, causing instanceof
+ * checks to fail and the RSC middleware to silently not handle requests.
+ *
+ * Pass `listen: true` to bind an HTTP port (needed for fetching pages).
+ */
+async function createTempViteServer(
+ root: string,
+ opts: { listen?: boolean } = {},
+): Promise {
+ const vite = await import("vite");
+ const { default: vinextPlugin } = await import("../index.js");
+
+ const server = await vite.createServer({
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ });
+ if (opts.listen) await server.listen();
+ return server;
+}
+
function findFileWithExtensions(basePath: string, matcher: ValidFileMatcher): boolean {
return matcher.dottedExtensions.some((ext) => fs.existsSync(basePath + ext));
}
@@ -120,12 +153,11 @@ export async function staticExportPages(
}
if (route.isDynamic) {
- // Dynamic route — must have getStaticPaths
+ // Dynamic route — needs getStaticPaths to enumerate params
if (typeof pageModule.getStaticPaths !== "function") {
- result.errors.push({
- route: route.pattern,
- error: `Dynamic route requires getStaticPaths with output: 'export'`,
- });
+ result.warnings.push(
+ `Dynamic route ${route.pattern} has no getStaticPaths() — skipping (no pages generated)`,
+ );
continue;
}
@@ -204,7 +236,7 @@ export async function staticExportPages(
routerShim,
});
- const outputPath = getOutputPath(urlPath, config.trailingSlash);
+ const outputPath = getOutputPath(urlPath, config.trailingSlash, outDir);
const fullPath = path.join(outDir, outputPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, html, "utf-8");
@@ -286,85 +318,86 @@ async function renderStaticPage(options: RenderStaticPageOptions): Promise = {};
+
+ if (typeof pageModule.getStaticProps === "function") {
+ const result = await pageModule.getStaticProps({ params });
+ if (result && "props" in result) {
+ pageProps = result.props as Record;
+ }
+ if (result && "redirect" in result) {
+ // Static export can't handle redirects — write a meta redirect
+ const redirect = result.redirect as { destination: string };
+ return ``;
+ }
+ if (result && "notFound" in result && result.notFound) {
+ throw new Error(`Page ${urlPath} returned notFound: true`);
+ }
+ }
- // Collect page props
- let pageProps: Record = {};
+ // Build element
+ const createElement = React.createElement;
+ let element: React.ReactElement;
- if (typeof pageModule.getStaticProps === "function") {
- const result = await pageModule.getStaticProps({ params });
- if (result && "props" in result) {
- pageProps = result.props as Record;
+ if (AppComponent) {
+ element = createElement(AppComponent, {
+ Component: PageComponent,
+ pageProps,
+ });
+ } else {
+ element = createElement(PageComponent, pageProps);
}
- if (result && "redirect" in result) {
- // Static export can't handle redirects — write a meta redirect
- const redirect = result.redirect as { destination: string };
- return ``;
+
+ // Reset head collector and flush dynamic preloads
+ if (typeof headShim.resetSSRHead === "function") {
+ headShim.resetSSRHead();
}
- if (result && "notFound" in result && result.notFound) {
- throw new Error(`Page ${urlPath} returned notFound: true`);
+ if (typeof dynamicShim.flushPreloads === "function") {
+ await dynamicShim.flushPreloads();
}
- }
- // Build element
- const createElement = React.createElement;
- let element: React.ReactElement;
+ // Render page body
+ const bodyHtml = await renderToStringAsync(element);
- if (AppComponent) {
- element = createElement(AppComponent, {
- Component: PageComponent,
- pageProps,
- });
- } else {
- element = createElement(PageComponent, pageProps);
- }
+ // Collect head tags
+ const ssrHeadHTML =
+ typeof headShim.getSSRHeadHTML === "function"
+ ? headShim.getSSRHeadHTML()
+ : "";
- // Reset head collector and flush dynamic preloads
- if (typeof headShim.resetSSRHead === "function") {
- headShim.resetSSRHead();
- }
- if (typeof dynamicShim.flushPreloads === "function") {
- await dynamicShim.flushPreloads();
- }
+ // __NEXT_DATA__ for client hydration
+ const nextDataScript = ``;
- // Render page body
- const bodyHtml = await renderToStringAsync(element);
-
- // Collect head tags
- const ssrHeadHTML =
- typeof headShim.getSSRHeadHTML === "function"
- ? headShim.getSSRHeadHTML()
- : "";
-
- // __NEXT_DATA__ for client hydration
- const nextDataScript = ``;
-
- // Build HTML shell
- let html: string;
-
- if (DocumentComponent) {
- const docElement = createElement(DocumentComponent);
- // renderToReadableStream auto-prepends when root is
- let docHtml = await renderToStringAsync(docElement);
- docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml);
- if (ssrHeadHTML) {
- docHtml = docHtml.replace("", ` ${ssrHeadHTML}\n`);
- }
- docHtml = docHtml.replace("", nextDataScript);
- if (!docHtml.includes("__NEXT_DATA__")) {
- docHtml = docHtml.replace("
", ` ${nextDataScript}\n`);
- }
- html = docHtml;
- } else {
- html = `
+ // Build HTML shell
+ let html: string;
+
+ if (DocumentComponent) {
+ const docElement = createElement(DocumentComponent);
+ // renderToReadableStream auto-prepends when root is
+ let docHtml = await renderToStringAsync(docElement);
+ docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml);
+ if (ssrHeadHTML) {
+ docHtml = docHtml.replace("", ` ${ssrHeadHTML}\n`);
+ }
+ docHtml = docHtml.replace("", nextDataScript);
+ if (!docHtml.includes("__NEXT_DATA__")) {
+ docHtml = docHtml.replace("", ` ${nextDataScript}\n`);
+ }
+ html = docHtml;
+ } else {
+ html = `
@@ -376,14 +409,15 @@ async function renderStaticPage(options: RenderStaticPageOptions): Promise
`;
- }
+ }
- // Clear SSR context
- if (typeof routerShim.setSSRContext === "function") {
- routerShim.setSSRContext(null);
+ return html;
+ } finally {
+ // Always clear SSR context, even if rendering throws
+ if (typeof routerShim.setSSRContext === "function") {
+ routerShim.setSSRContext(null);
+ }
}
-
- return html;
}
interface RenderErrorPageOptions {
@@ -502,21 +536,31 @@ function buildUrlFromParams(
}
/**
- * Determine the output file path for a given URL.
- * Respects trailingSlash config.
+ * Determine the output file path for a given URL and verify it stays
+ * within the output directory. Respects trailingSlash config.
+ *
+ * `outDir` is the resolved absolute path of the output directory.
+ * After computing the relative output path, the function resolves it
+ * against `outDir` and checks that it doesn't escape the boundary
+ * (e.g. via crafted `generateStaticParams` / `getStaticPaths` values).
*/
-function getOutputPath(urlPath: string, trailingSlash: boolean): string {
+function getOutputPath(urlPath: string, trailingSlash: boolean, outDir: string): string {
if (urlPath === "/") {
return "index.html";
}
- // Remove leading slash
- const clean = urlPath.replace(/^\//, "");
+ const normalized = path.posix.normalize(urlPath);
+ const clean = normalized.replace(/^\//, "");
- if (trailingSlash) {
- return `${clean}/index.html`;
+ const relative = trailingSlash ? `${clean}/index.html` : `${clean}.html`;
+
+ const resolved = path.resolve(outDir, relative);
+ const resolvedOutDir = path.resolve(outDir);
+ if (!resolved.startsWith(resolvedOutDir + path.sep)) {
+ throw new Error(`Output path "${urlPath}" escapes the output directory`);
}
- return `${clean}.html`;
+
+ return relative;
}
/**
@@ -607,6 +651,42 @@ async function resolveParentParams(
return currentParams;
}
+/**
+ * Expand a dynamic App Router route into concrete URLs via generateStaticParams.
+ * Handles parent param resolution (top-down passing).
+ * Returns the list of expanded URLs, or an empty array if the route has no params.
+ */
+async function expandDynamicAppRoute(
+ route: AppRoute,
+ allRoutes: AppRoute[],
+ server: ViteDevServer,
+ generateStaticParams: (opts: { params: Record }) => Promise[]>,
+): Promise {
+ const parentParamSets = await resolveParentParams(route, allRoutes, server);
+
+ let paramSets: Record[];
+ try {
+ if (parentParamSets.length > 0) {
+ paramSets = [];
+ for (const parentParams of parentParamSets) {
+ const childResults = await generateStaticParams({ params: parentParams });
+ if (Array.isArray(childResults)) {
+ for (const childParams of childResults) {
+ paramSets.push({ ...parentParams, ...childParams });
+ }
+ }
+ }
+ } else {
+ paramSets = await generateStaticParams({ params: {} });
+ }
+ } catch (e) {
+ throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`);
+ }
+
+ if (!Array.isArray(paramSets)) return [];
+ return paramSets.map((params) => buildUrlFromParams(route.pattern, params));
+}
+
// -------------------------------------------------------------------
// App Router static export
// -------------------------------------------------------------------
@@ -665,46 +745,24 @@ export async function staticExportApp(
const pageModule = await server.ssrLoadModule(route.pagePath);
if (typeof pageModule.generateStaticParams !== "function") {
- result.errors.push({
- route: route.pattern,
- error: `Dynamic route requires generateStaticParams() with output: 'export'`,
- });
+ result.warnings.push(
+ `Dynamic route ${route.pattern} has no generateStaticParams() — skipping (no pages generated)`,
+ );
continue;
}
- // Resolve parent dynamic segments for top-down params passing.
- // Find all other routes whose patterns are prefixes of this route's pattern
- // and that have dynamic params, then collect their generateStaticParams.
- const parentParamSets = await resolveParentParams(route, routes, server);
-
- let paramSets: Record[];
- if (parentParamSets.length > 0) {
- // Top-down: call child's generateStaticParams for each parent param set
- paramSets = [];
- for (const parentParams of parentParamSets) {
- const childResults = await pageModule.generateStaticParams({ params: parentParams });
- if (Array.isArray(childResults)) {
- for (const childParams of childResults) {
- paramSets.push({ ...parentParams, ...childParams });
- }
- }
- }
- } else {
- // Bottom-up: no parent params, call with empty params
- paramSets = await pageModule.generateStaticParams({ params: {} });
- }
+ const expandedUrls = await expandDynamicAppRoute(
+ route, routes, server, pageModule.generateStaticParams,
+ );
- if (!Array.isArray(paramSets) || paramSets.length === 0) {
+ if (expandedUrls.length === 0) {
result.warnings.push(
`generateStaticParams() for ${route.pattern} returned empty array — no pages generated`,
);
continue;
}
- for (const params of paramSets) {
- const urlPath = buildUrlFromParams(route.pattern, params);
- urlsToRender.push(urlPath);
- }
+ urlsToRender.push(...expandedUrls);
} catch (e) {
result.errors.push({
route: route.pattern,
@@ -726,11 +784,12 @@ export async function staticExportApp(
route: urlPath,
error: `Server returned ${res.status}`,
});
+ await res.body?.cancel(); // release connection
continue;
}
const html = await res.text();
- const outputPath = getOutputPath(urlPath, config.trailingSlash);
+ const outputPath = getOutputPath(urlPath, config.trailingSlash, outDir);
const fullPath = path.join(outDir, outputPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, html, "utf-8");
@@ -763,3 +822,291 @@ export async function staticExportApp(
return result;
}
+
+// -------------------------------------------------------------------
+// High-level orchestrator
+// -------------------------------------------------------------------
+
+export interface RunStaticExportOptions {
+ root: string;
+ outDir?: string;
+ config?: ResolvedNextConfig;
+ configOverride?: Partial;
+}
+
+/**
+ * High-level orchestrator for static export.
+ *
+ * Loads next.config from the project root, detects the router type,
+ * starts a temporary Vite dev server, scans routes, runs the appropriate
+ * static export (Pages or App Router), and returns the result.
+ */
+export async function runStaticExport(
+ options: RunStaticExportOptions,
+): Promise {
+ const { root, configOverride } = options;
+ const outDir = options.outDir ?? path.join(root, "out");
+
+ // 1. Load and resolve config (reuse caller's config if provided)
+ let config: ResolvedNextConfig;
+ if (options.config) {
+ config = options.config;
+ } else {
+ const loadedConfig = await loadNextConfig(root);
+ const merged: NextConfig = { ...loadedConfig, ...configOverride };
+ config = await resolveNextConfig(merged);
+ }
+
+ // 2. Detect router type
+ const appDirCandidates = [
+ path.join(root, "app"),
+ path.join(root, "src", "app"),
+ ];
+ const pagesDirCandidates = [
+ path.join(root, "pages"),
+ path.join(root, "src", "pages"),
+ ];
+
+ const appDir = appDirCandidates.find((d) => fs.existsSync(d));
+ const pagesDir = pagesDirCandidates.find((d) => fs.existsSync(d));
+
+ if (!appDir && !pagesDir) {
+ return {
+ pageCount: 0,
+ files: [],
+ warnings: ["No app/ or pages/ directory found — nothing to export"],
+ errors: [],
+ };
+ }
+
+ // 3. Start a temporary Vite dev server (with listener for HTTP fetching)
+ const server = await createTempViteServer(root, { listen: true });
+
+ try {
+ // 4. Clean output directory
+ fs.rmSync(outDir, { recursive: true, force: true });
+ fs.mkdirSync(outDir, { recursive: true });
+
+ // 5. Scan routes and run export
+ if (appDir) {
+ const addr = server.httpServer?.address();
+ const port = typeof addr === "object" && addr ? addr.port : 0;
+ if (port === 0) {
+ throw new Error("Vite dev server failed to bind to a port");
+ }
+ const baseUrl = `http://localhost:${port}`;
+
+ const routes = await appRouter(appDir);
+ return await staticExportApp({
+ baseUrl,
+ routes,
+ appDir,
+ server,
+ outDir,
+ config,
+ });
+ } else {
+ // Pages Router
+ const routes = await pagesRouter(pagesDir!);
+ const apiRoutes = await apiRouter(pagesDir!);
+ return await staticExportPages({
+ server,
+ routes,
+ apiRoutes,
+ pagesDir: pagesDir!,
+ outDir,
+ config,
+ });
+ }
+ } finally {
+ await server.close();
+ }
+}
+
+// -------------------------------------------------------------------
+// Pre-render static pages (after production build)
+// -------------------------------------------------------------------
+
+export interface PrerenderOptions {
+ root: string;
+ distDir?: string;
+}
+
+export interface PrerenderResult {
+ pageCount: number;
+ files: string[];
+ warnings: string[];
+ skipped: string[];
+}
+
+/**
+ * Pre-render static pages after a production build.
+ *
+ * Starts a temporary production server, detects static routes via a temporary
+ * Vite dev server, fetches each static page, and writes the HTML to
+ * dist/server/pages/.
+ *
+ * Only runs for Pages Router builds. App Router builds skip pre-rendering
+ * because the App Router prod server delegates entirely to the RSC handler
+ * (which manages its own middleware, auth, and streaming pipeline).
+ */
+export async function prerenderStaticPages(
+ options: PrerenderOptions,
+): Promise {
+ const { root } = options;
+ const distDir = options.distDir ?? path.join(root, "dist");
+
+ const result: PrerenderResult = {
+ pageCount: 0,
+ files: [],
+ warnings: [],
+ skipped: [],
+ };
+
+ // Bail if dist/ doesn't exist
+ if (!fs.existsSync(distDir)) {
+ result.warnings.push("dist/ directory not found — run `vinext build` first");
+ return result;
+ }
+
+ // Detect router type from build output
+ const appRouterEntry = path.join(distDir, "server", "index.js");
+ const pagesRouterEntry = path.join(distDir, "server", "entry.js");
+ const isAppRouter = fs.existsSync(appRouterEntry);
+ const isPagesRouter = fs.existsSync(pagesRouterEntry);
+
+ if (!isAppRouter && !isPagesRouter) {
+ result.warnings.push("No server entry found in dist/ — cannot detect router type");
+ return result;
+ }
+
+ // App Router prod server delegates entirely to the RSC handler which manages
+ // its own middleware, auth, and streaming pipeline. Pre-rendered HTML files
+ // would never be served, so skip pre-rendering for App Router builds.
+ if (isAppRouter) {
+ return result;
+ }
+
+ // Collect static routes via source-file inspection (no dev server needed).
+ // We scan the filesystem for routes, then read each source file to detect
+ // server-side exports. This avoids spinning up a Vite dev server just for
+ // route classification. Dynamic routes are skipped since they need
+ // getStaticPaths execution to enumerate param values.
+ const collected = await collectStaticRoutesFromSource(root);
+ result.skipped.push(...collected.skipped);
+
+ if (collected.urls.length === 0) {
+ result.warnings.push("No static routes found — nothing to pre-render");
+ return result;
+ }
+
+ const staticUrls = collected.urls;
+
+ // Start temp production server in-process
+ const { startProdServer } = await import("../server/prod-server.js");
+ const server = await startProdServer({
+ port: 0, // Random available port
+ host: "127.0.0.1",
+ outDir: distDir,
+ });
+ const addr = server.address() as import("node:net").AddressInfo;
+ const port = addr.port;
+
+ try {
+ const pagesOutDir = path.join(distDir, "server", "pages");
+ fs.mkdirSync(pagesOutDir, { recursive: true });
+
+ for (const urlPath of staticUrls) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 10_000);
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
+ signal: controller.signal,
+ });
+
+ if (!res.ok) {
+ result.skipped.push(urlPath);
+ await res.text(); // consume body
+ continue;
+ }
+
+ const html = await res.text();
+ const outputPath = getOutputPath(urlPath, false, pagesOutDir);
+ const fullPath = path.join(pagesOutDir, outputPath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, html, "utf-8");
+
+ result.files.push(outputPath);
+ result.pageCount++;
+ } catch {
+ result.skipped.push(urlPath);
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+
+ return result;
+}
+
+/**
+ * Lightweight route collection for pre-rendering via source-file inspection.
+ *
+ * Scans the pages/ directory and reads each source file to detect exports
+ * like getServerSideProps and revalidate, without starting a Vite dev server.
+ * Dynamic routes are skipped (they need getStaticPaths execution).
+ */
+async function collectStaticRoutesFromSource(root: string): Promise {
+ const pagesDirCandidates = [
+ path.join(root, "pages"),
+ path.join(root, "src", "pages"),
+ ];
+ const pagesDir = pagesDirCandidates.find((d) => fs.existsSync(d));
+ if (!pagesDir) return { urls: [], skipped: [] };
+
+ const routes = await pagesRouter(pagesDir);
+ const urls: string[] = [];
+ const skipped: string[] = [];
+
+ // Patterns that indicate a page has server-side data fetching
+ const gsspPattern = /export\s+(async\s+)?function\s+getServerSideProps|export\s+(const|let|var)\s+getServerSideProps/;
+ const revalidateZeroPattern = /export\s+const\s+revalidate\s*=\s*0\b/;
+
+ for (const route of routes) {
+ const routeName = path.basename(route.filePath, path.extname(route.filePath));
+ if (routeName.startsWith("_")) continue;
+
+ if (route.isDynamic) {
+ skipped.push(`${route.pattern} (dynamic)`);
+ continue;
+ }
+
+ try {
+ const source = fs.readFileSync(route.filePath, "utf-8");
+
+ if (gsspPattern.test(source)) {
+ skipped.push(`${route.pattern} (getServerSideProps)`);
+ continue;
+ }
+
+ if (revalidateZeroPattern.test(source)) {
+ skipped.push(`${route.pattern} (revalidate: 0)`);
+ continue;
+ }
+
+ urls.push(route.pattern);
+ } catch {
+ skipped.push(`${route.pattern} (failed to read source)`);
+ }
+ }
+
+ return { urls, skipped };
+}
+
+interface CollectedRoutes {
+ urls: string[];
+ skipped: string[];
+}
+
diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts
index 0b4f253e..62ef336c 100644
--- a/packages/vinext/src/cli.ts
+++ b/packages/vinext/src/cli.ts
@@ -270,6 +270,60 @@ async function buildApp() {
}));
}
+ // ── Static export (output: "export") ──────────────────────────
+ const { loadNextConfig, resolveNextConfig } = await import(
+ /* @vite-ignore */ "./config/next-config.js"
+ );
+ const rawConfig = await loadNextConfig(process.cwd());
+ const resolvedConfig = await resolveNextConfig(rawConfig);
+
+ if (resolvedConfig.output === "export") {
+ console.log("\n Static export (output: 'export')...\n");
+
+ const { runStaticExport } = await import(
+ /* @vite-ignore */ "./build/static-export.js"
+ );
+
+ const result = await runStaticExport({ root: process.cwd(), config: resolvedConfig });
+
+ if (result.warnings.length > 0) {
+ for (const w of result.warnings) console.log(` Warning: ${w}`);
+ }
+ if (result.errors.length > 0) {
+ for (const e of result.errors) console.error(` Error (${e.route}): ${e.error}`);
+ }
+
+ console.log(`\n Exported ${result.pageCount} page(s) to out/\n`);
+
+ if (result.errors.length > 0) {
+ process.exit(1);
+ }
+
+ console.log(" Static export complete. Serve with any static file server:\n");
+ console.log(" npx serve out\n");
+ return;
+ }
+
+ // ── Pre-render static pages (non-export builds) ────────────────
+ console.log(" Pre-rendering static pages...\n");
+
+ const { prerenderStaticPages } = await import(
+ /* @vite-ignore */ "./build/static-export.js"
+ );
+
+ const prerenderResult = await prerenderStaticPages({ root: process.cwd() });
+
+ if (prerenderResult.warnings.length > 0) {
+ for (const w of prerenderResult.warnings) console.log(` Warning: ${w}`);
+ }
+ if (prerenderResult.skipped.length > 0) {
+ console.log(` Skipped ${prerenderResult.skipped.length} route(s) (dynamic or errored)`);
+ }
+
+ if (prerenderResult.pageCount > 0) {
+ console.log(`\n Pre-rendered ${prerenderResult.pageCount} static page(s)\n`);
+ }
+
console.log("\n Build complete. Run `vinext start` to start the production server.\n");
}
@@ -282,6 +336,21 @@ async function start() {
mode: "production",
});
+ // Reject static export builds — they don't need a production server
+ const { loadNextConfig, resolveNextConfig } = await import(
+ /* @vite-ignore */ "./config/next-config.js"
+ );
+ const rawConfig = await loadNextConfig(process.cwd());
+ const resolvedConfig = await resolveNextConfig(rawConfig);
+ if (resolvedConfig.output === "export") {
+ console.error(
+ '\n "vinext start" does not work with "output: export" configuration.',
+ );
+ console.error(" Use a static file server instead:\n");
+ console.error(" npx serve out\n");
+ process.exit(1);
+ }
+
const port = parsed.port ?? parseInt(process.env.PORT ?? "3000", 10);
const host = parsed.hostname ?? "0.0.0.0";
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index 33447856..20ab258b 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -280,6 +280,34 @@ function tryServeStatic(
return true;
}
+/**
+ * Check if a pre-rendered HTML file exists for a given pathname.
+ * Returns the absolute file path if found, null otherwise.
+ *
+ * Checks both `/pathname.html` and `/pathname/index.html` to support
+ * both trailingSlash modes. Pre-rendered files are written during
+ * `vinext build` to dist/server/pages/.
+ */
+function resolvePrerenderedHtml(dir: string, pathname: string): string | null {
+ // Normalize: "/" → "index", "/about" → "about", "/blog/post" → "blog/post"
+ const normalized = pathname === "/" ? "index" : pathname.replace(/^\//, "").replace(/\/$/, "");
+
+ // Guard against directory traversal
+ const resolvedDir = path.resolve(dir);
+
+ const directPath = path.join(dir, `${normalized}.html`);
+ if (path.resolve(directPath).startsWith(resolvedDir + path.sep) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) {
+ return directPath;
+ }
+
+ const indexPath = path.join(dir, normalized, "index.html");
+ if (path.resolve(indexPath).startsWith(resolvedDir + path.sep) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
+ return indexPath;
+ }
+
+ return null;
+}
+
/**
* Resolve the host for a request, ignoring X-Forwarded-Host to prevent
* host header poisoning attacks (open redirects, cache poisoning).
@@ -679,6 +707,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy,
} : undefined;
+ // Pre-rendered HTML directory (written by `vinext build` pre-rendering step).
+ // Check existence once at startup to avoid per-request fs.existsSync calls.
+ const pagesPrerenderedDir = path.join(path.dirname(serverEntryPath), "pages");
+ const hasPrerenderedPages = fs.existsSync(pagesPrerenderedDir);
+
const server = createServer(async (req, res) => {
const rawUrl = req.url ?? "/";
// Normalize backslashes (browsers treat /\ as //), then decode and normalize path.
@@ -945,6 +978,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
}
}
+ // ── 7b. Pre-rendered HTML ─────────────────────────────────────
+ // Serve build-time rendered static pages. Placed after middleware,
+ // basePath stripping, redirects, and rewrites so those all run first.
+ const pagesPrerenderedFile = hasPrerenderedPages
+ ? resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname)
+ : null;
+ if (pagesPrerenderedFile) {
+ const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8");
+ const prerenderedHeaders: Record = { ...middlewareHeaders };
+ sendCompressed(req, res, html, "text/html; charset=utf-8", 200, prerenderedHeaders, compress);
+ return;
+ }
+
// ── 8. API routes ─────────────────────────────────────────────
if (resolvedPathname.startsWith("/api/") || resolvedPathname === "/api") {
let response: Response;
diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts
index bc9513ca..8c370f50 100644
--- a/tests/app-router.test.ts
+++ b/tests/app-router.test.ts
@@ -1672,7 +1672,7 @@ describe("App Router Static export", () => {
expect(html404).toContain("Page Not Found");
});
- it("reports errors for dynamic routes without generateStaticParams", async () => {
+ it("warns and skips dynamic routes without generateStaticParams", async () => {
const { staticExportApp } = await import(
"../packages/vinext/src/build/static-export.js"
);
@@ -1714,9 +1714,10 @@ describe("App Router Static export", () => {
config,
});
- // Should have an error about missing generateStaticParams
+ // Should warn (not error) about missing generateStaticParams
+ expect(result.errors).toHaveLength(0);
expect(
- result.errors.some((e) => e.error.includes("generateStaticParams")),
+ result.warnings.some((w) => w.includes("generateStaticParams")),
).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
diff --git a/tests/build-prerender.test.ts b/tests/build-prerender.test.ts
new file mode 100644
index 00000000..45c8f42c
--- /dev/null
+++ b/tests/build-prerender.test.ts
@@ -0,0 +1,164 @@
+/**
+ * Tests for Phase 2 of static pre-rendering.
+ *
+ * Tests:
+ * 1. Production server serving pre-rendered HTML from dist/server/pages/
+ * 2. prerenderStaticPages() function existence and return type
+ */
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import fs from "node:fs";
+import path from "node:path";
+import type { Server } from "node:http";
+
+const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
+
+// ─── Production server — serves pre-rendered HTML ─────────────────────────────
+
+describe("Production server — serves pre-rendered HTML", () => {
+ const outDir = path.resolve(PAGES_FIXTURE, "dist");
+ const serverEntryPath = path.join(outDir, "server", "entry.js");
+ const pagesDir = path.join(outDir, "server", "pages");
+ const prerenderedFile = path.join(pagesDir, "prerendered-test.html");
+ let server: Server;
+ let baseUrl: string;
+
+ beforeAll(async () => {
+ if (!fs.existsSync(serverEntryPath)) {
+ throw new Error(
+ `Fixture not built: ${serverEntryPath} does not exist. ` +
+ `Run "cd ${PAGES_FIXTURE} && pnpm build" first.`,
+ );
+ }
+
+ // Create a fake pre-rendered HTML file at dist/server/pages/prerendered-test.html
+ fs.mkdirSync(pagesDir, { recursive: true });
+ fs.writeFileSync(
+ prerenderedFile,
+ `Pre-renderedPre-rendered test content