diff --git a/.changeset/big-drinks-invite.md b/.changeset/big-drinks-invite.md
new file mode 100644
index 0000000000..89c0ab635e
--- /dev/null
+++ b/.changeset/big-drinks-invite.md
@@ -0,0 +1,5 @@
+---
+"@react-router/dev": patch
+---
+
+Introduce a `prerender.unstable_concurrency` option, to support running the prerendering concurrently, potentially speeding up the build.
diff --git a/contributors.yml b/contributors.yml
index 7b0c1b6f4b..f63e094e67 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -212,6 +212,7 @@
- kigawas
- kilavvy
- kiliman
+- kirillgroshkov
- kkirsche
- kno-raziel
- knownasilya
diff --git a/docs/how-to/pre-rendering.md b/docs/how-to/pre-rendering.md
index 71e182c423..e0cbc6a85d 100644
--- a/docs/how-to/pre-rendering.md
+++ b/docs/how-to/pre-rendering.md
@@ -9,43 +9,105 @@ title: Pre-Rendering
-Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime. Pre-rendering is enabled via the `prerender` config in `react-router.config.ts` and can be used in two ways based on the `ssr` config value:
+Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime.
-- Alongside a runtime SSR server with `ssr:true` (the default value)
-- Deployed to a static file server with `ssr:false`
+## Configuration
+
+Pre-rendering is enabled via the `prerender` config in `react-router.config.ts`.
+
+The simplest configuration is a boolean `true` which will pre-render all off the applications static paths based on `routes.ts`:
+
+```ts filename=react-router.config.ts
+import type { Config } from "@react-router/dev/config";
-## Pre-rendering with `ssr:true`
+export default {
+ prerender: true,
+} satisfies Config;
+```
-### Configuration
+The boolean `true` will not include any dynamic paths (i.e., `/blog/:slug`) because the parameter values are unknown.
-Add the `prerender` option to your config, there are three signatures:
+To configure specific paths including dynamic values, you can specify an array of paths:
-```ts filename=react-router.config.ts lines=[7-8,10-11,13-21]
+```ts filename=react-router.config.ts
import type { Config } from "@react-router/dev/config";
+let slugs = getPostSlugs();
+
export default {
- // Can be omitted - defaults to true
- ssr: true,
+ prerender: [
+ "/",
+ "/blog",
+ ...slugs.map((s) => `/blog/${s}`),
+ ],
+} satisfies Config;
+```
- // all static paths (no dynamic segments like "/post/:slug")
- prerender: true,
+If you need to perform more complex and/or asynchronous logic to determine the paths, you can also provide a function that returns an array of paths. This function provides you with a `getStaticPaths` method you can use to avoid manually adding all of the static paths in your application:
- // specific paths
- prerender: ["/", "/blog", "/blog/popular-post"],
+```ts filename=react-router.config.ts
+import type { Config } from "@react-router/dev/config";
- // async function for dependencies like a CMS
+export default {
async prerender({ getStaticPaths }) {
- let posts = await fakeGetPostsFromCMS();
+ let slugs = await getPostSlugsFromCMS();
return [
+ ...getStaticPaths(), // "/" and "/blog"
+ ...slugs.map((s) => `/blog/${s}`),
+ ];
+ },
+} satisfies Config;
+```
+
+### Concurrency (unstable)
+
+This API is experimental and subject to breaking changes in
+minor/patch releases. Please use with caution and pay **very** close attention
+to release notes for relevant changes.
+
+By default, pages are pre-rendered one path at a time. You can enable concurrency to pre-render multiple paths in parallel which can speed up build times in many cases. You should experiment with the value that provides the best performance for your app.
+
+To specify concurrency, move your `prerender` config down into a `prerender.paths` field and you can specify the concurrency in `prerender.unstable_concurrency`:
+
+```ts filename=react-router.config.ts
+import type { Config } from "@react-router/dev/config";
+
+let slugs = getPostSlugs();
+
+export default {
+ prerender: {
+ paths: [
"/",
"/blog",
- ...posts.map((post) => post.href),
- ];
+ ...slugs.map((s) => `/blog/${s}`),
+ ],
+ unstable_concurrency: 4,
},
} satisfies Config;
```
-### Data Loading and Pre-rendering
+## Pre-Rendering with/without a Runtime Server
+
+Pre-Rendering can be used in two ways based on the `ssr` config value:
+
+- Alongside a runtime SSR server with `ssr:true` (the default value)
+- Deployed to a static file server with `ssr:false`
+
+### Pre-rendering with `ssr:true`
+
+When pre-rendering with `ssr:true`, you're indicating you will still have a runtime server but you are choosing to pre-render certain paths for quicker Response times.
+
+```ts filename=react-router.config.ts
+import type { Config } from "@react-router/dev/config";
+
+export default {
+ // Can be omitted - defaults to true
+ ssr: true,
+ prerender: ["/", "/blog", "/blog/popular-post"],
+} satisfies Config;
+```
+
+#### Data Loading and Pre-rendering
There is no extra application API for pre-rendering. Routes being pre-rendered use the same route `loader` functions as server rendering:
@@ -64,7 +126,7 @@ Instead of a request coming to your route on a deployed server, the build create
When server rendering, requests to paths that have not been pre-rendered will be server rendered as usual.
-### Static File Output
+#### Static File Output
The rendered result will be written out to your `build/client` directory. You'll notice two files for each path:
@@ -89,7 +151,7 @@ Prerender: Generated build/client/blog/my-first-post/index.html
During development, pre-rendering doesn't save the rendered results to the public directory, this only happens for `react-router build`.
-## Pre-rendering with `ssr:false`
+### Pre-rendering with `ssr:false`
The above examples assume you are deploying a runtime server but are pre-rendering some static pages to avoid hitting the server, resulting in faster loads.
@@ -108,7 +170,7 @@ If you specify `ssr:false` without a `prerender` config, React Router refers to
If you want to pre-render paths with `ssr:false`, those matched routes _can_ have loaders because we'll pre-render all of the matched routes for those paths, not just the root. You cannot include `actions` or `headers` functions in any routes when `ssr:false` is set because there will be no runtime server to run them on.
-### Pre-rendering with a SPA Fallback
+#### Pre-rendering with a SPA Fallback
If you want `ssr:false` but don't want to pre-render _all_ of your routes - that's fine too! You may have some paths where you need the performance/SEO benefits of pre-rendering, but other pages where a SPA would be fine.
@@ -155,7 +217,7 @@ sirv-cli build/client --single index.html
sirv-cli build/client --single __spa-fallback.html
```
-### Invalid Exports
+#### Invalid Exports
When pre-rendering with `ssr:false`, React Router will error at build time if you have invalid exports to help prevent some mistakes that can be easily overlooked.
diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts
index ebbf8f62f9..2cd0c95bbe 100644
--- a/integration/vite-prerender-test.ts
+++ b/integration/vite-prerender-test.ts
@@ -577,6 +577,58 @@ test.describe("Prerendering", () => {
expect(html).toMatch('
About
');
expect(html).toMatch('About Loader Data
');
});
+
+ test("Permits a concurrency option", async () => {
+ fixture = await createFixture({
+ prerender: true,
+ files: {
+ ...files,
+ "react-router.config.ts": js`
+ export default {
+ prerender: {
+ paths: ['/', '/about'],
+ unstable_concurrency: 2,
+ },
+ }
+ `,
+ "vite.config.ts": js`
+ import { defineConfig } from "vite";
+ import { reactRouter } from "@react-router/dev/vite";
+
+ export default defineConfig({
+ build: { manifest: true },
+ plugins: [
+ reactRouter()
+ ],
+ });
+ `,
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+
+ let clientDir = path.join(fixture.projectDir, "build", "client");
+ expect(listAllFiles(clientDir).sort()).toEqual([
+ "_root.data",
+ "about.data",
+ "about/index.html",
+ "favicon.ico",
+ "index.html",
+ ]);
+
+ let res = await fixture.requestDocument("/");
+ let html = await res.text();
+ expect(html).toMatch("Index Title: Index Loader Data");
+ expect(html).toMatch("Root
");
+ expect(html).toMatch('Index
');
+ expect(html).toMatch('Index Loader Data
');
+
+ res = await fixture.requestDocument("/about");
+ html = await res.text();
+ expect(html).toMatch("About Title: About Loader Data");
+ expect(html).toMatch("Root
");
+ expect(html).toMatch('About
');
+ expect(html).toMatch('About Loader Data
');
+ });
});
test.describe("ssr: true", () => {
diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts
index 44f63824a0..3e0df29fc4 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -110,6 +110,13 @@ type BuildEndHook = (args: {
viteConfig: Vite.ResolvedConfig;
}) => void | Promise;
+export type PrerenderPaths =
+ | boolean
+ | Array
+ | ((args: {
+ getStaticPaths: () => string[];
+ }) => Array | Promise>);
+
/**
* Config to be exported via the default export from `react-router.config.ts`.
*/
@@ -149,13 +156,19 @@ export type ReactRouterConfig = {
/**
* An array of URLs to prerender to HTML files at build time. Can also be a
* function returning an array to dynamically generate URLs.
+ *
+ * `unstable_concurrency` defaults to 1, which means "no concurrency" - fully serial execution.
+ * Setting it to a value more than 1 enables concurrent prerendering.
+ * Setting it to a value higher than one can increase the speed of the build,
+ * but may consume more resources, and send more concurrent requests to the
+ * server/CMS.
*/
prerender?:
- | boolean
- | Array
- | ((args: {
- getStaticPaths: () => string[];
- }) => Array | Promise>);
+ | PrerenderPaths
+ | {
+ paths: PrerenderPaths;
+ unstable_concurrency?: number;
+ };
/**
* An array of React Router plugin config presets to ease integration with
* other platforms and tools.
@@ -462,17 +475,35 @@ async function resolveConfig({
serverBundles = undefined;
}
- let isValidPrerenderConfig =
- prerender == null ||
- typeof prerender === "boolean" ||
- Array.isArray(prerender) ||
- typeof prerender === "function";
+ if (prerender) {
+ let isValidPrerenderPathsConfig = (p: unknown) =>
+ typeof p === "boolean" || typeof p === "function" || Array.isArray(p);
- if (!isValidPrerenderConfig) {
- return err(
- "The `prerender` config must be a boolean, an array of string paths, " +
- "or a function returning a boolean or array of string paths",
- );
+ let isValidPrerenderConfig =
+ isValidPrerenderPathsConfig(prerender) ||
+ (typeof prerender === "object" &&
+ "paths" in prerender &&
+ isValidPrerenderPathsConfig(prerender.paths));
+
+ if (!isValidPrerenderConfig) {
+ return err(
+ "The `prerender`/`prerender.paths` config must be a boolean, an array " +
+ "of string paths, or a function returning a boolean or array of string paths.",
+ );
+ }
+
+ let isValidConcurrencyConfig =
+ typeof prerender != "object" ||
+ !("unstable_concurrency" in prerender) ||
+ (typeof prerender.unstable_concurrency === "number" &&
+ Number.isInteger(prerender.unstable_concurrency) &&
+ prerender.unstable_concurrency > 0);
+
+ if (!isValidConcurrencyConfig) {
+ return err(
+ "The `prerender.unstable_concurrency` config must be a positive integer if specified.",
+ );
+ }
}
let routeDiscovery: ResolvedReactRouterConfig["routeDiscovery"];
diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json
index 2722b58c1a..2e37951c2e 100644
--- a/packages/react-router-dev/package.json
+++ b/packages/react-router-dev/package.json
@@ -83,6 +83,7 @@
"isbot": "^5.1.11",
"jsesc": "3.0.2",
"lodash": "^4.17.21",
+ "p-map": "^7.0.3",
"pathe": "^1.1.2",
"picocolors": "^1.1.1",
"prettier": "^3.6.2",
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index d409f808c0..683feba361 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -36,6 +36,7 @@ import pick from "lodash/pick";
import jsesc from "jsesc";
import colors from "picocolors";
import kebabCase from "lodash/kebabCase";
+import pMap from "p-map";
import * as Typegen from "../typegen";
import type { RouteManifestEntry, RouteManifest } from "../config/routes";
@@ -79,6 +80,7 @@ import {
createConfigLoader,
resolveEntryFiles,
configRouteToBranchRoute,
+ type PrerenderPaths,
} from "../config/config";
import { getOptimizeDepsEntries } from "./optimize-deps-entries";
import { decorateComponentExportsWithProps } from "./with-props";
@@ -2658,11 +2660,12 @@ async function handlePrerender(
}
let buildRoutes = createPrerenderRoutes(build.routes);
- for (let path of build.prerender) {
+
+ let prerenderSinglePath = async (path: string) => {
// Ensure we have a leading slash for matching
let matches = matchRoutes(buildRoutes, `/${path}/`.replace(/^\/\/+/, "/"));
if (!matches) {
- continue;
+ return;
}
// When prerendering a resource route, we don't want to pass along the
// `.data` file since we want to prerender the raw Response returned from
@@ -2731,7 +2734,15 @@ async function handlePrerender(
: undefined,
);
}
+ };
+
+ let concurrency = 1;
+ let { prerender } = reactRouterConfig;
+ if (typeof prerender === "object" && "unstable_concurrency" in prerender) {
+ concurrency = prerender.unstable_concurrency ?? 1;
}
+
+ await pMap(build.prerender, prerenderSinglePath, { concurrency });
}
function getStaticPrerenderPaths(routes: DataRouteObject[]) {
@@ -2916,33 +2927,49 @@ export async function getPrerenderPaths(
routes: GenericRouteManifest,
logWarning = false,
): Promise {
- let prerenderPaths: string[] = [];
- if (prerender != null && prerender !== false) {
- let prerenderRoutes = createPrerenderRoutes(routes);
- if (prerender === true) {
- let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes);
- if (logWarning && !ssr && paramRoutes.length > 0) {
- console.warn(
- colors.yellow(
- [
- "⚠️ Paths with dynamic/splat params cannot be prerendered when " +
- "using `prerender: true`. You may want to use the `prerender()` " +
- "API to prerender the following paths:",
- ...paramRoutes.map((p) => " - " + p),
- ].join("\n"),
- ),
- );
- }
- prerenderPaths = paths;
- } else if (typeof prerender === "function") {
- prerenderPaths = await prerender({
- getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths,
- });
- } else {
- prerenderPaths = prerender || ["/"];
+ if (prerender == null || prerender === false) {
+ return [];
+ }
+
+ let pathsConfig: PrerenderPaths;
+
+ if (typeof prerender === "object" && "paths" in prerender) {
+ pathsConfig = prerender.paths;
+ } else {
+ pathsConfig = prerender;
+ }
+
+ if (pathsConfig === false) {
+ return [];
+ }
+
+ let prerenderRoutes = createPrerenderRoutes(routes);
+
+ if (pathsConfig === true) {
+ let { paths, paramRoutes } = getStaticPrerenderPaths(prerenderRoutes);
+ if (logWarning && !ssr && paramRoutes.length > 0) {
+ console.warn(
+ colors.yellow(
+ [
+ "⚠️ Paths with dynamic/splat params cannot be prerendered when " +
+ "using `prerender: true`. You may want to use the `prerender()` " +
+ "API to prerender the following paths:",
+ ...paramRoutes.map((p) => " - " + p),
+ ].join("\n"),
+ ),
+ );
}
+ return paths;
}
- return prerenderPaths;
+
+ if (typeof pathsConfig === "function") {
+ let paths = await pathsConfig({
+ getStaticPaths: () => getStaticPrerenderPaths(prerenderRoutes).paths,
+ });
+ return paths;
+ }
+
+ return pathsConfig;
}
// Note: Duplicated from react-router/lib/server-runtime
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c3e43eaea6..44cd3ad065 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1098,6 +1098,9 @@ importers:
lodash:
specifier: ^4.17.21
version: 4.17.21
+ p-map:
+ specifier: ^7.0.3
+ version: 7.0.3
pathe:
specifier: ^1.1.2
version: 1.1.2
@@ -8147,6 +8150,10 @@ packages:
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
engines: {node: '>=6'}
+ p-map@7.0.3:
+ resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
+ engines: {node: '>=18'}
+
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
@@ -17759,6 +17766,8 @@ snapshots:
p-map@2.1.0: {}
+ p-map@7.0.3: {}
+
p-try@2.2.0: {}
pac-proxy-agent@7.0.2: