feat: static pre-rendering at build time#274
feat: static pre-rendering at build time#274NathanDrake2406 wants to merge 22 commits intocloudflare:mainfrom
Conversation
commit: |
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
cd25163 to
ce830ad
Compare
- 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.
|
/bigbonk review please |
There was a problem hiding this comment.
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
resolvePrerenderedHtmlhas a path prefix confusion bug —startsWith(resolvedDir)without a trailing separator means a directory namedpages-eviladjacent topageswould pass the check (see inline comment).
Correctness concerns
- The
getOutputPathtraversal guard is dead code —path.posix.normalizeresolves..segments, so theincludes('..')check after normalization can never be true. collectStaticRoutescallsgetStaticProps({ params: {} })on every non-dynamic page during route collection, purely to checkrevalidate. This has side effects (database queries, API calls) and runs with empty params which may not match the page's expectations.prerenderStaticPagesspins up two servers (a Vite dev server for route collection + a prod server for fetching). The dev server usesssrLoadModuleagainst 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 viait.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()) { |
There was a problem hiding this comment.
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:
| 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()) { |
There was a problem hiding this comment.
Same prefix confusion bug as line 301:
| 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()) { |
| const normalized = path.posix.normalize(urlPath); | ||
| if (normalized.includes("..")) { | ||
| throw new Error(`Route path "${urlPath}" contains path traversal segments`); |
There was a problem hiding this comment.
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:
- Check before normalizing:
if (urlPath.includes('..'))— but this is overly broad and rejects legitimate paths. - Check the resolved output path against the
outDirboundary (which is the correct approach, but would need theoutDirpassed in). - 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.
| if (typeof pageModule.getStaticProps === "function") { | ||
| try { | ||
| const propsResult = await pageModule.getStaticProps({ params: {} }); |
There was a problem hiding this comment.
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:
- Check for
export const revalidate = 0as a module-level export (similar to howdynamicis checked on line 1085) instead of executing the function. - Or skip the
getStaticPropsrevalidate check entirely for route collection — onlygetServerSidePropsandforce-dynamicare definitive signals that a page can't be pre-rendered. Pages withrevalidate: 0ingetStaticPropsare relatively rare.
|
|
||
| // 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; |
There was a problem hiding this comment.
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:
- Slow — two full server startups during build.
- 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.
| // ── 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); |
There was a problem hiding this comment.
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:
- Passing the already-resolved config to
runStaticExport, or - Having the CLI only check
resolvedConfig.outputand 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.
packages/vinext/src/cli.ts
Outdated
| const { loadNextConfig, resolveNextConfig } = await import( | ||
| /* @vite-ignore */ "./config/next-config.js" | ||
| ); | ||
| const startRawConfig = await loadNextConfig(process.cwd()); | ||
| const startResolvedConfig = await resolveNextConfig(startRawConfig); |
There was a problem hiding this comment.
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).
tests/build-prerender.test.ts
Outdated
| 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); |
There was a problem hiding this comment.
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:
- Adding a build step in
beforeAllif the entry doesn't exist, or - 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; |
There was a problem hiding this comment.
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.
| 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: {} }); |
There was a problem hiding this comment.
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}`);
}|
The review has been submitted successfully. Here's a summary of the feedback I posted: Key findings:
|
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.
|
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
|
/bigbonk review |
There was a problem hiding this comment.
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
- Pre-rendered HTML placement in request pipeline (should fix) — served before
afterFilesrewrites, 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 respectafterFilesrewrites. - Missing
Cache-Controlheader on pre-rendered responses — pre-rendered HTML is served without any caching headers, unlike Next.js which setss-maxage/stale-while-revalidatefor static pages. middlewareRewriteStatusnot applied to pre-rendered responses — if middleware sets a custom status viaNextResponse.rewrite(url, { status: 403 }), the pre-rendered response ignores it and always returns 200.- Regex-based route classification can be fooled — the
collectStaticRoutesFromSourceapproach is much better than executing user code, but the regex can false-positive on commented-out or string-literal exports. - 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 spreadsmiddlewareHeaders, not the config headers that were merged into it. Actually on re-read, config headers ARE merged intomiddlewareHeadersat step 5, so this is fine. - Test cleanup is brittle — afterAll cleanup in build-prerender.test.ts removes the
pagesDironly if empty, but theindex.htmltest 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.
| // ── 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; |
There was a problem hiding this comment.
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.
| 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, |
There was a problem hiding this comment.
Two issues with the pre-rendered response:
-
Missing
Cache-Controlheader. Next.js serves pre-rendered (ISR) pages withCache-Control: s-maxage=<revalidate>, stale-while-revalidateand static pages withs-maxage=31536000, stale-while-revalidate. Without any cache headers, CDNs won't cache these pages, negating much of the performance benefit of pre-rendering. -
middlewareRewriteStatusis ignored. If middleware usedNextResponse.rewrite(url, { status: 403 }), the SSR path (step 10) respects this viamiddlewareRewriteStatus ?? response.status, but the pre-rendered path hardcodes200. Consider:
| 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; | |
| } |
| // 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/; |
There was a problem hiding this comment.
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 getServerSidePropswill 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:
| // 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/; |
There was a problem hiding this comment.
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.
| const { loadNextConfig, resolveNextConfig } = await import( | ||
| /* @vite-ignore */ "./config/next-config.js" | ||
| ); | ||
| const rawConfig = await loadNextConfig(process.cwd()); | ||
| const resolvedConfig = await resolveNextConfig(rawConfig); |
There was a problem hiding this comment.
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.
tests/build-prerender.test.ts
Outdated
| if (!fs.existsSync(serverEntryPath)) { | ||
| throw new Error( | ||
| `Fixture not built: ${serverEntryPath} does not exist. ` + | ||
| `Run "cd ${PAGES_FIXTURE} && pnpm build" first.`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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.
| if (appDir) { | ||
| const addr = server.httpServer?.address(); | ||
| const port = typeof addr === "object" && addr ? addr.port : 0; |
There was a problem hiding this comment.
Good — this addresses the port 0 guard that the previous review flagged. The check is in the right place.
| // 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; |
There was a problem hiding this comment.
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");
}| } catch (e) { | ||
| throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`); |
There was a problem hiding this comment.
Good — this wraps the error with route context, addressing the previous review's concern about error messages not mentioning which route failed.
|
The review has been submitted successfully. Here's a summary of the feedback I posted: Key findings:
|
- 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.
Self-review: concerns and suggested improvementsAfter 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 BugsISR pages get a year-long
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.
API / Design Issues
Hybrid
CLI / UX Issues"Pre-rendering static pages…" always printed for App Router builds
Test QualityNo test for the path-traversal guard in
Empty- |
- 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
Self-review — round 2Previous round caught and fixed the structural issues. This pass focuses on correctness and edge cases remaining after those fixes. Must fix
const outputPath = getOutputPath(urlPath, false, pagesOutDir);This means pre-rendered files are always written as
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
plugins: [vinextPlugin({ appDir: root })],Per the plugin code, Error message inconsistency in error: `Failed to call generateStaticParams(): ${(e as Error).message}`,But throw new Error(`generateStaticParams() failed for ${route.pattern}: ...`);So the outer error message at line 808 is actually a double-wrap:
Consider
const isrPattern = /\brevalidate\s*:/;This matches
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
Confirmed good
|
…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
Self-review round 3Previous round addressed: Must fix1. The check 2. The previous review flagged this and a guard was added for port 0 after if (!addr || addr.port === 0) {
await new Promise<void>((resolve) => server.close(() => resolve()));
throw new Error(...);
}This is correct — good. But the 3. The previous review noted that 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 fix4. The content-type guard was added to 5. On every non-static, non-API request that reaches step 9b, this function makes up to 4 syscalls: 6. The check
A false positive is possible: if the user has an 7.
Nits / observations8. At the top of the file, 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. The test update correctly documents this: 10. App Router Mentioned above. For completeness: Previously flagged, now resolved ✓
The two must-fix items (#1 is an observation/clarification, #2 was re-evaluated and is fine) reduce to one actionable item: the |
Summary
Implements build-time static pre-rendering for vinext, closing #9.
vinext buildwithoutput: "export"now runs full static export toout/(Phase 1)vinext buildin normal mode pre-renders static pages todist/server/pages/(Phase 2)vinext startrejects export builds with a helpful error messagePhase 1:
output: "export"supportAdds
runStaticExport()orchestrator that connects the existingstaticExportPages/staticExportAppfunctions to the CLI build pipeline. Loads config, detects router type, starts a temp dev server, scans routes, renders all static pages toout/.Phase 2: Selective pre-rendering in normal builds
Adds
prerenderStaticPages()that runs after production builds:getServerSideProps,force-dynamic, etc.)dist/server/pages/Files changed
packages/vinext/src/build/static-export.tsrunStaticExport(),prerenderStaticPages(),collectStaticRoutes()packages/vinext/src/cli.tsbuildApp(), export build rejection instart()packages/vinext/src/server/prod-server.tsresolvePrerenderedHtml()+ pre-render serving in both router handlerstests/build-static-export.test.tsrunStaticExport()(both routers)tests/build-prerender.test.tsprerenderStaticPages()Test plan
pnpm run typecheckpassespnpm run lintpassesCloses #9