Skip to content

feat: support build-time prerendering of routes#3766

Open
bartlomieju wants to merge 1 commit into
mainfrom
feat/prerender
Open

feat: support build-time prerendering of routes#3766
bartlomieju wants to merge 1 commit into
mainfrom
feat/prerender

Conversation

@bartlomieju
Copy link
Copy Markdown
Contributor

Summary

  • Adds prerender option to RouteConfig for generating static HTML at build time
  • Routes marked prerender: true are rendered during the build and served as static files at runtime — zero rendering cost per request
  • Dynamic routes support prerender: () => [{ slug: "foo" }, { slug: "bar" }] to enumerate paths
  • Prerendered pages are served by the existing static files middleware before the router runs

Usage

// Static route — prerendered at build time
export const config: RouteConfig = { prerender: true };
export default () => <h1>About</h1>;
// Dynamic route — enumerate paths to prerender
export const config: RouteConfig = {
  prerender: () => [{ slug: "hello" }, { slug: "world" }],
};
export default (ctx) => <h1>{ctx.params.slug}</h1>;

Closes #3555

Current limitations

  • Builder path only — works with MemoryBuildCache (used in tests and Deno Deploy). DiskBuildCache support (disk snapshots) and Vite plugin integration are not yet implemented.
  • No middleware context — prerendered routes are rendered with a bare App.fsRoutes() app, so custom middleware state (auth, sessions) is not available during prerender. At serve time, middlewares are bypassed entirely since the static files middleware intercepts first.
  • Content-Type — prerendered pages are stored with text/html; charset=UTF-8 content type explicitly, since route pathnames like /about have no file extension for auto-detection.

What's left (follow-up PRs)

  • Vite plugin integration (server_snapshot.ts production branch)
  • DiskBuildCache support — prerender before flush() so HTML is included in the disk snapshot
  • Docs: add prerender to route config docs, add a "Static Site Generation" page under Advanced
  • Support prerendering routes that use _app.tsx and _layout.tsx wrappers via the temp app
  • Consider ISR (Incremental Static Regeneration) with revalidation

Dogfooding

Attempted to dogfood with the www/ docs site, but its routes use dynamic handlers (query params, user-agent detection for Deno CLI redirects) that aren't suitable for prerendering. The feature is best validated on pure-static pages like /about, /pricing, /docs/* with content loaded at build time.

Test plan

  • Prerender - static route/ and /about with prerender: true produce HTML served as static files
  • Prerender - dynamic route with path function[slug] route with prerender: () => [...] generates correct pages
  • Prerender - skips dynamic route with prerender: true — warns and skips when prerender: true is used on a [param] route
  • All 22 existing builder tests pass
  • All 10 static files middleware tests pass

🤖 Generated with Claude Code

Add `prerender` option to `RouteConfig` that generates static HTML at
build time. Routes marked with `prerender: true` are rendered during
the build step and served as static files at runtime, bypassing the
route handler entirely.

For dynamic routes, `prerender` accepts a function that returns an
array of param objects to enumerate which paths to generate.

Closes #3555

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@lunadogbot lunadogbot left a comment

Choose a reason for hiding this comment

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

MemoryBuildCache.loadedFsRoutes, the optional contentType on addProcessedFile, and the pathname === "/" || removal in static_files.ts:31 are all minimal and consistent with the rest of the cache.

One correctness blocker worth fixing before this ships:

  1. substituteParams in packages/fresh/src/dev/prerender.ts:86 calls result.replace(":" + key, value) — substring match, first occurrence only. /:id/:identifier with { id: "X", identifier: "Y" } becomes /X/Xentifier because :identifier starts with :id. Same hazard for :slug vs :slugify, etc. Use a regex with a word boundary (:slug(?![A-Za-z0-9_])), or split on / and match segments exactly so :foo* and :foo are both handled cleanly.
  • nit: encoder.encode(await res.text()) round-trips bytes through a decode/encode; new Uint8Array(await res.arrayBuffer()) is one less pass.
  • nit: tests cover :slug and static routes but not catch-all [...slug] (pattern becomes :slug*) or route groups ((group)). The :slug* branch in substituteParams is untested.
  • nit: fs_crawl.ts:77 matches the literal string "prerender" anywhere in source, including comments and identifiers — same shape as the existing routeOverride check, so it's consistent, but worth a comment noting the heuristic.

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.

Build Time Rendered Pages

2 participants