Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-drinks-invite.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
- kigawas
- kilavvy
- kiliman
- kirillgroshkov
- kkirsche
- kno-raziel
- knownasilya
Expand Down
106 changes: 84 additions & 22 deletions docs/how-to/pre-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,43 +9,105 @@ title: Pre-Rendering
<br/>
<br/>

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)

<docs-warning>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.</docs-warning>

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:

Expand All @@ -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:

Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
52 changes: 52 additions & 0 deletions integration/vite-prerender-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,58 @@ test.describe("Prerendering", () => {
expect(html).toMatch('<h2 data-route="true">About</h2>');
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
});

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("<title>Index Title: Index Loader Data</title>");
expect(html).toMatch("<h1>Root</h1>");
expect(html).toMatch('<h2 data-route="true">Index</h2>');
expect(html).toMatch('<p data-loader-data="true">Index Loader Data</p>');

res = await fixture.requestDocument("/about");
html = await res.text();
expect(html).toMatch("<title>About Title: About Loader Data</title>");
expect(html).toMatch("<h1>Root</h1>");
expect(html).toMatch('<h2 data-route="true">About</h2>');
expect(html).toMatch('<p data-loader-data="true">About Loader Data</p>');
});
});

test.describe("ssr: true", () => {
Expand Down
61 changes: 46 additions & 15 deletions packages/react-router-dev/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ type BuildEndHook = (args: {
viteConfig: Vite.ResolvedConfig;
}) => void | Promise<void>;

export type PrerenderPaths =
| boolean
| Array<string>
| ((args: {
getStaticPaths: () => string[];
}) => Array<string> | Promise<Array<string>>);

/**
* Config to be exported via the default export from `react-router.config.ts`.
*/
Expand Down Expand Up @@ -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<string>
| ((args: {
getStaticPaths: () => string[];
}) => Array<string> | Promise<Array<string>>);
| PrerenderPaths
| {
paths: PrerenderPaths;
unstable_concurrency?: number;
};
/**
* An array of React Router plugin config presets to ease integration with
* other platforms and tools.
Expand Down Expand Up @@ -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"];
Expand Down
1 change: 1 addition & 0 deletions packages/react-router-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading