Skip to content

feat: static pre-rendering at build time#274

Open
NathanDrake2406 wants to merge 13 commits intocloudflare:mainfrom
NathanDrake2406:feat/static-prerender-build
Open

feat: static pre-rendering at build time#274
NathanDrake2406 wants to merge 13 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: fad5609

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

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

2 participants