Skip to content

feat: static pre-rendering at build time#274

Draft
NathanDrake2406 wants to merge 22 commits intocloudflare:mainfrom
NathanDrake2406:feat/static-prerender-build
Draft

feat: static pre-rendering at build time#274
NathanDrake2406 wants to merge 22 commits intocloudflare:mainfrom
NathanDrake2406:feat/static-prerender-build

Conversation

@NathanDrake2406
Copy link
Contributor

@NathanDrake2406 NathanDrake2406 commented Mar 5, 2026

Summary

Implements build-time static pre-rendering for vinext, closing #9.

  • vinext build with output: "export" now runs full static export to out/ (Phase 1)
  • vinext build in normal mode pre-renders static pages to dist/server/pages/ (Phase 2)
  • Production server serves pre-rendered HTML before falling through to SSR
  • vinext start rejects export builds with a helpful error message

Phase 1: output: "export" support

Adds runStaticExport() orchestrator that connects the existing staticExportPages/staticExportApp functions to the CLI build pipeline. Loads config, detects router type, starts a temp dev server, scans routes, renders all static pages to out/.

Phase 2: Selective pre-rendering in normal builds

Adds prerenderStaticPages() that runs after production builds:

  1. Starts a temp prod server in-process
  2. Detects static routes via Vite dev server module inspection (skips getServerSideProps, force-dynamic, etc.)
  3. Fetches each static route and writes HTML to dist/server/pages/
  4. Prod server checks for pre-rendered HTML before SSR fallback

Files changed

File Change
packages/vinext/src/build/static-export.ts runStaticExport(), prerenderStaticPages(), collectStaticRoutes()
packages/vinext/src/cli.ts Wire both phases into buildApp(), export build rejection in start()
packages/vinext/src/server/prod-server.ts resolvePrerenderedHtml() + pre-render serving in both router handlers
tests/build-static-export.test.ts 14 tests for runStaticExport() (both routers)
tests/build-prerender.test.ts 7 tests for prod server serving + prerenderStaticPages()

Test plan

Closes #9

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@274

commit: ac92b02

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 cloudflare#9
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 cloudflare#9
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 cloudflare#9
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 cloudflare#9
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 cloudflare#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
… 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.
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
@NathanDrake2406 NathanDrake2406 force-pushed the feat/static-prerender-build branch from cd25163 to ce830ad Compare March 6, 2026 03:15
- 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<string, unknown>
- 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
Keep createTempViteServer (from this PR) and adopt upstream's
ValidFileMatcher-based findFileWithExtensions signature.
Fix prerendered page headers type to match widened middlewareHeaders.
@southpolesteve
Copy link
Collaborator

/bigbonk review please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Static pre-rendering at build time

Solid feature implementation overall. The architecture is well-structured: runStaticExport() as the orchestrator for output: "export", prerenderStaticPages() for selective pre-rendering in normal builds, and the prod server integration to serve pre-rendered HTML. The refactoring of renderStaticPage to use try/finally for SSR context cleanup and the extraction of expandDynamicAppRoute are good improvements.

I found one security issue that should be fixed, plus several correctness and robustness concerns.

Security

  • resolvePrerenderedHtml has a path prefix confusion bugstartsWith(resolvedDir) without a trailing separator means a directory named pages-evil adjacent to pages would pass the check (see inline comment).

Correctness concerns

  • The getOutputPath traversal guard is dead code — path.posix.normalize resolves .. segments, so the includes('..') check after normalization can never be true.
  • collectStaticRoutes calls getStaticProps({ params: {} }) on every non-dynamic page during route collection, purely to check revalidate. This has side effects (database queries, API calls) and runs with empty params which may not match the page's expectations.
  • prerenderStaticPages spins up two servers (a Vite dev server for route collection + a prod server for fetching). The dev server uses ssrLoadModule against source files while the prod server uses the built output — if there's any mismatch between source and built state, the route list will be wrong.

Test quality

  • Tests are well-structured but the prerender tests depend on hasServerEntry (a pre-existing build artifact). If the fixture hasn't been built, all 5 prod server tests silently skip via it.skipIf. This makes CI green even when the feature is untestable.

See inline comments for specific suggestions.

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()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (security): The startsWith check is vulnerable to prefix confusion. If the pre-rendered directory is /dist/server/pages, then a resolved path like /dist/server/pages-evil/hack.html will pass the startsWith check because the string /dist/server/pages-evil/... starts with /dist/server/pages.

The fix is to append path.sep (or /) to the resolved directory before the prefix check:

Suggested change
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()) {

Same issue on line 306. In practice this is low-risk since the pathname comes from the URL which is already normalized upstream, but it's a defense-in-depth fix that should be applied.

}

const indexPath = path.join(dir, normalized, "index.html");
if (path.resolve(indexPath).startsWith(resolvedDir) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same prefix confusion bug as line 301:

Suggested change
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()) {

Comment on lines +557 to +559
const normalized = path.posix.normalize(urlPath);
if (normalized.includes("..")) {
throw new Error(`Route path "${urlPath}" contains path traversal segments`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This traversal guard is dead code. path.posix.normalize resolves .. segments, so after normalization, normalized will never contain ... For example, /foo/../../etc/passwd normalizes to /etc/passwd.

Either:

  1. Check before normalizing: if (urlPath.includes('..')) — but this is overly broad and rejects legitimate paths.
  2. Check the resolved output path against the outDir boundary (which is the correct approach, but would need the outDir passed in).
  3. Remove the dead check and add the boundary check at the write site in the callers.

Since the urlPath values come from route patterns and user-defined getStaticPaths/generateStaticParams, option 2 (checking fullPath.startsWith(resolvedOutDir + path.sep) at the write site) would be the most robust defense.

Comment on lines +1129 to +1131
if (typeof pageModule.getStaticProps === "function") {
try {
const propsResult = await pageModule.getStaticProps({ params: {} });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling getStaticProps({ params: {} }) on every non-dynamic page during route collection is a side-effect concern. This function may make database queries, API calls, or other I/O — and it's being called with empty params purely to inspect the revalidate value. For dynamic routes with required params, this will likely throw (which the catch handles, but it means those routes are incorrectly skipped).

Consider a lighter-weight approach:

  1. Check for export const revalidate = 0 as a module-level export (similar to how dynamic is checked on line 1085) instead of executing the function.
  2. Or skip the getStaticProps revalidate check entirely for route collection — only getServerSideProps and force-dynamic are definitive signals that a page can't be pre-rendered. Pages with revalidate: 0 in getStaticProps are relatively rare.

Comment on lines +980 to +1000

// Collect static routes using a temporary Vite dev server
const collected = await collectStaticRoutes(root, false);
result.skipped.push(...collected.skipped);

if (collected.urls.length === 0) {
result.warnings.push("No static routes found — nothing to pre-render");
return result;
}

const staticUrls = collected.urls;

// Start temp production server in-process
const { startProdServer } = await import("../server/prod-server.js");
const server = await startProdServer({
port: 0, // Random available port
host: "127.0.0.1",
outDir: distDir,
});
const addr = server.address() as import("node:net").AddressInfo;
const port = addr.port;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function spins up two servers: a Vite dev server (via collectStaticRoutes on line 982) for route discovery, plus a production server (line 994) for HTML fetching. This is:

  1. Slow — two full server startups during build.
  2. Fragile — the dev server inspects source files while the prod server uses built output. If the source and build are out of sync (e.g., user edited a file after build), the route list won't match reality.

A more robust approach would be to either:

  • Use only the prod server for both route detection and fetching (inspect the built server entry's route table), or
  • Embed the list of static routes into the build output during the Vite build step so it's available without spinning up a dev server.

This doesn't block merging but is worth tracking as a follow-up for build performance.

Comment on lines +273 to +278
// ── 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config is loaded and resolved here, but runStaticExport (line 287) also loads and resolves the config internally. This means the config is parsed twice. Consider either:

  1. Passing the already-resolved config to runStaticExport, or
  2. Having the CLI only check resolvedConfig.output and letting the orchestrator handle everything else.

Also, if the config file has side effects or is expensive to load, double-loading could be a problem.

Comment on lines +340 to +344
const { loadNextConfig, resolveNextConfig } = await import(
/* @vite-ignore */ "./config/next-config.js"
);
const startRawConfig = await loadNextConfig(process.cwd());
const startResolvedConfig = await resolveNextConfig(startRawConfig);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same double-loading issue as in buildApp(). The config is loaded here to check output: "export", then startProdServer will load its own config from the build output. Not a correctness issue but wasteful.

Also, the variable names startRawConfig / startResolvedConfig are a bit unusual. Since this is in its own function scope, rawConfig / resolvedConfig would be fine (no collision).

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hasServerEntry check runs at module evaluation time, before any test setup. If the fixture hasn't been built (no dist/server/entry.js), all 5 prod-server tests silently skip. This means a CI configuration that doesn't pre-build fixtures will show green despite exercising nothing.

Consider either:

  1. Adding a build step in beforeAll if the entry doesn't exist, or
  2. Failing explicitly with a clear message like throw new Error('Fixture not built') instead of skipping, so it's visible when the test infrastructure is broken.

* `vinext build` to dist/server/pages/.
*/
function resolvePrerenderedHtml(dir: string, pathname: string): string | null {
if (!fs.existsSync(dir)) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This fs.existsSync(dir) check runs on every call to resolvePrerenderedHtml, which itself is called on every request (via line 987). Since hasPrerenderedPages is already checked at line 986 before calling this function, this inner check is redundant. It won't cause a bug but it's an unnecessary filesystem syscall per request.

Comment on lines +671 to +683
let paramSets: Record<string, string | string[]>[];
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: {} });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If generateStaticParams throws (e.g., because a required API/database is unavailable at build time), the error propagates unhandled to the caller. In staticExportApp the caller catches this (line 766), but in collectStaticRoutes (line 1101) there's also a catch. However, in expandDynamicAppRoute itself there's no error context added, so the error message won't mention which route failed. Consider wrapping with route context:

try {
  paramSets = await generateStaticParams({ params: {} });
} catch (e) {
  throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`);
}

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 7, 2026

The review has been submitted successfully. Here's a summary of the feedback I posted:

Key findings:

  1. Security bug (should fix): resolvePrerenderedHtml in prod-server.ts uses startsWith(resolvedDir) without a trailing path separator, which is vulnerable to prefix confusion attacks (e.g., a sibling directory named pages-evil would pass the check).

  2. Dead code: The getOutputPath traversal guard checks for .. after path.posix.normalize, but normalize already resolves those segments, making the check always false.

  3. Side effects in route collection: collectStaticRoutes calls getStaticProps({ params: {} }) on every page to inspect the revalidate value. This executes user code with empty params during build, which could trigger unexpected database queries or API calls.

  4. Double server startup in prerender: prerenderStaticPages spins up both a Vite dev server (for route discovery) and a production server (for fetching), which is slow and fragile if source/build are out of sync.

  5. Silent test skipping: The prerender tests use it.skipIf(!hasServerEntry) which means all 5 production server tests silently skip if the fixture hasn't been pre-built.

  6. Minor issues: Missing vite config extensions (mts, cjs), redundant fs.existsSync per request, double config loading in CLI, missing error context in expandDynamicAppRoute, and no guard for port 0 when the dev server fails to bind.

github run

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.
- 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
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.
@southpolesteve
Copy link
Collaborator

Thanks for submitting this! Im going to take a little extra actual human time with this one just to be sure its the approach we want to go with and has no future potential conflict with TPR. Will try and tackle it this weekend

…-build

# Conflicts:
#	packages/vinext/src/build/static-export.ts
#	tests/app-router.test.ts
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Static pre-rendering at build time

This is a well-structured feature with clean separation between the three main pieces: runStaticExport() orchestrator, prerenderStaticPages() for normal builds, and the prod server integration. The refactoring of renderStaticPage to use try/finally for SSR context cleanup, the extraction of expandDynamicAppRoute, and the improved getOutputPath with boundary checking are all good improvements.

The previous bonk review identified several issues (path prefix confusion, dead code, side effects in route collection, etc.). I can see that some have been addressed — the security issue in resolvePrerenderedHtml now correctly uses resolvedDir + path.sep, and the getOutputPath traversal guard now does proper boundary checking. The collectStaticRoutes function was replaced with collectStaticRoutesFromSource which uses regex instead of executing user code — a solid improvement.

I have a few remaining concerns, mostly around the prod server integration ordering and missing test coverage for security-sensitive paths.

Summary of findings

  1. Pre-rendered HTML placement in request pipeline (should fix) — served before afterFiles rewrites, which doesn't match Next.js execution order. Pre-rendered pages in Next.js are logically equivalent to page rendering (step 10), so they should respect afterFiles rewrites.
  2. Missing Cache-Control header on pre-rendered responses — pre-rendered HTML is served without any caching headers, unlike Next.js which sets s-maxage / stale-while-revalidate for static pages.
  3. middlewareRewriteStatus not applied to pre-rendered responses — if middleware sets a custom status via NextResponse.rewrite(url, { status: 403 }), the pre-rendered response ignores it and always returns 200.
  4. Regex-based route classification can be fooled — the collectStaticRoutesFromSource approach is much better than executing user code, but the regex can false-positive on commented-out or string-literal exports.
  5. Config headers from next.config.js not applied to pre-rendered responses — step 5 accumulates matched config headers into middlewareHeaders, but the pre-rendered path only spreads middlewareHeaders, not the config headers that were merged into it. Actually on re-read, config headers ARE merged into middlewareHeaders at step 5, so this is fine.
  6. Test cleanup is brittle — afterAll cleanup in build-prerender.test.ts removes the pagesDir only if empty, but the index.html test creates and removes its file within the test. If the test fails mid-way, cleanup won't happen and subsequent runs may see stale files.

Comment on lines +1029 to +1047
// ── 7b. Pre-rendered HTML ─────────────────────────────────────
// Serve build-time rendered static pages. Placed after middleware,
// basePath stripping, redirects, and rewrites so those all run first.
const pagesPrerenderedFile = hasPrerenderedPages
? resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname)
: null;
if (pagesPrerenderedFile) {
const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8");
const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders };
sendCompressed(
req,
res,
html,
"text/html; charset=utf-8",
200,
prerenderedHeaders,
compress,
);
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering concern: Pre-rendered HTML is served at step 7b, before afterFiles rewrites (step 9) and before SSR (step 10). In Next.js, pre-rendered pages are conceptually the same as page rendering — they sit at the page-matching layer, after all rewrites have been applied.

This means an afterFiles rewrite like { source: '/old-path', destination: '/new-path' } will be bypassed if /old-path has a pre-rendered HTML file. The pre-rendered file will be served instead of following the rewrite.

Consider moving the pre-rendered HTML check to just before step 10 (SSR page rendering), after afterFiles rewrites have been applied:

// ── 9. Apply afterFiles rewrites ...
// ── 9b. Pre-rendered HTML (after all rewrites) ──
// ── 10. SSR page rendering ──

This also ensures API routes (step 8) are never accidentally shadowed by a pre-rendered file named api.html or similar.

Comment on lines +1035 to +1044
if (pagesPrerenderedFile) {
const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8");
const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders };
sendCompressed(
req,
res,
html,
"text/html; charset=utf-8",
200,
prerenderedHeaders,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues with the pre-rendered response:

  1. Missing Cache-Control header. Next.js serves pre-rendered (ISR) pages with Cache-Control: s-maxage=<revalidate>, stale-while-revalidate and static pages with s-maxage=31536000, stale-while-revalidate. Without any cache headers, CDNs won't cache these pages, negating much of the performance benefit of pre-rendering.

  2. middlewareRewriteStatus is ignored. If middleware used NextResponse.rewrite(url, { status: 403 }), the SSR path (step 10) respects this via middlewareRewriteStatus ?? response.status, but the pre-rendered path hardcodes 200. Consider:

Suggested change
if (pagesPrerenderedFile) {
const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8");
const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders };
sendCompressed(
req,
res,
html,
"text/html; charset=utf-8",
200,
prerenderedHeaders,
if (pagesPrerenderedFile) {
const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8");
const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders };
sendCompressed(
req,
res,
html,
"text/html; charset=utf-8",
middlewareRewriteStatus ?? 200,
prerenderedHeaders,
compress,
);
return;
}

Comment on lines +1061 to +1064
// 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/;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex approach is a big improvement over the previous collectStaticRoutes that executed getStaticProps() — no more side effects during route collection.

However, regex-based detection can produce false positives:

  • A commented-out // export async function getServerSideProps will match
  • A string literal "export function getServerSideProps" will match
  • Re-exports like export { getServerSideProps } from './data' won't match

For pre-rendering, false positives (incorrectly skipping a static page) are much safer than false negatives (incorrectly pre-rendering a dynamic page), so this tradeoff is reasonable. But consider adding a comment documenting that this is intentionally conservative:

Suggested change
// 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/;
// Patterns that indicate a page has server-side data fetching.
// These are intentionally broad (may match comments/strings) — false positives
// only cause a static page to be skipped, which is safe. False negatives
// (missing a GSSP page) would cause a broken pre-render.
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/;

// 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/;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: revalidateZeroPattern checks for export const revalidate = 0 but this is a Pages Router context. Pages Router pages use getStaticProps returning { revalidate: 0 }, not a module-level export const revalidate. The export const revalidate convention is an App Router thing.

Since prerenderStaticPages already bails out for App Router builds (line 977), this pattern will never actually match in practice. It's dead code — consider removing it to avoid confusion, or add a comment explaining it's a defensive check for hybrid projects.

Comment on lines +308 to +312
const { loadNextConfig, resolveNextConfig } = await import(
/* @vite-ignore */ "./config/next-config.js"
);
const rawConfig = await loadNextConfig(process.cwd());
const resolvedConfig = await resolveNextConfig(rawConfig);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config is loaded and resolved here, but runStaticExport also accepts and loads config internally (line 853-856 of static-export.ts). For the output: "export" path, the resolved config is passed through (config: resolvedConfig on line 319), so it's only loaded once — good.

But for the non-export path, prerenderStaticPages loads config again internally if needed. The config loaded here on lines 311-312 is only used for the if (resolvedConfig.output === "export") check, then discarded. This is fine for correctness but wasteful.

Consider restructuring to check for output: "export" more cheaply — e.g., reading just the raw config and checking the property before resolving.

Comment on lines +26 to +31
if (!fs.existsSync(serverEntryPath)) {
throw new Error(
`Fixture not built: ${serverEntryPath} does not exist. ` +
`Run "cd ${PAGES_FIXTURE} && pnpm build" first.`,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good improvement over the previous it.skipIf approach — throwing an explicit error makes test infrastructure failures visible. This directly addresses the concern from the previous review about silent test skipping.

One concern: this beforeAll creates pagesDir and prerenderedFile, but if the server startup fails (line 42-46), those files are left behind because afterAll cleanup may not run if beforeAll throws. Consider wrapping the server startup in a try/catch that cleans up the test files on failure.

Comment on lines +109 to +125
it("serves pre-rendered index.html for /", async () => {
const indexFile = path.join(pagesDir, "index.html");
fs.writeFileSync(
indexFile,
`<!DOCTYPE html><html><body>Pre-rendered home</body></html>`,
"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);
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test creates index.html at runtime and fetches / to verify it's served. But there's a subtle issue: the server was started in beforeAll with hasPrerenderedPages determined at startup time (line 752 of prod-server.ts). Since we created pagesDir in beforeAll, hasPrerenderedPages is true.

But the resolvePrerenderedHtml function does per-request fs.existsSync + fs.statSync calls, so adding files after server startup works. This is correct but relies on the implementation detail that file existence is checked per-request, not cached at startup. A comment noting this would help future maintainers.

Comment on lines +884 to +886
if (appDir) {
const addr = server.httpServer?.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this addresses the port 0 guard that the previous review flagged. The check is in the right place.

Comment on lines +996 to +1003
// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If startProdServer fails to bind (e.g., address already in use), the error will propagate but the message won't mention what went wrong. Consider a guard similar to the one in runStaticExport (line 887):

const addr = server.address() as import("node:net").AddressInfo;
if (!addr || addr.port === 0) {
  throw new Error("Production server failed to bind to a port for pre-rendering");
}

Comment on lines +678 to +679
} catch (e) {
throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this wraps the error with route context, addressing the previous review's concern about error messages not mentioning which route failed.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

The review has been submitted successfully. Here's a summary of the feedback I posted:

Key findings:

  1. Pre-rendered HTML placement in request pipeline (should fix): The pre-rendered HTML is served at step 7b, before afterFiles rewrites (step 9). In Next.js, pre-rendered pages are logically equivalent to page rendering, so they should be served after all rewrites have been applied. An afterFiles rewrite would be bypassed if the path has a pre-rendered HTML file. Recommended moving it to just before step 10 (SSR).

  2. Missing Cache-Control and middlewareRewriteStatus: Pre-rendered responses have no caching headers (Next.js sets s-maxage/stale-while-revalidate for static pages), and the middleware rewrite status is hardcoded to 200 instead of respecting middlewareRewriteStatus.

  3. Dead revalidateZeroPattern check: The export const revalidate = 0 regex is an App Router convention, but collectStaticRoutesFromSource only runs for Pages Router builds (App Router bails at line 977). This pattern will never match in practice.

  4. Regex detection tradeoffs are acceptable but undocumented: The regex approach for collectStaticRoutesFromSource can false-positive on comments/strings but is intentionally conservative (false positives only skip pages). Suggested adding a comment documenting this design choice.

  5. Double config loading in CLI: Both buildApp() and start() load and resolve the full config just to check output === "export". Suggested lighter-weight alternatives.

  6. Positive notes: The resolvePrerenderedHtml security fix (using + path.sep), the getOutputPath boundary checking, the expandDynamicAppRoute error wrapping with route context, and the port 0 guard are all well done. The replacement of collectStaticRoutes (which executed user code) with collectStaticRoutesFromSource (regex-based) is a solid improvement.

github run

- Move pre-rendered HTML serving to step 9b (after afterFiles rewrites),
  matching Next.js execution order where rewrites apply before static pages
- Add Cache-Control: s-maxage=31536000, stale-while-revalidate header to
  pre-rendered responses for long-lived CDN caching
- Respect middlewareRewriteStatus in pre-rendered response (was hardcoded 200)
- Remove dead revalidateZeroPattern from collectStaticRoutesFromSource; the
  export const revalidate convention is App Router-only, not Pages Router
- Add doc comment explaining conservative regex detection tradeoffs
- Guard against port 0 after server bind in prerenderStaticPages; close the
  server before throwing to avoid listener leaks
- Replace next.config.js load in start() CLI with cheap fs existence check
  (out/ exists + dist/ missing) to detect static export builds
- Add fetch timeout (30s + AbortController) to staticExportApp URL loop and
  404 fetch, matching the guard already present in prerenderStaticPages
- Add try/catch in test beforeAll to clean up written files if server startup
  fails, preventing stale state across runs
- Add Cache-Control assertion to build-prerender test suite
clearTimeout(t404) was only called on the success path. If fetch() threw
(e.g. AbortError from the timer itself), the timer ref would linger until
it fired. Restructure so clearTimeout is in finally, and cancel unconsumed
response bodies on non-404 status codes to release connections promptly.
@james-elicx james-elicx marked this pull request as draft March 10, 2026 22:01
@james-elicx
Copy link
Collaborator

Self-review: concerns and suggested improvements

After a full read of the PR diff I found a number of issues across correctness, API design, CLI UX, and test quality. Organised by severity below.


Correctness / Behavioural Bugs

ISR pages get a year-long Cache-Control
Any Pages Router page with getStaticProps returning { revalidate: N } will be pre-rendered and served with s-maxage=31536000 — the ISR TTL is completely ignored. The pre-renderer has no way to emit the right header at serve time because the revalidate value isn't persisted alongside the HTML. Fix options: (a) write a .meta.json sidecar with the revalidate value at pre-render time and read it in resolvePrerenderedHtml, or (b) exclude ISR pages from pre-rendering by detecting revalidate in the source scan.

middlewareRewriteStatus forwarded to pre-rendered response
middlewareRewriteStatus ?? 200 in step 9b means a middleware NextResponse.rewrite(url, { status: 403 }) serves the pre-rendered HTML body with a 403 status. Pre-rendered files should always be served as 200 — status codes from middleware that reach step 9b indicate a rewrite, not a denial.

buildUrlFromParams emits literal "undefined" for missing params
If getStaticPaths/generateStaticParams returns an object missing a required key, String(undefined) is used and a file called undefined.html is written to disk. Should throw with a clear message instead.

__NEXT_DATA__.page uses :slug format instead of [slug]
The Next.js client-side router matches hydration by the page field in __NEXT_DATA__. With the colon-parameterised pattern vinext emits (e.g. /blog/:slug), pages using useRouter().query will get an empty object instead of the actual params. Should convert to bracket notation before writing __NEXT_DATA__.

collectStaticRoutesFromSource misses re-exported getServerSideProps

export { getServerSideProps } from './shared'

This passes the regex filter and the page gets pre-rendered, resulting in stale HTML missing server-side data. The comment acknowledges false positives are safe but does not address this class of false negative.

getStaticPaths called without i18n config
locales: [] and defaultLocale: "" are hardcoded. Projects with i18n configured in next.config.js will generate incomplete output since getStaticPaths branches on locales.


API / Design Issues

configOverride silently ignored when config is provided
runStaticExport has undocumented mutual exclusion between config and configOverride. When both are passed, configOverride is silently dropped. Should be documented or resolved by always merging configOverride on top.

appDir: root passed to vinext plugin in createTempViteServer
For a Pages Router project, this causes vinext to search for a non-existent app/ directory. The server creation should be router-agnostic (pass only the root, let vinext auto-detect).

Hybrid app/ + pages/ layout skips Pages Router entirely
When both directories exist, only the App Router is exported. Next.js explicitly supports the hybrid layout during migration — both routers should be exported.

appDir field in AppStaticExportOptions is dead code
Declared on the interface, passed at the call site, never used inside the function.


CLI / UX Issues

"Pre-rendering static pages…" always printed for App Router builds
prerenderStaticPages returns early for App Router but the log message prints unconditionally. Users building App Router apps see a misleading message on every build.

next.config.js loaded twice during build
The Vite build already loads the config internally; buildApp() reads it again. If generateBuildId is non-deterministic the buildId in __NEXT_DATA__ could differ from the one used in the build.

start() static-export heuristic is too coarse
hasOutDir && !hasDistDir false-positives on projects using "outDir": "out" in tsconfig.json, and silently allows vinext start on a partial/hybrid build that has both directories. Checking for dist/server/entry.js specifically would be more reliable.


Test Quality

No test for the path-traversal guard in getOutputPath
The traversal guard is a security-relevant addition with no test. A crafted getStaticPaths return value like ../../etc/passwd should throw, not write outside outDir.

describe blocks share the same dist/server/pages/ directory
The "falls back to SSR" test doesn't verify that about.html is absent. If a previous test run left that file on disk, the assertion passes trivially, masking the fallback logic entirely.

beforeAll throws instead of skipping when fixture isn't built
Vitest surfaces this as a hard failure rather than a pending skip, producing a confusing error message in CI.

trailingSlash is implicit in file-presence assertions
expect(result.files).toContain("about.html") silently breaks if the fixture config ever gains trailingSlash: true (the file becomes about/index.html).

Empty-generateStaticParams test is vacuous
The test title claims it verifies a warning is emitted for empty generateStaticParams, but the assertions only check existing array types — they would pass even if the warning code were deleted.

- Always serve pre-rendered HTML with status 200; middlewareRewriteStatus
  is meaningful only for dynamic rendering, not pre-built files
- Throw on missing required params in buildUrlFromParams instead of
  silently writing 'undefined.html' to disk
- Add patternToNextPage() to convert :slug → [slug] in __NEXT_DATA__.page
  so the client-side router can match routes during hydration
- Exclude ISR pages (getStaticProps with revalidate:) from pre-rendering;
  pre-rendering them with s-maxage=31536000 would defeat the ISR interval
- Add gsspReExportPattern to catch re-exported getServerSideProps
  (export { getServerSideProps } from './shared') which the original
  regex missed, causing broken pre-renders
- Pass actual i18n config (locales/defaultLocale) to getStaticPaths
  instead of hardcoded empty strings
- Remove dead appDir field from AppStaticExportOptions and all call sites
- Apply configOverride even when caller passes pre-resolved config (shallow
  merge), so e.g. output:"export" forced by CLI always takes effect
- Gate pre-render log on pageCount>0||skipped.length>0 to suppress noise
  for App Router builds (which return early with no pages pre-rendered)
- Improve vinext start() heuristic: check dist/server/entry.js or
  dist/server/index.js instead of coarse dist/ directory presence to
  avoid false positives when project uses outDir:"out"
…plicit trailingSlash, non-vacuous empty-GSP test

- build-prerender.test.ts: use describe.skipIf(!fixtureBuilt) instead of
  throwing in beforeAll, so the suite skips gracefully in CI before build
- build-prerender.test.ts: assert about.html absent before the SSR-fallback
  test so the test isn't silently vacuous if a stale file exists in pagesDir
- static-export.ts: export getOutputPath so it can be unit-tested directly
- build-prerender.test.ts: add path-traversal guard tests for getOutputPath
  (two traversal cases + normal paths)
- build-static-export.test.ts: set trailingSlash: false explicitly in
  configOverride so file assertions (about.html vs about/index.html) don't
  silently depend on the default
- build-static-export.test.ts: replace vacuous empty-generateStaticParams
  structural check with a real assertion using the new empty-gsp/[slug]
  fixture route (generateStaticParams returns [])
- tests/fixtures/app-basic/app/empty-gsp/[slug]/page.tsx: new fixture
@james-elicx
Copy link
Collaborator

Self-review — round 2

Previous round caught and fixed the structural issues. This pass focuses on correctness and edge cases remaining after those fixes.


Must fix

prerenderStaticPages ignores trailingSlash config
packages/vinext/src/build/static-export.ts:1090 hardcodes false for trailingSlash:

const outputPath = getOutputPath(urlPath, false, pagesOutDir);

This means pre-rendered files are always written as about.html, never about/index.html. resolvePrerenderedHtml checks both patterns so serving is unaffected, but file names are inconsistent with the project config. If a user has trailingSlash: true, the pre-rendered files won't match the canonical URL pattern that the browser requests (the server redirects /about/about/ first, then looks for about/index.html, doesn't find it, falls through to SSR — defeating the whole point). Should pass the resolved config's trailingSlash here.

collectStaticRoutesFromSource reads from the original source tree, not the built output
packages/vinext/src/build/static-export.ts:1117 scans root/pages/ with fs.readFileSync. This means:

  • TypeScript source files are read at pre-render time (after vinext build)
  • If the source tree isn't present at deploy (e.g. Docker multi-stage builds that copy only dist/), pre-rendering silently falls back to "no static routes" with only a warning.

This is probably acceptable for the current use case (local builds), but worth a comment explaining the assumption: the source tree must be present at pre-render time.


Should fix

__NEXT_DATA__ not injected in pre-rendered Pages Router output
prerenderStaticPages fetches pages via HTTP from the prod server. The prod server's Pages Router SSR entry already injects __NEXT_DATA__ into the response, so this is fine. But staticExportPages (the output: "export" path) injects __NEXT_DATA__ via renderStaticPage manually. These two code paths are inconsistent: for output: "export", the __NEXT_DATA__.page field uses patternToNextPage() to convert :slug → [slug]. For the pre-render path, the __NEXT_DATA__.page field is whatever the SSR entry produces. As long as the SSR entry also uses correct bracket notation this is fine — but it's worth verifying or adding a test.

createTempViteServer always uses appDir: root
packages/vinext/src/build/static-export.ts:61:

plugins: [vinextPlugin({ appDir: root })],

Per the plugin code, appDir is treated as baseDir and the plugin appends app/ to it. Passing root here is the same as not passing appDir at all (the plugin default). This is harmless but confusing — the option name implies it's the app directory, not the project root. Either remove the option (use default) or add a comment explaining the semantics.

Error message inconsistency in staticExportApp
packages/vinext/src/build/static-export.ts:808:

error: `Failed to call generateStaticParams(): ${(e as Error).message}`,

But expandDynamicAppRoute already wraps its own error with route context at line 721:

throw new Error(`generateStaticParams() failed for ${route.pattern}: ...`);

So the outer error message at line 808 is actually a double-wrap: "Failed to call generateStaticParams(): generateStaticParams() failed for /blog/:slug: ...". The outer wrapper adds noise. Either remove it or check that expandDynamicAppRoute always rethrows with context.

resolveParentParams silently skips parent if module load fails
packages/vinext/src/build/static-export.ts:662 catches all errors from parent module loading and continues silently. If a parent's generateStaticParams fails to load (e.g. import error), the child is called with { params: {} } instead — which means the child's generateStaticParams is called without the parent param values. The expanded URLs will be wrong but no warning is emitted. Should at minimum push a warning.


Consider

isrPattern misses the export const revalidate convention
packages/vinext/src/build/static-export.ts:1140:

const isrPattern = /\brevalidate\s*:/;

This matches revalidate: in object literals (correct for getStaticProps return). But in Pages Router, a module-level export const revalidate = 60 is not valid (that's App Router syntax) — so this is actually fine for Pages Router. The comment could clarify this explicitly to prevent future confusion.

isrPattern has undocumented false positive on type annotations

type Config = { revalidate: number }

matches and causes the page to be skipped. The existing comment says "intentionally broad (false positives are safe)" but doesn't call out this specific case. Worth adding an example in the comment.

No Content-Type guard in prerenderStaticPages
If the prod server returns 200 but with Content-Type: application/json (e.g. a JSON API route that was misclassified as static), the pre-renderer will write the JSON response as an .html file. Should check res.headers.get('content-type') contains text/html before writing.

runStaticExport doesn't close the server when staticExportApp or staticExportPages throws
packages/vinext/src/build/static-export.ts:940-977: the try/finally at line 975 closes the server — this is correct. But both staticExportApp and staticExportPages catch per-page errors internally and never throw, so this is fine in practice. Worth a comment noting the invariant.


Confirmed good

  • Path traversal guard in resolvePrerenderedHtml and getOutputPath
  • Pre-render served at step 9b (after all rewrites) ✓
  • Cache-Control: s-maxage=31536000, stale-while-revalidate
  • ISR pages excluded from pre-rendering ✓
  • gsspReExportPattern for re-exported GSSP ✓
  • patternToNextPage() for correct __NEXT_DATA__.page
  • clearTimeout in finally blocks ✓
  • App Router pre-rendering correctly skipped ✓
  • describe.skipIf(!fixtureBuilt) for graceful CI skip ✓
  • about.html absence assertion in fallback test ✓
  • Path-traversal tests for getOutputPath
  • Explicit trailingSlash: false in static export test assertions ✓
  • Non-vacuous empty-GSP test with real fixture ✓

…e clarity

- prerenderStaticPages: load config to use trailingSlash instead of hardcoded false
- prerenderStaticPages: accept optional config to avoid redundant config read from CLI
- prerenderStaticPages: skip non-HTML responses (content-type guard) to prevent corrupt .html files
- resolveParentParams: warn instead of silently swallowing parent module load failures
- staticExportApp: remove double-wrapped error prefix (expandDynamicAppRoute already adds route context)
- createTempViteServer: add comment explaining why appDir: root is intentional
- collectStaticRoutesFromSource: document source-tree-at-deploy-time assumption in JSDoc
- tests: fix path-traversal guard tests — posix normalize cannot escape root, update to match actual behavior
@james-elicx
Copy link
Collaborator

Self-review round 3

Previous round addressed: trailingSlash hardcode, double-wrapped error, silent parent-load failure, Content-Type guard, appDir: root comment, source-tree JSDoc, path-traversal test correctness. Good pass overall. Issues remaining below, roughly in priority order.


Must fix

1. resolvePrerenderedHtml — prefix confusion in traversal guard (prod-server.ts)

The check path.resolve(directPath).startsWith(resolvedDir + path.sep) is correct for the directory separator, but the previous review flagged this and the fix was only applied inconsistently. Verify both the directPath and indexPath checks include + path.sep — they do now. But there's a subtler issue: path.join(dir, normalized, "index.html") where normalized has already had the trailing slash stripped, so normalized = "index" when pathname === "/". That means for the root path, indexPath becomes <dir>/index/index.html, not <dir>/index.html. The directPath branch correctly returns <dir>/index.html for pathname === "/", so the root case works — but it's only because directPath is checked first. The indexPath fallback for "/" is dead/wrong. Consider adding a comment or restructuring to make this explicit so a future refactor doesn't accidentally break it.

2. prerenderStaticPagesstartProdServer failure leaves server open (static-export.ts:1088)

The previous review flagged this and a guard was added for port 0 after listen(). But if startProdServer itself throws (address already in use, etc.) before binding, the error propagates without cleanup — there's no server to close in that case, which is fine. However, if it binds then fails the port check at line ~1088:

if (!addr || addr.port === 0) {
  await new Promise<void>((resolve) => server.close(() => resolve()));
  throw new Error(...);
}

This is correct — good. But the try/finally that calls server.close() wraps only the fetch loop (lines 1095–1133), not the port check itself. If addr.port === 0, we manually close and throw — that's handled. But if the fs.mkdirSync(pagesOutDir) at line 1096 throws (permissions error), the server leaks because the try hasn't started yet when mkdirSync is called... wait, it's inside the try. Let me re-read: pagesOutDir assignment and mkdirSync are inside the outer try block starting at line 1095, so they are covered. This is actually fine. Disregard — this specific concern is addressed.

3. collectStaticRoutesFromSource does not detect export const revalidate (static-export.ts)

The previous review noted that revalidateZeroPattern (checking export const revalidate = 0) was removed. This is good — that pattern is App Router, not Pages Router. But the current ISR detection (isrPattern = /\brevalidate\s*:/) only matches the object key form ({ revalidate: 60 } inside getStaticProps). A page that does:

export async function getStaticProps() {
  return { props: {}, revalidate: 60 }
}

...will match correctly. And a page that uses a variable:

const ONE_HOUR = 3600;
return { props: {}, revalidate: ONE_HOUR };

...will silently miss. But this is the same intentional false-negative tradeoff documented in the comment ("false positives only cause a static page to be skipped, which is safe"). This is acceptable — not a bug, just noting it's a known limitation worth keeping in the JSDoc.


Should fix

4. staticExportApp — Content-Type not checked on rendered HTML (static-export.ts)

The content-type guard was added to prerenderStaticPages (fetching from prod server) but not to staticExportApp (fetching from dev server). The dev server returns text/html for pages and application/json for API routes, but API routes are already excluded by route scanning. However, if a misconfigured route returns a JSON response, it would still be written as an .html file. Low severity given routes are pre-filtered, but for consistency the same guard should apply.

5. resolvePrerenderedHtml — two fs.existsSync + fs.statSync calls per request (prod-server.ts)

On every non-static, non-API request that reaches step 9b, this function makes up to 4 syscalls: existsSync + statSync for directPath, then the same for indexPath. On Linux with a warm FS cache this is fine, but with hasPrerenderedPages already checked at the call site, we could shortcut further by checking existsSync only once per path (consolidate into a single try { fs.statSync(...).isFile() } that handles non-existence). Not a correctness issue — more of a nit.

6. start() heuristic is still fragile (cli.ts)

The check hasOutDir && !hasDistServer will produce a false negative if:

  • User has both out/ and a dist/server/entry.js from a previous non-export build (they ran next export then vinext build in the same directory).
  • More importantly: a fresh export build produces out/ but no dist/, so hasDistServer is correctly false — the guard fires correctly here.

A false positive is possible: if the user has an out/ directory for unrelated reasons (TypeScript, other tools) and no dist/server/ yet (first run), they'll get the static export error on vinext start even though they want a normal server. The comment in the code acknowledges this. Given the comment explains the tradeoff, this is acceptable as-is, but worth calling out for reviewers.

7. __NEXT_DATA__.page for non-dynamic routes (static-export.ts)

patternToNextPage converts :slug to [slug]. For static (non-dynamic) routes, route.pattern is already /about, /blog, etc. with no params, so the conversion is a no-op. Correct. But for the Pages Router, page in __NEXT_DATA__ is expected to be a path like /blog/[slug] — the route template, not the concrete URL. The current code passes patternToNextPage(route.pattern) as page, which is the template — correct for dynamic routes. For static routes it's also correct (no params to convert). ✓


Nits / observations

8. prerenderStaticPages config import style inconsistency

At the top of the file, loadNextConfig and resolveNextConfig are imported as named top-level ES imports (line ~26). But inside prerenderStaticPages, when options.config is absent, they're re-imported via dynamic import():

const { loadNextConfig, resolveNextConfig } = await import("../config/next-config.js");

This works (Vite deduplicates modules), but it's inconsistent with the rest of the file. Since the top-level import already exists, just use it directly:

config = await resolveNextConfig(await loadNextConfig(root));

The dynamic import was presumably added to avoid loading the module eagerly, but the top-level static import already does that. Remove the dynamic re-import.

9. getOutputPath — boundary check is dead for all URL inputs

The test update correctly documents this: path.posix.normalize on any URL starting with / can never produce a path above /, so the startsWith check in getOutputPath never fires. This is worth a comment in the function body noting the guard exists as a defense-in-depth measure against non-URL inputs (e.g., arbitrary generateStaticParams return values on Windows where path resolution behaves differently).

10. App Router staticExportApp — no Content-Type guard (duplicate of #4 above)

Mentioned above. For completeness: staticExportApp writes res.text() directly to .html files without checking Content-Type. The guard in prerenderStaticPages (added in this round) isn't present here.


Previously flagged, now resolved ✓

  • Port 0 guard in runStaticExport
  • clearTimeout in finally blocks ✓
  • trailingSlash: false hardcode in prerenderStaticPages
  • Double-wrapped error message in staticExportApp
  • resolveParentParams silent parent load failure ✓
  • Cache-Control header on pre-rendered responses ✓
  • Pre-rendered pages always served with status 200 ✓
  • middlewareRewriteStatus not applied to pre-rendered responses ✓
  • appDir removed from AppStaticExportOptions
  • patternToNextPage() for correct __NEXT_DATA__.page format ✓
  • Missing required param now throws in buildUrlFromParams
  • describe.skipIf for fixture-dependent tests ✓

The two must-fix items (#1 is an observation/clarification, #2 was re-evaluated and is fine) reduce to one actionable item: the indexPath fallback for pathname === "/" in resolvePrerenderedHtml is dead code — add a comment to prevent future confusion. The should-fix items (#4 and #8) are the most impactful for correctness and consistency respectively.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static pre-rendering at build time

3 participants