From 5e56610fa8f4216fa3de913790e3efd50cf3a8aa Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 04:57:43 +1100
Subject: [PATCH 01/12] feat: wire output: 'export' to vinext build
Add runStaticExport() orchestrator that connects the existing
staticExportPages/staticExportApp functions to the CLI build pipeline.
When next.config has output: 'export', vinext build now:
1. Runs the Vite build
2. Starts a temporary dev server
3. Renders all static pages to out/
4. Prints a summary
Also adds 'vinext start' rejection for export builds, matching
Next.js behavior (ported from test/e2e/app-dir-export/test/start.test.ts).
Phase 1 of #9
---
packages/vinext/src/build/static-export.ts | 105 +++++++++++++-
packages/vinext/src/cli.ts | 49 +++++++
tests/build-static-export.test.ts | 156 +++++++++++++++++++++
3 files changed, 309 insertions(+), 1 deletion(-)
create mode 100644 tests/build-static-export.test.ts
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 687105b5..94cecb45 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -20,7 +20,9 @@
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";
@@ -761,3 +763,104 @@ export async function staticExportApp(
return result;
}
+
+// -------------------------------------------------------------------
+// High-level orchestrator
+// -------------------------------------------------------------------
+
+export interface RunStaticExportOptions {
+ root: string;
+ outDir?: string;
+ 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
+ const loadedConfig = await loadNextConfig(root);
+ const merged: NextConfig = { ...loadedConfig, ...configOverride };
+ const 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
+ const { default: vinextPlugin } = await import("../index.js");
+ const vite = await import("vite");
+ const server = await vite.createServer({
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ });
+ await server.listen();
+
+ 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;
+ 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();
+ }
+}
diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts
index 0b4f253e..fc033125 100644
--- a/packages/vinext/src/cli.ts
+++ b/packages/vinext/src/cli.ts
@@ -270,6 +270,40 @@ 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() });
+
+ 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;
+ }
+
console.log("\n Build complete. Run `vinext start` to start the production server.\n");
}
@@ -282,6 +316,21 @@ async function start() {
mode: "production",
});
+ // Reject static export builds — they don't need a production server
+ const outExportDir = path.resolve(process.cwd(), "out");
+ const distServerDir = path.resolve(process.cwd(), "dist", "server");
+ if (
+ fs.existsSync(path.join(outExportDir, "index.html")) &&
+ !fs.existsSync(distServerDir)
+ ) {
+ 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/tests/build-static-export.test.ts b/tests/build-static-export.test.ts
new file mode 100644
index 00000000..6ca29593
--- /dev/null
+++ b/tests/build-static-export.test.ts
@@ -0,0 +1,156 @@
+/**
+ * Failing tests for runStaticExport() — the high-level orchestrator that
+ * takes a project root, starts a temporary Vite dev server, scans routes,
+ * runs the appropriate static export (Pages or App Router), and returns
+ * a StaticExportResult.
+ *
+ * runStaticExport() does NOT exist yet. These tests define the contract
+ * and should fail with an import error until the implementation lands.
+ */
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import fs from "node:fs";
+import path from "node:path";
+import type { StaticExportResult } from "../packages/vinext/src/build/static-export.js";
+import { runStaticExport } from "../packages/vinext/src/build/static-export.js";
+
+const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
+const APP_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/app-basic");
+
+// ─── Pages Router ────────────────────────────────────────────────────────────
+
+describe("runStaticExport — Pages Router", () => {
+ let result: StaticExportResult;
+ const outDir = path.resolve(PAGES_FIXTURE, "out-run-static-pages");
+
+ beforeAll(async () => {
+ result = await runStaticExport({
+ root: PAGES_FIXTURE,
+ outDir,
+ configOverride: { output: "export" },
+ });
+ }, 60_000);
+
+ afterAll(() => {
+ fs.rmSync(outDir, { recursive: true, force: true });
+ });
+
+ it("produces HTML files in outDir", () => {
+ expect(result.pageCount).toBeGreaterThan(0);
+ expect(result.files.length).toBeGreaterThan(0);
+
+ // Every listed file should physically exist on disk
+ for (const file of result.files) {
+ const fullPath = path.join(outDir, file);
+ expect(fs.existsSync(fullPath), `expected ${file} to exist`).toBe(true);
+ }
+ });
+
+ it("generates index.html", () => {
+ expect(result.files).toContain("index.html");
+ expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true);
+ });
+
+ it("generates about.html", () => {
+ expect(result.files).toContain("about.html");
+ expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true);
+ });
+
+ it("generates 404.html", () => {
+ expect(result.files).toContain("404.html");
+ expect(fs.existsSync(path.join(outDir, "404.html"))).toBe(true);
+ });
+
+ it("expands dynamic routes via getStaticPaths", () => {
+ // pages-basic/pages/blog/[slug].tsx defines hello-world and getting-started
+ expect(result.files).toContain("blog/hello-world.html");
+ expect(result.files).toContain("blog/getting-started.html");
+ });
+
+ it("reports errors for getServerSideProps pages, not crashes", () => {
+ // pages-basic has pages that use getServerSideProps (e.g. ssr.tsx).
+ // These should appear as structured errors, not thrown exceptions.
+ const gsspErrors = result.errors.filter((e) =>
+ e.error.includes("getServerSideProps"),
+ );
+ expect(gsspErrors.length).toBeGreaterThan(0);
+ });
+
+ it("returns warnings array (possibly empty)", () => {
+ expect(Array.isArray(result.warnings)).toBe(true);
+ });
+});
+
+// ─── App Router ──────────────────────────────────────────────────────────────
+
+describe("runStaticExport — App Router", () => {
+ let result: StaticExportResult;
+ const outDir = path.resolve(APP_FIXTURE, "out-run-static-app");
+
+ beforeAll(async () => {
+ result = await runStaticExport({
+ root: APP_FIXTURE,
+ outDir,
+ configOverride: { output: "export" },
+ });
+ }, 60_000);
+
+ afterAll(() => {
+ fs.rmSync(outDir, { recursive: true, force: true });
+ });
+
+ it("produces HTML files in outDir", () => {
+ expect(result.pageCount).toBeGreaterThan(0);
+ expect(result.files.length).toBeGreaterThan(0);
+
+ for (const file of result.files) {
+ const fullPath = path.join(outDir, file);
+ expect(fs.existsSync(fullPath), `expected ${file} to exist`).toBe(true);
+ }
+ });
+
+ it("generates index.html", () => {
+ expect(result.files).toContain("index.html");
+ expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true);
+ });
+
+ it("generates about.html", () => {
+ expect(result.files).toContain("about.html");
+ expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true);
+ });
+
+ it("expands dynamic routes via generateStaticParams", () => {
+ // app-basic/app/blog/[slug]/page.tsx defines hello-world, getting-started, advanced-guide
+ expect(result.files).toContain("blog/hello-world.html");
+ expect(result.files).toContain("blog/getting-started.html");
+ expect(result.files).toContain("blog/advanced-guide.html");
+ });
+
+ it("generates 404.html", () => {
+ expect(result.files).toContain("404.html");
+ expect(fs.existsSync(path.join(outDir, "404.html"))).toBe(true);
+ });
+
+ it("produces a warning (not error) for empty generateStaticParams", () => {
+ // If a dynamic route's generateStaticParams returns [], it should be a
+ // warning — the route is simply skipped — not a hard error.
+ // This is tested structurally: warnings are strings, errors have { route, error }.
+ // The existing staticExportApp already handles this as a warning.
+ for (const w of result.warnings) {
+ expect(typeof w).toBe("string");
+ }
+ for (const e of result.errors) {
+ expect(e).toHaveProperty("route");
+ expect(e).toHaveProperty("error");
+ // No error should mention "empty" generateStaticParams — that goes in warnings
+ expect(e.error).not.toMatch(/returned empty array/);
+ }
+ });
+
+ it("returns no errors for the core static pages", () => {
+ // index and about are plain server components — no dynamic API, no errors expected.
+ const coreRouteErrors = result.errors.filter(
+ (e) => e.route === "/" || e.route === "/about",
+ );
+ expect(coreRouteErrors).toEqual([]);
+ });
+});
From 2149bed7530f4769db2e84680a9e2c6b0debbe23 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 05:20:11 +1100
Subject: [PATCH 02/12] feat: add prerenderStaticPages() for build-time
pre-rendering
Add Phase 2 orchestrator that pre-renders static pages after a
production build:
1. Starts a temporary prod server in-process
2. Detects static routes via Vite dev server module inspection
3. Fetches each static route and writes HTML to dist/server/pages/
For Pages Router: skips pages with getServerSideProps, expands
dynamic routes via getStaticPaths (fallback: false only).
For App Router: skips force-dynamic pages, expands dynamic routes
via generateStaticParams with parent param resolution.
Phase 2 of #9
---
packages/vinext/src/build/static-export.ts | 240 +++++++++++++++++++++
tests/build-prerender.test.ts | 162 ++++++++++++++
2 files changed, 402 insertions(+)
create mode 100644 tests/build-prerender.test.ts
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 94cecb45..2390bf87 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -864,3 +864,243 @@ export async function runStaticExport(
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/.
+ *
+ * Prefers starting the prod server in-process via `startProdServer()`.
+ * Falls back to a subprocess when the in-process import fails (e.g. when
+ * running from compiled JS where the import path differs).
+ */
+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;
+ }
+
+ // Collect static routes using a temporary Vite dev server
+ const staticUrls = await collectStaticRoutes(root, isAppRouter);
+
+ if (staticUrls.length === 0) {
+ result.warnings.push("No static routes found — nothing to pre-render");
+ return result;
+ }
+
+ // 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) {
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 10_000);
+ const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+
+ if (!res.ok) {
+ result.skipped.push(urlPath);
+ await res.text(); // consume body
+ continue;
+ }
+
+ const html = await res.text();
+ const outputPath = getOutputPath(urlPath, false);
+ 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 {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+
+ return result;
+}
+
+/**
+ * Collect static routes by starting a temporary Vite dev server and
+ * inspecting page module exports.
+ */
+async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise {
+ // Detect source directories
+ 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 (isAppRouter && !appDir) return [];
+ if (!isAppRouter && !pagesDir) return [];
+
+ // Start a temporary Vite dev server for module inspection
+ const { default: vinextPlugin } = await import("../index.js");
+ const vite = await import("vite");
+ const server = await vite.createServer({
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ });
+ await server.listen();
+
+ try {
+ const urls: string[] = [];
+
+ if (isAppRouter && appDir) {
+ const routes = await appRouter(appDir);
+ for (const route of routes) {
+ // Skip route handlers (API routes)
+ if (route.routePath && !route.pagePath) continue;
+ if (!route.pagePath) continue;
+
+ try {
+ const pageModule = await server.ssrLoadModule(route.pagePath);
+
+ // Skip force-dynamic pages
+ if (pageModule.dynamic === "force-dynamic") continue;
+
+ if (route.isDynamic) {
+ // Need generateStaticParams to expand
+ if (typeof pageModule.generateStaticParams !== "function") continue;
+
+ const parentParamSets = await resolveParentParams(route, routes, server);
+ let paramSets: Record[];
+
+ if (parentParamSets.length > 0) {
+ 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 {
+ paramSets = await pageModule.generateStaticParams({ params: {} });
+ }
+
+ if (Array.isArray(paramSets)) {
+ for (const params of paramSets) {
+ urls.push(buildUrlFromParams(route.pattern, params));
+ }
+ }
+ } else {
+ urls.push(route.pattern);
+ }
+ } catch {
+ // Skip routes that fail to load
+ }
+ }
+ } else if (pagesDir) {
+ const routes = await pagesRouter(pagesDir);
+ for (const route of routes) {
+ // Skip internal pages
+ const routeName = path.basename(route.filePath, path.extname(route.filePath));
+ if (routeName.startsWith("_")) continue;
+
+ try {
+ const pageModule = await server.ssrLoadModule(route.filePath);
+
+ // Skip pages with getServerSideProps
+ if (typeof pageModule.getServerSideProps === "function") continue;
+
+ if (route.isDynamic) {
+ // Need getStaticPaths with fallback: false
+ if (typeof pageModule.getStaticPaths !== "function") continue;
+
+ const pathsResult = await pageModule.getStaticPaths({
+ locales: [],
+ defaultLocale: "",
+ });
+ if (pathsResult?.fallback !== false) continue;
+
+ const paths: Array<{ params: Record }> =
+ pathsResult?.paths ?? [];
+ for (const { params } of paths) {
+ urls.push(buildUrlFromParams(route.pattern, params));
+ }
+ } else {
+ urls.push(route.pattern);
+ }
+ } catch {
+ // Skip routes that fail to load
+ }
+ }
+ }
+
+ return urls;
+ } finally {
+ await server.close();
+ }
+}
+
diff --git a/tests/build-prerender.test.ts b/tests/build-prerender.test.ts
new file mode 100644
index 00000000..c60feb32
--- /dev/null
+++ b/tests/build-prerender.test.ts
@@ -0,0 +1,162 @@
+/**
+ * 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");
+ const hasServerEntry = fs.existsSync(serverEntryPath);
+
+ let server: Server;
+ let baseUrl: string;
+
+ beforeAll(async () => {
+ if (!hasServerEntry) return;
+
+ // 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
`,
+ "utf-8",
+ );
+
+ const { startProdServer } = await import(
+ "../packages/vinext/src/server/prod-server.js"
+ );
+ server = await startProdServer({
+ port: 0,
+ host: "127.0.0.1",
+ outDir,
+ });
+ const addr = server.address() as { port: number };
+ baseUrl = `http://127.0.0.1:${addr.port}`;
+ });
+
+ afterAll(async () => {
+ if (server) {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ // Clean up the fake pre-rendered file and pages directory
+ if (fs.existsSync(prerenderedFile)) {
+ fs.rmSync(prerenderedFile);
+ }
+ if (fs.existsSync(pagesDir) && fs.readdirSync(pagesDir).length === 0) {
+ fs.rmdirSync(pagesDir);
+ }
+ });
+
+ it.skipIf(!hasServerEntry)(
+ "serves pre-rendered HTML for /prerendered-test",
+ async () => {
+ const res = await fetch(`${baseUrl}/prerendered-test`);
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ expect(html).toContain("Pre-rendered test content");
+ },
+ );
+
+ it.skipIf(!hasServerEntry)(
+ "serves pre-rendered HTML with text/html content type",
+ async () => {
+ const res = await fetch(`${baseUrl}/prerendered-test`);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("text/html");
+ },
+ );
+
+ it.skipIf(!hasServerEntry)(
+ "falls back to SSR when no pre-rendered file exists",
+ async () => {
+ // /about is a real page in pages-basic but has no pre-rendered file
+ const res = await fetch(`${baseUrl}/about`);
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ expect(html).toContain("About");
+ },
+ );
+
+ it.skipIf(!hasServerEntry)(
+ "serves nested pre-rendered HTML (e.g. /blog/hello-world)",
+ async () => {
+ // Create a nested pre-rendered file simulating a dynamic route
+ const nestedDir = path.join(pagesDir, "blog");
+ const nestedFile = path.join(nestedDir, "hello-world.html");
+ fs.mkdirSync(nestedDir, { recursive: true });
+ fs.writeFileSync(
+ nestedFile,
+ `Blog post content`,
+ "utf-8",
+ );
+
+ try {
+ const res = await fetch(`${baseUrl}/blog/hello-world`);
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ expect(html).toContain("Blog post content");
+ } finally {
+ fs.rmSync(nestedFile);
+ if (fs.existsSync(nestedDir) && fs.readdirSync(nestedDir).length === 0) {
+ fs.rmdirSync(nestedDir);
+ }
+ }
+ },
+ );
+
+ it.skipIf(!hasServerEntry)(
+ "serves pre-rendered index.html for /",
+ async () => {
+ const indexFile = path.join(pagesDir, "index.html");
+ fs.writeFileSync(
+ indexFile,
+ `Pre-rendered home`,
+ "utf-8",
+ );
+
+ try {
+ const res = await fetch(`${baseUrl}/`);
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ expect(html).toContain("Pre-rendered home");
+ } finally {
+ fs.rmSync(indexFile);
+ }
+ },
+ );
+});
+
+// ─── prerenderStaticPages — function exists ───────────────────────────────────
+
+describe("prerenderStaticPages — function exists", () => {
+ it("prerenderStaticPages is exported as a function", async () => {
+ const mod = await import("../packages/vinext/src/build/static-export.js");
+ expect(typeof mod.prerenderStaticPages).toBe("function");
+ });
+
+ it("PrerenderResult type is returned", async () => {
+ // This will fail because prerenderStaticPages doesn't exist yet
+ const { prerenderStaticPages } = await import(
+ "../packages/vinext/src/build/static-export.js"
+ );
+ // Call with the pages-basic fixture which has a built dist/
+ const result = await prerenderStaticPages({ root: PAGES_FIXTURE });
+ expect(result).toHaveProperty("pageCount");
+ expect(result).toHaveProperty("files");
+ expect(result).toHaveProperty("warnings");
+ expect(result).toHaveProperty("skipped");
+ });
+});
From bbcf6e4910080265dbf0486482d1d0ac872cbdc1 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 05:20:17 +1100
Subject: [PATCH 03/12] feat: serve pre-rendered HTML from prod server
Add resolvePrerenderedHtml() helper and pre-render check to both
App Router and Pages Router production servers. Pre-rendered files
in dist/server/pages/ are served directly with text/html before
falling back to SSR.
Includes path traversal protection and supports both /page.html
and /page/index.html patterns for trailingSlash compatibility.
Phase 2 of #9
---
packages/vinext/src/server/prod-server.ts | 50 +++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index d4051468..27ed4419 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -240,6 +240,36 @@ 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 {
+ if (!fs.existsSync(dir)) return 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) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) {
+ return directPath;
+ }
+
+ const indexPath = path.join(dir, normalized, "index.html");
+ if (path.resolve(indexPath).startsWith(resolvedDir) && 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).
@@ -546,6 +576,15 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
return;
}
+ // Pre-rendered HTML — serve build-time rendered pages before hitting RSC/SSR
+ const appPrerenderedDir = path.join(path.dirname(rscEntryPath), "pages");
+ const appPrerenderedFile = resolvePrerenderedHtml(appPrerenderedDir, pathname);
+ if (appPrerenderedFile) {
+ const html = fs.readFileSync(appPrerenderedFile, "utf-8");
+ sendCompressed(req, res, html, "text/html; charset=utf-8", 200, {}, compress);
+ return;
+ }
+
try {
// Convert Node.js request to Web Request and call the RSC handler
const request = nodeToWebRequest(req);
@@ -712,6 +751,17 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
return;
}
+ // ── 1b. Pre-rendered HTML ──────────────────────────────────
+ // Check if a pre-rendered HTML file exists for this pathname.
+ // These are generated during `vinext build` for static pages.
+ const pagesPrerenderedDir = path.join(path.dirname(serverEntryPath), "pages");
+ const pagesPrerenderedFile = resolvePrerenderedHtml(pagesPrerenderedDir, pathname);
+ if (pagesPrerenderedFile) {
+ const html = fs.readFileSync(pagesPrerenderedFile, "utf-8");
+ sendCompressed(req, res, html, "text/html; charset=utf-8", 200, {}, compress);
+ return;
+ }
+
try {
// ── 2. Strip basePath ─────────────────────────────────────────
if (basePath && pathname.startsWith(basePath)) {
From f44f44692ac72c6aa42e0aae9bb94101920f626a Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 05:20:22 +1100
Subject: [PATCH 04/12] feat: wire prerenderStaticPages() into vinext build
After a non-export build completes, automatically pre-render static
pages to dist/server/pages/. The prod server serves these directly
without SSR on first request.
Phase 2 of #9
---
packages/vinext/src/cli.ts | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts
index fc033125..b7d3b19b 100644
--- a/packages/vinext/src/cli.ts
+++ b/packages/vinext/src/cli.ts
@@ -304,6 +304,26 @@ async function buildApp() {
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");
}
From 1b8a0f9769f0b6bb886c8b8f6c274f766122909f Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 06:21:51 +1100
Subject: [PATCH 05/12] fix: address 6 bugs found in code review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. Remove App Router pre-rendered HTML shortcut — was bypassing RSC
streaming and middleware/auth pipeline
2. Move Pages Router pre-rendered check after middleware/basePath/
redirects/rewrites pipeline (step 7b instead of 1b)
3. Skip ISR pages (revalidate != false) in collectStaticRoutes()
to prevent freezing dynamic content as static HTML
4. basePath handling covered by fix #2 (uses resolvedPathname)
5. Temp Vite servers now check for project vite.config and use it
when present, so user plugins/aliases are available
6. vinext start guard now checks config.output directly instead of
relying on out/ directory existence heuristic
---
packages/vinext/src/build/static-export.ts | 78 ++++++++++++++++------
packages/vinext/src/cli.ts | 12 ++--
packages/vinext/src/server/prod-server.ts | 32 ++++-----
3 files changed, 77 insertions(+), 45 deletions(-)
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 2390bf87..40005fd2 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -815,16 +815,31 @@ export async function runStaticExport(
}
// 3. Start a temporary Vite dev server
- const { default: vinextPlugin } = await import("../index.js");
const vite = await import("vite");
- const server = await vite.createServer({
- root,
- configFile: false,
- plugins: [vinextPlugin({ appDir: root })],
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- });
+ const hasViteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mjs"]
+ .some((f) => fs.existsSync(path.join(root, f)));
+
+ let serverConfig: Record;
+ if (hasViteConfig) {
+ // Use the project's vite config so user plugins/aliases/transforms are available
+ serverConfig = {
+ root,
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ };
+ } else {
+ const { default: vinextPlugin } = await import("../index.js");
+ serverConfig = {
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ };
+ }
+ const server = await vite.createServer(serverConfig);
await server.listen();
try {
@@ -1000,16 +1015,30 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
if (!isAppRouter && !pagesDir) return [];
// Start a temporary Vite dev server for module inspection
- const { default: vinextPlugin } = await import("../index.js");
const vite = await import("vite");
- const server = await vite.createServer({
- root,
- configFile: false,
- plugins: [vinextPlugin({ appDir: root })],
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- });
+ const hasViteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mjs"]
+ .some((f) => fs.existsSync(path.join(root, f)));
+
+ let serverConfig: Record;
+ if (hasViteConfig) {
+ serverConfig = {
+ root,
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ };
+ } else {
+ const { default: vinextPlugin } = await import("../index.js");
+ serverConfig = {
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ };
+ }
+ const server = await vite.createServer(serverConfig);
await server.listen();
try {
@@ -1025,8 +1054,9 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
try {
const pageModule = await server.ssrLoadModule(route.pagePath);
- // Skip force-dynamic pages
+ // Skip dynamic/request-dependent pages
if (pageModule.dynamic === "force-dynamic") continue;
+ if (pageModule.revalidate !== undefined && pageModule.revalidate !== false) continue;
if (route.isDynamic) {
// Need generateStaticParams to expand
@@ -1074,6 +1104,16 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
// Skip pages with getServerSideProps
if (typeof pageModule.getServerSideProps === "function") continue;
+ // Skip ISR pages (getStaticProps with revalidate)
+ if (typeof pageModule.getStaticProps === "function") {
+ try {
+ const propsResult = await pageModule.getStaticProps({});
+ if (propsResult?.revalidate) continue;
+ } catch {
+ continue; // Skip if getStaticProps fails
+ }
+ }
+
if (route.isDynamic) {
// Need getStaticPaths with fallback: false
if (typeof pageModule.getStaticPaths !== "function") continue;
diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts
index b7d3b19b..4fee3cc2 100644
--- a/packages/vinext/src/cli.ts
+++ b/packages/vinext/src/cli.ts
@@ -337,12 +337,12 @@ async function start() {
});
// Reject static export builds — they don't need a production server
- const outExportDir = path.resolve(process.cwd(), "out");
- const distServerDir = path.resolve(process.cwd(), "dist", "server");
- if (
- fs.existsSync(path.join(outExportDir, "index.html")) &&
- !fs.existsSync(distServerDir)
- ) {
+ const { loadNextConfig, resolveNextConfig } = await import(
+ /* @vite-ignore */ "./config/next-config.js"
+ );
+ const startRawConfig = await loadNextConfig(process.cwd());
+ const startResolvedConfig = await resolveNextConfig(startRawConfig);
+ if (startResolvedConfig.output === "export") {
console.error(
'\n "vinext start" does not work with "output: export" configuration.',
);
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index 27ed4419..612896b9 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -576,15 +576,6 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
return;
}
- // Pre-rendered HTML — serve build-time rendered pages before hitting RSC/SSR
- const appPrerenderedDir = path.join(path.dirname(rscEntryPath), "pages");
- const appPrerenderedFile = resolvePrerenderedHtml(appPrerenderedDir, pathname);
- if (appPrerenderedFile) {
- const html = fs.readFileSync(appPrerenderedFile, "utf-8");
- sendCompressed(req, res, html, "text/html; charset=utf-8", 200, {}, compress);
- return;
- }
-
try {
// Convert Node.js request to Web Request and call the RSC handler
const request = nodeToWebRequest(req);
@@ -751,17 +742,6 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
return;
}
- // ── 1b. Pre-rendered HTML ──────────────────────────────────
- // Check if a pre-rendered HTML file exists for this pathname.
- // These are generated during `vinext build` for static pages.
- const pagesPrerenderedDir = path.join(path.dirname(serverEntryPath), "pages");
- const pagesPrerenderedFile = resolvePrerenderedHtml(pagesPrerenderedDir, pathname);
- if (pagesPrerenderedFile) {
- const html = fs.readFileSync(pagesPrerenderedFile, "utf-8");
- sendCompressed(req, res, html, "text/html; charset=utf-8", 200, {}, compress);
- return;
- }
-
try {
// ── 2. Strip basePath ─────────────────────────────────────────
if (basePath && pathname.startsWith(basePath)) {
@@ -930,6 +910,18 @@ 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 pagesPrerenderedDir = path.join(path.dirname(serverEntryPath), "pages");
+ const pagesPrerenderedFile = resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname);
+ if (pagesPrerenderedFile) {
+ const html = fs.readFileSync(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;
From df3830f05d0a56d43e0b3d0b8d81cfc16297a6fd Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 14:00:24 +1100
Subject: [PATCH 06/12] fix: downgrade missing
generateStaticParams/getStaticPaths from error to warning
Dynamic routes without generateStaticParams (App Router) or
getStaticPaths (Pages Router) in output: 'export' mode now produce
a warning and skip the route instead of failing the build.
This enables legitimate use cases like CMS apps with no published
content and SPA-style client-rendered dynamic routes.
Addresses vercel/next.js#61213 and vercel/next.js#55393.
---
packages/vinext/src/build/static-export.ts | 16 +++++++---------
tests/app-router.test.ts | 7 ++++---
2 files changed, 11 insertions(+), 12 deletions(-)
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 40005fd2..5ff54a64 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -122,12 +122,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;
}
@@ -665,10 +664,9 @@ 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;
}
diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts
index 89ac8c36..5e828184 100644
--- a/tests/app-router.test.ts
+++ b/tests/app-router.test.ts
@@ -1590,7 +1590,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"
);
@@ -1632,9 +1632,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 });
From ce830ad415eebc8ad0ed53ba26566a45288bf1ce Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 14:13:03 +1100
Subject: [PATCH 07/12] fix: address code review findings in static export
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Must-fixes:
- Update stale file header comments (warning, not error)
- Sanitize getOutputPath() against path traversal from user params
- Fix revalidate filtering: only skip revalidate=0 (force-dynamic),
not all ISR pages — they should be pre-rendered
- Fix clearTimeout leak on fetch failure in prerenderStaticPages
- Consume response body on non-ok fetch in staticExportApp
- Fix revalidate:0 handling in Pages Router collectStaticRoutes
(0 is falsy, so the old check didn't catch it)
Should-fixes:
- Wrap renderStaticPage in try/finally to clear SSR context on error
- Extract createTempViteServer() helper (was duplicated 2x)
- Extract expandDynamicAppRoute() helper (was duplicated with
diverging behavior between staticExportApp and collectStaticRoutes)
- Remove unnecessary server.listen() in collectStaticRoutes —
only ssrLoadModule is needed, not an HTTP listener
- Add Pages Router test for warning on missing getStaticPaths
---
packages/vinext/src/build/static-export.ts | 371 ++++++++++-----------
tests/pages-router.test.ts | 40 +++
2 files changed, 224 insertions(+), 187 deletions(-)
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 5ff54a64..9981be58 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -8,14 +8,15 @@
* 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";
@@ -31,6 +32,44 @@ import React from "react";
import { renderToReadableStream } from "react-dom/server.edge";
const PAGE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
+const VITE_CONFIG_FILES = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
+
+/**
+ * Create a temporary Vite dev server for a project root.
+ * Uses the project's vite config if present, otherwise auto-configures with vinext.
+ * 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 hasViteConfig = VITE_CONFIG_FILES.some((f) => fs.existsSync(path.join(root, f)));
+
+ let serverConfig: Record;
+ if (hasViteConfig) {
+ serverConfig = {
+ root,
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ };
+ } else {
+ const { default: vinextPlugin } = await import("../index.js");
+ serverConfig = {
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ };
+ }
+
+ const server = await vite.createServer(serverConfig);
+ if (opts.listen) await server.listen();
+ return server;
+}
function findFileWithExtensions(basePath: string): boolean {
return PAGE_EXTENSIONS.some((ext) => fs.existsSync(basePath + ext));
@@ -286,85 +325,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 +416,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 {
@@ -509,8 +550,13 @@ function getOutputPath(urlPath: string, trailingSlash: boolean): string {
return "index.html";
}
- // Remove leading slash
- const clean = urlPath.replace(/^\//, "");
+ // Normalize and reject path traversal from user-controlled params
+ const normalized = path.posix.normalize(urlPath);
+ if (normalized.includes("..")) {
+ throw new Error(`Route path "${urlPath}" contains path traversal segments`);
+ }
+
+ const clean = normalized.replace(/^\//, "");
if (trailingSlash) {
return `${clean}/index.html`;
@@ -606,6 +652,38 @@ 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[];
+ 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: {} });
+ }
+
+ if (!Array.isArray(paramSets)) return [];
+ return paramSets.map((params) => buildUrlFromParams(route.pattern, params));
+}
+
// -------------------------------------------------------------------
// App Router static export
// -------------------------------------------------------------------
@@ -670,39 +748,18 @@ export async function staticExportApp(
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,
@@ -724,6 +781,7 @@ export async function staticExportApp(
route: urlPath,
error: `Server returned ${res.status}`,
});
+ await res.body?.cancel(); // release connection
continue;
}
@@ -812,33 +870,8 @@ export async function runStaticExport(
};
}
- // 3. Start a temporary Vite dev server
- const vite = await import("vite");
- const hasViteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mjs"]
- .some((f) => fs.existsSync(path.join(root, f)));
-
- let serverConfig: Record;
- if (hasViteConfig) {
- // Use the project's vite config so user plugins/aliases/transforms are available
- serverConfig = {
- root,
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- };
- } else {
- const { default: vinextPlugin } = await import("../index.js");
- serverConfig = {
- root,
- configFile: false,
- plugins: [vinextPlugin({ appDir: root })],
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- };
- }
- const server = await vite.createServer(serverConfig);
- await server.listen();
+ // 3. Start a temporary Vite dev server (with listener for HTTP fetching)
+ const server = await createTempViteServer(root, { listen: true });
try {
// 4. Clean output directory
@@ -958,13 +991,12 @@ export async function prerenderStaticPages(
fs.mkdirSync(pagesOutDir, { recursive: true });
for (const urlPath of staticUrls) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 10_000);
try {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 10_000);
const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
signal: controller.signal,
});
- clearTimeout(timer);
if (!res.ok) {
result.skipped.push(urlPath);
@@ -982,6 +1014,8 @@ export async function prerenderStaticPages(
result.pageCount++;
} catch {
result.skipped.push(urlPath);
+ } finally {
+ clearTimeout(timer);
}
}
} finally {
@@ -1012,32 +1046,8 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
if (isAppRouter && !appDir) return [];
if (!isAppRouter && !pagesDir) return [];
- // Start a temporary Vite dev server for module inspection
- const vite = await import("vite");
- const hasViteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mjs"]
- .some((f) => fs.existsSync(path.join(root, f)));
-
- let serverConfig: Record;
- if (hasViteConfig) {
- serverConfig = {
- root,
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- };
- } else {
- const { default: vinextPlugin } = await import("../index.js");
- serverConfig = {
- root,
- configFile: false,
- plugins: [vinextPlugin({ appDir: root })],
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- };
- }
- const server = await vite.createServer(serverConfig);
- await server.listen();
+ // Only need ssrLoadModule for module inspection — no HTTP listener needed
+ const server = await createTempViteServer(root);
try {
const urls: string[] = [];
@@ -1054,34 +1064,19 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
// Skip dynamic/request-dependent pages
if (pageModule.dynamic === "force-dynamic") continue;
- if (pageModule.revalidate !== undefined && pageModule.revalidate !== false) continue;
+ // revalidate: 0 means "always revalidate" (force-dynamic equivalent) — skip.
+ // Positive revalidate values (ISR) are fine to pre-render; the revalidation
+ // simply won't run without a server.
+ if (pageModule.revalidate === 0) continue;
if (route.isDynamic) {
// Need generateStaticParams to expand
if (typeof pageModule.generateStaticParams !== "function") continue;
- const parentParamSets = await resolveParentParams(route, routes, server);
- let paramSets: Record[];
-
- if (parentParamSets.length > 0) {
- 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 {
- paramSets = await pageModule.generateStaticParams({ params: {} });
- }
-
- if (Array.isArray(paramSets)) {
- for (const params of paramSets) {
- urls.push(buildUrlFromParams(route.pattern, params));
- }
- }
+ const expandedUrls = await expandDynamicAppRoute(
+ route, routes, server, pageModule.generateStaticParams,
+ );
+ urls.push(...expandedUrls);
} else {
urls.push(route.pattern);
}
@@ -1102,11 +1097,13 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
// Skip pages with getServerSideProps
if (typeof pageModule.getServerSideProps === "function") continue;
- // Skip ISR pages (getStaticProps with revalidate)
+ // Skip dynamic pages (getStaticProps with revalidate: 0 means force-dynamic)
if (typeof pageModule.getStaticProps === "function") {
try {
const propsResult = await pageModule.getStaticProps({});
- if (propsResult?.revalidate) continue;
+ // revalidate: 0 means "always revalidate" (force-dynamic) — skip.
+ // Positive values (ISR) are fine to pre-render.
+ if (propsResult?.revalidate === 0) continue;
} catch {
continue; // Skip if getStaticProps fails
}
diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts
index 92a0846c..3c353cfc 100644
--- a/tests/pages-router.test.ts
+++ b/tests/pages-router.test.ts
@@ -1543,6 +1543,46 @@ describe("Static export (Pages Router)", () => {
}
});
+ it("warns and skips dynamic routes without getStaticPaths", async () => {
+ const { staticExportPages } = await import(
+ "../packages/vinext/src/build/static-export.js"
+ );
+ const { resolveNextConfig } = await import(
+ "../packages/vinext/src/config/next-config.js"
+ );
+
+ // Create a fake dynamic route with no getStaticPaths
+ const fakeRoutes = [
+ {
+ pattern: "/fake/:id",
+ filePath: path.resolve(FIXTURE_DIR, "pages", "index.tsx"),
+ isDynamic: true,
+ params: ["id"],
+ },
+ ];
+ const config = await resolveNextConfig({ output: "export" });
+ const tempDir = path.resolve(FIXTURE_DIR, "out-temp-pages-warn");
+
+ try {
+ const result = await staticExportPages({
+ server,
+ routes: fakeRoutes as any,
+ apiRoutes: [],
+ pagesDir: path.resolve(FIXTURE_DIR, "pages"),
+ outDir: tempDir,
+ config,
+ });
+
+ // Should warn (not error) about missing getStaticPaths
+ expect(result.errors).toHaveLength(0);
+ expect(
+ result.warnings.some((w) => w.includes("getStaticPaths")),
+ ).toBe(true);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
it("includes __NEXT_DATA__ in exported HTML", async () => {
const indexHtml = fs.readFileSync(
path.join(exportDir, "index.html"),
From 5be5d1ce11ff513922901b9bcba8380be8422c46 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 14:20:57 +1100
Subject: [PATCH 08/12] refactor: improve observability and type safety in
static export
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- collectStaticRoutes now returns { urls, skipped } instead of just
URLs — every skipped route includes a reason (force-dynamic,
revalidate: 0, load failure, getServerSideProps, etc.)
- Skipped routes surface in PrerenderResult.skipped for CLI output
- Type createTempViteServer config as vite.InlineConfig instead of
Record
---
packages/vinext/src/build/static-export.ts | 54 +++++++++++++++-------
1 file changed, 38 insertions(+), 16 deletions(-)
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 9981be58..fae008d8 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -46,7 +46,7 @@ async function createTempViteServer(
const vite = await import("vite");
const hasViteConfig = VITE_CONFIG_FILES.some((f) => fs.existsSync(path.join(root, f)));
- let serverConfig: Record;
+ let serverConfig: import("vite").InlineConfig;
if (hasViteConfig) {
serverConfig = {
root,
@@ -969,13 +969,16 @@ export async function prerenderStaticPages(
}
// Collect static routes using a temporary Vite dev server
- const staticUrls = await collectStaticRoutes(root, isAppRouter);
+ const collected = await collectStaticRoutes(root, isAppRouter);
+ result.skipped.push(...collected.skipped);
- if (staticUrls.length === 0) {
+ 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({
@@ -1029,7 +1032,12 @@ export async function prerenderStaticPages(
* Collect static routes by starting a temporary Vite dev server and
* inspecting page module exports.
*/
-async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise {
+interface CollectedRoutes {
+ urls: string[];
+ skipped: string[];
+}
+
+async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise {
// Detect source directories
const appDirCandidates = [
path.join(root, "app"),
@@ -1043,14 +1051,15 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
const appDir = appDirCandidates.find((d) => fs.existsSync(d));
const pagesDir = pagesDirCandidates.find((d) => fs.existsSync(d));
- if (isAppRouter && !appDir) return [];
- if (!isAppRouter && !pagesDir) return [];
+ if (isAppRouter && !appDir) return { urls: [], skipped: [] };
+ if (!isAppRouter && !pagesDir) return { urls: [], skipped: [] };
// Only need ssrLoadModule for module inspection — no HTTP listener needed
const server = await createTempViteServer(root);
try {
const urls: string[] = [];
+ const skipped: string[] = [];
if (isAppRouter && appDir) {
const routes = await appRouter(appDir);
@@ -1063,11 +1072,17 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
const pageModule = await server.ssrLoadModule(route.pagePath);
// Skip dynamic/request-dependent pages
- if (pageModule.dynamic === "force-dynamic") continue;
+ if (pageModule.dynamic === "force-dynamic") {
+ skipped.push(`${route.pattern} (force-dynamic)`);
+ continue;
+ }
// revalidate: 0 means "always revalidate" (force-dynamic equivalent) — skip.
// Positive revalidate values (ISR) are fine to pre-render; the revalidation
// simply won't run without a server.
- if (pageModule.revalidate === 0) continue;
+ if (pageModule.revalidate === 0) {
+ skipped.push(`${route.pattern} (revalidate: 0)`);
+ continue;
+ }
if (route.isDynamic) {
// Need generateStaticParams to expand
@@ -1080,8 +1095,8 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
} else {
urls.push(route.pattern);
}
- } catch {
- // Skip routes that fail to load
+ } catch (e) {
+ skipped.push(`${route.pattern} (failed to load: ${(e as Error).message})`);
}
}
} else if (pagesDir) {
@@ -1095,7 +1110,10 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
const pageModule = await server.ssrLoadModule(route.filePath);
// Skip pages with getServerSideProps
- if (typeof pageModule.getServerSideProps === "function") continue;
+ if (typeof pageModule.getServerSideProps === "function") {
+ skipped.push(`${route.pattern} (getServerSideProps)`);
+ continue;
+ }
// Skip dynamic pages (getStaticProps with revalidate: 0 means force-dynamic)
if (typeof pageModule.getStaticProps === "function") {
@@ -1103,9 +1121,13 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
const propsResult = await pageModule.getStaticProps({});
// revalidate: 0 means "always revalidate" (force-dynamic) — skip.
// Positive values (ISR) are fine to pre-render.
- if (propsResult?.revalidate === 0) continue;
+ if (propsResult?.revalidate === 0) {
+ skipped.push(`${route.pattern} (revalidate: 0)`);
+ continue;
+ }
} catch {
- continue; // Skip if getStaticProps fails
+ skipped.push(`${route.pattern} (getStaticProps failed)`);
+ continue;
}
}
@@ -1127,13 +1149,13 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
} else {
urls.push(route.pattern);
}
- } catch {
- // Skip routes that fail to load
+ } catch (e) {
+ skipped.push(`${route.pattern} (failed to load: ${(e as Error).message})`);
}
}
}
- return urls;
+ return { urls, skipped };
} finally {
await server.close();
}
From 69a5835e0bd70b5f465b958dd8d4805ed4368cdb Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Fri, 6 Mar 2026 14:47:15 +1100
Subject: [PATCH 09/12] fix: address 5 issues from code review audit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Skip App Router builds in prerenderStaticPages() — pre-rendered HTML
files were written to dist/server/pages/ but never served (the App
Router prod server delegates entirely to the RSC handler)
- Fix getStaticProps({}) probe to pass { params: {} } — prevents crash
when getStaticProps destructures context.params
- Replace sync readFileSync with async fs.promises.readFile in the
per-request pre-rendered HTML handler
- Move pagesPrerenderedDir computation and directory existence check to
server startup (was recomputed on every request)
- Remove stale TDD comments claiming functions don't exist yet, fix
JSDoc describing nonexistent subprocess fallback
---
packages/vinext/src/build/static-export.ts | 17 ++++++++++++-----
packages/vinext/src/server/prod-server.ts | 12 +++++++++---
tests/build-prerender.test.ts | 1 -
tests/build-static-export.test.ts | 5 +----
4 files changed, 22 insertions(+), 13 deletions(-)
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index fae008d8..58751e6b 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -934,9 +934,9 @@ export interface PrerenderResult {
* Vite dev server, fetches each static page, and writes the HTML to
* dist/server/pages/.
*
- * Prefers starting the prod server in-process via `startProdServer()`.
- * Falls back to a subprocess when the in-process import fails (e.g. when
- * running from compiled JS where the import path differs).
+ * 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,
@@ -968,8 +968,15 @@ export async function prerenderStaticPages(
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 using a temporary Vite dev server
- const collected = await collectStaticRoutes(root, isAppRouter);
+ const collected = await collectStaticRoutes(root, false);
result.skipped.push(...collected.skipped);
if (collected.urls.length === 0) {
@@ -1118,7 +1125,7 @@ async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise<
// Skip dynamic pages (getStaticProps with revalidate: 0 means force-dynamic)
if (typeof pageModule.getStaticProps === "function") {
try {
- const propsResult = await pageModule.getStaticProps({});
+ const propsResult = await pageModule.getStaticProps({ params: {} });
// revalidate: 0 means "always revalidate" (force-dynamic) — skip.
// Positive values (ISR) are fine to pre-render.
if (propsResult?.revalidate === 0) {
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index 612896b9..1637d4ff 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -669,6 +669,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.
@@ -913,10 +918,11 @@ 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 pagesPrerenderedDir = path.join(path.dirname(serverEntryPath), "pages");
- const pagesPrerenderedFile = resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname);
+ const pagesPrerenderedFile = hasPrerenderedPages
+ ? resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname)
+ : null;
if (pagesPrerenderedFile) {
- const html = fs.readFileSync(pagesPrerenderedFile, "utf-8");
+ 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;
diff --git a/tests/build-prerender.test.ts b/tests/build-prerender.test.ts
index c60feb32..a67a870c 100644
--- a/tests/build-prerender.test.ts
+++ b/tests/build-prerender.test.ts
@@ -148,7 +148,6 @@ describe("prerenderStaticPages — function exists", () => {
});
it("PrerenderResult type is returned", async () => {
- // This will fail because prerenderStaticPages doesn't exist yet
const { prerenderStaticPages } = await import(
"../packages/vinext/src/build/static-export.js"
);
diff --git a/tests/build-static-export.test.ts b/tests/build-static-export.test.ts
index 6ca29593..b934f4e5 100644
--- a/tests/build-static-export.test.ts
+++ b/tests/build-static-export.test.ts
@@ -1,11 +1,8 @@
/**
- * Failing tests for runStaticExport() — the high-level orchestrator that
+ * Tests for runStaticExport() — the high-level orchestrator that
* takes a project root, starts a temporary Vite dev server, scans routes,
* runs the appropriate static export (Pages or App Router), and returns
* a StaticExportResult.
- *
- * runStaticExport() does NOT exist yet. These tests define the contract
- * and should fail with an import error until the implementation lands.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import fs from "node:fs";
From 0c2c9f6cf9aafbd40804232e68c2dc260adaeda2 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 7 Mar 2026 12:47:55 +1100
Subject: [PATCH 10/12] fix: prevent path prefix confusion in
resolvePrerenderedHtml
Add path.sep suffix to startsWith checks to prevent prefix confusion
(e.g. /dist/server/pages-evil/hack.html passing a check for
/dist/server/pages). Also remove redundant fs.existsSync check since
the caller already gates on hasPrerenderedPages at startup.
---
packages/vinext/src/server/prod-server.ts | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index e154b80c..20ab258b 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -289,8 +289,6 @@ function tryServeStatic(
* `vinext build` to dist/server/pages/.
*/
function resolvePrerenderedHtml(dir: string, pathname: string): string | null {
- if (!fs.existsSync(dir)) return null;
-
// Normalize: "/" → "index", "/about" → "about", "/blog/post" → "blog/post"
const normalized = pathname === "/" ? "index" : pathname.replace(/^\//, "").replace(/\/$/, "");
@@ -298,12 +296,12 @@ function resolvePrerenderedHtml(dir: string, pathname: string): string | null {
const resolvedDir = path.resolve(dir);
const directPath = path.join(dir, `${normalized}.html`);
- if (path.resolve(directPath).startsWith(resolvedDir) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) {
+ 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) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
+ if (path.resolve(indexPath).startsWith(resolvedDir + path.sep) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
return indexPath;
}
From dcc6f65a35f542e2621b43f94752224c5bdc735a Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 7 Mar 2026 12:48:06 +1100
Subject: [PATCH 11/12] fix: address code review feedback on static export
- Replace dead path traversal guard in getOutputPath with output
directory boundary check (validates resolved path stays within outDir)
- Eliminate dev server from prerenderStaticPages by using source-file
inspection for route classification instead of ssrLoadModule
- Fix createTempViteServer to always use configFile: false, preventing
RSC plugin resolution failures when loaded from user's vite.config
- Replace getStaticProps({ params: {} }) side effect with module-level
revalidate export check during route collection
- Add route context to generateStaticParams error messages
- Add port 0 check after Vite dev server bind
- Pass resolved config from CLI to runStaticExport to avoid double
config loading
- Rename start() config variables for clarity
---
packages/vinext/src/build/static-export.ts | 275 ++++++++-------------
packages/vinext/src/cli.ts | 8 +-
2 files changed, 111 insertions(+), 172 deletions(-)
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 6393d43d..e2056b92 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -32,11 +32,15 @@ import React from "react";
import { renderToReadableStream } from "react-dom/server.edge";
import { createValidFileMatcher, type ValidFileMatcher } from "../routing/file-matcher.js";
-const VITE_CONFIG_FILES = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
-
/**
* Create a temporary Vite dev server for a project root.
- * Uses the project's vite config if present, otherwise auto-configures with vinext.
+ *
+ * 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(
@@ -44,29 +48,16 @@ async function createTempViteServer(
opts: { listen?: boolean } = {},
): Promise {
const vite = await import("vite");
- const hasViteConfig = VITE_CONFIG_FILES.some((f) => fs.existsSync(path.join(root, f)));
-
- let serverConfig: import("vite").InlineConfig;
- if (hasViteConfig) {
- serverConfig = {
- root,
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- };
- } else {
- const { default: vinextPlugin } = await import("../index.js");
- serverConfig = {
- root,
- configFile: false,
- plugins: [vinextPlugin({ appDir: root })],
- optimizeDeps: { holdUntilCrawlEnd: true },
- server: { port: 0, cors: false },
- logLevel: "silent",
- };
- }
-
- const server = await vite.createServer(serverConfig);
+ 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;
}
@@ -245,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");
@@ -545,26 +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";
}
- // Normalize and reject path traversal from user-controlled params
const normalized = path.posix.normalize(urlPath);
- if (normalized.includes("..")) {
- throw new Error(`Route path "${urlPath}" contains path traversal segments`);
- }
-
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;
}
/**
@@ -669,18 +665,22 @@ async function expandDynamicAppRoute(
const parentParamSets = await resolveParentParams(route, allRoutes, server);
let paramSets: Record[];
- 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 });
+ 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: {} });
}
- } else {
- paramSets = await generateStaticParams({ params: {} });
+ } catch (e) {
+ throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`);
}
if (!Array.isArray(paramSets)) return [];
@@ -789,7 +789,7 @@ export async function staticExportApp(
}
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");
@@ -830,6 +830,7 @@ export async function staticExportApp(
export interface RunStaticExportOptions {
root: string;
outDir?: string;
+ config?: ResolvedNextConfig;
configOverride?: Partial;
}
@@ -846,10 +847,15 @@ export async function runStaticExport(
const { root, configOverride } = options;
const outDir = options.outDir ?? path.join(root, "out");
- // 1. Load and resolve config
- const loadedConfig = await loadNextConfig(root);
- const merged: NextConfig = { ...loadedConfig, ...configOverride };
- const config = await resolveNextConfig(merged);
+ // 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 = [
@@ -885,6 +891,9 @@ export async function runStaticExport(
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);
@@ -978,8 +987,12 @@ export async function prerenderStaticPages(
return result;
}
- // Collect static routes using a temporary Vite dev server
- const collected = await collectStaticRoutes(root, false);
+ // 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) {
@@ -1018,7 +1031,7 @@ export async function prerenderStaticPages(
}
const html = await res.text();
- const outputPath = getOutputPath(urlPath, false);
+ 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");
@@ -1039,135 +1052,61 @@ export async function prerenderStaticPages(
}
/**
- * Collect static routes by starting a temporary Vite dev server and
- * inspecting page module exports.
+ * 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).
*/
-interface CollectedRoutes {
- urls: string[];
- skipped: string[];
-}
-
-async function collectStaticRoutes(root: string, isAppRouter: boolean): Promise {
- // Detect source directories
- const appDirCandidates = [
- path.join(root, "app"),
- path.join(root, "src", "app"),
- ];
+async function collectStaticRoutesFromSource(root: string): Promise {
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 (!pagesDir) return { urls: [], skipped: [] };
- if (isAppRouter && !appDir) return { urls: [], skipped: [] };
- if (!isAppRouter && !pagesDir) return { urls: [], skipped: [] };
+ const routes = await pagesRouter(pagesDir);
+ const urls: string[] = [];
+ const skipped: string[] = [];
- // Only need ssrLoadModule for module inspection — no HTTP listener needed
- const server = await createTempViteServer(root);
+ // 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/;
- try {
- const urls: string[] = [];
- const skipped: string[] = [];
+ for (const route of routes) {
+ const routeName = path.basename(route.filePath, path.extname(route.filePath));
+ if (routeName.startsWith("_")) continue;
- if (isAppRouter && appDir) {
- const routes = await appRouter(appDir);
- for (const route of routes) {
- // Skip route handlers (API routes)
- if (route.routePath && !route.pagePath) continue;
- if (!route.pagePath) continue;
-
- try {
- const pageModule = await server.ssrLoadModule(route.pagePath);
-
- // Skip dynamic/request-dependent pages
- if (pageModule.dynamic === "force-dynamic") {
- skipped.push(`${route.pattern} (force-dynamic)`);
- continue;
- }
- // revalidate: 0 means "always revalidate" (force-dynamic equivalent) — skip.
- // Positive revalidate values (ISR) are fine to pre-render; the revalidation
- // simply won't run without a server.
- if (pageModule.revalidate === 0) {
- skipped.push(`${route.pattern} (revalidate: 0)`);
- continue;
- }
+ if (route.isDynamic) {
+ skipped.push(`${route.pattern} (dynamic)`);
+ continue;
+ }
- if (route.isDynamic) {
- // Need generateStaticParams to expand
- if (typeof pageModule.generateStaticParams !== "function") continue;
+ try {
+ const source = fs.readFileSync(route.filePath, "utf-8");
- const expandedUrls = await expandDynamicAppRoute(
- route, routes, server, pageModule.generateStaticParams,
- );
- urls.push(...expandedUrls);
- } else {
- urls.push(route.pattern);
- }
- } catch (e) {
- skipped.push(`${route.pattern} (failed to load: ${(e as Error).message})`);
- }
+ if (gsspPattern.test(source)) {
+ skipped.push(`${route.pattern} (getServerSideProps)`);
+ continue;
}
- } else if (pagesDir) {
- const routes = await pagesRouter(pagesDir);
- for (const route of routes) {
- // Skip internal pages
- const routeName = path.basename(route.filePath, path.extname(route.filePath));
- if (routeName.startsWith("_")) continue;
-
- try {
- const pageModule = await server.ssrLoadModule(route.filePath);
-
- // Skip pages with getServerSideProps
- if (typeof pageModule.getServerSideProps === "function") {
- skipped.push(`${route.pattern} (getServerSideProps)`);
- continue;
- }
- // Skip dynamic pages (getStaticProps with revalidate: 0 means force-dynamic)
- if (typeof pageModule.getStaticProps === "function") {
- try {
- const propsResult = await pageModule.getStaticProps({ params: {} });
- // revalidate: 0 means "always revalidate" (force-dynamic) — skip.
- // Positive values (ISR) are fine to pre-render.
- if (propsResult?.revalidate === 0) {
- skipped.push(`${route.pattern} (revalidate: 0)`);
- continue;
- }
- } catch {
- skipped.push(`${route.pattern} (getStaticProps failed)`);
- continue;
- }
- }
-
- if (route.isDynamic) {
- // Need getStaticPaths with fallback: false
- if (typeof pageModule.getStaticPaths !== "function") continue;
-
- const pathsResult = await pageModule.getStaticPaths({
- locales: [],
- defaultLocale: "",
- });
- if (pathsResult?.fallback !== false) continue;
-
- const paths: Array<{ params: Record }> =
- pathsResult?.paths ?? [];
- for (const { params } of paths) {
- urls.push(buildUrlFromParams(route.pattern, params));
- }
- } else {
- urls.push(route.pattern);
- }
- } catch (e) {
- skipped.push(`${route.pattern} (failed to load: ${(e as Error).message})`);
- }
+ if (revalidateZeroPattern.test(source)) {
+ skipped.push(`${route.pattern} (revalidate: 0)`);
+ continue;
}
- }
- return { urls, skipped };
- } finally {
- await server.close();
+ 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 4fee3cc2..62ef336c 100644
--- a/packages/vinext/src/cli.ts
+++ b/packages/vinext/src/cli.ts
@@ -284,7 +284,7 @@ async function buildApp() {
/* @vite-ignore */ "./build/static-export.js"
);
- const result = await runStaticExport({ root: process.cwd() });
+ const result = await runStaticExport({ root: process.cwd(), config: resolvedConfig });
if (result.warnings.length > 0) {
for (const w of result.warnings) console.log(` Warning: ${w}`);
@@ -340,9 +340,9 @@ async function start() {
const { loadNextConfig, resolveNextConfig } = await import(
/* @vite-ignore */ "./config/next-config.js"
);
- const startRawConfig = await loadNextConfig(process.cwd());
- const startResolvedConfig = await resolveNextConfig(startRawConfig);
- if (startResolvedConfig.output === "export") {
+ 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.',
);
From b0dd390a708e0e86beb1dc4b55fc9866df1fd8fb Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sat, 7 Mar 2026 12:48:11 +1100
Subject: [PATCH 12/12] test: fail explicitly when fixture not built
Replace silent test skipping (it.skipIf) with an explicit error in
beforeAll when the fixture's server entry doesn't exist. This prevents
CI from showing green when the test infrastructure is broken.
---
tests/build-prerender.test.ts | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/tests/build-prerender.test.ts b/tests/build-prerender.test.ts
index a67a870c..45c8f42c 100644
--- a/tests/build-prerender.test.ts
+++ b/tests/build-prerender.test.ts
@@ -19,13 +19,16 @@ describe("Production server — serves pre-rendered HTML", () => {
const serverEntryPath = path.join(outDir, "server", "entry.js");
const pagesDir = path.join(outDir, "server", "pages");
const prerenderedFile = path.join(pagesDir, "prerendered-test.html");
- const hasServerEntry = fs.existsSync(serverEntryPath);
-
let server: Server;
let baseUrl: string;
beforeAll(async () => {
- if (!hasServerEntry) return;
+ 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 });
@@ -60,7 +63,7 @@ describe("Production server — serves pre-rendered HTML", () => {
}
});
- it.skipIf(!hasServerEntry)(
+ it(
"serves pre-rendered HTML for /prerendered-test",
async () => {
const res = await fetch(`${baseUrl}/prerendered-test`);
@@ -70,7 +73,7 @@ describe("Production server — serves pre-rendered HTML", () => {
},
);
- it.skipIf(!hasServerEntry)(
+ it(
"serves pre-rendered HTML with text/html content type",
async () => {
const res = await fetch(`${baseUrl}/prerendered-test`);
@@ -79,7 +82,7 @@ describe("Production server — serves pre-rendered HTML", () => {
},
);
- it.skipIf(!hasServerEntry)(
+ it(
"falls back to SSR when no pre-rendered file exists",
async () => {
// /about is a real page in pages-basic but has no pre-rendered file
@@ -90,7 +93,7 @@ describe("Production server — serves pre-rendered HTML", () => {
},
);
- it.skipIf(!hasServerEntry)(
+ it(
"serves nested pre-rendered HTML (e.g. /blog/hello-world)",
async () => {
// Create a nested pre-rendered file simulating a dynamic route
@@ -117,7 +120,7 @@ describe("Production server — serves pre-rendered HTML", () => {
},
);
- it.skipIf(!hasServerEntry)(
+ it(
"serves pre-rendered index.html for /",
async () => {
const indexFile = path.join(pagesDir, "index.html");