Skip to content

Improvements to ssr:false + prerender scenarios #12948

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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/fresh-buttons-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Don't apply Single Fetch revalidation de-optimization when in SPA mode since there is no server HTTP request
12 changes: 12 additions & 0 deletions .changeset/prerender-invalid-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@react-router/dev": patch
---

Enhance invalid export detection when using `ssr:false`

- `headers`/`action` are prohibited in all routes with `ssr:false` because there will be no runtime server on which to run them
- `loader` functions are more nuanced and depend on whether a given route is prerendered
- When using `ssr:false` without a `prerender` config, only the `root` route can have a `loader`
- This is "SPA mode" which generates a single `index.html` file with the root route `HydrateFallback` so it is capable of hydrating for any path in your application - therefore we can only call a root route `loader` at build time
- When using `ssr:false` with a `prerender` config, you can export a `loader` from routes matched by one of the `prerender` paths because those routes will be server rendered at build time
- Exporting a `loader` from a route that is never matched by a `prerender` path will throw a build time error because there will be no runtime server to ever run the loader
13 changes: 13 additions & 0 deletions .changeset/prerender-spa-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@react-router/dev": minor
---

Generate a "SPA fallback" HTML file for scenarios where applications are prerendering the `/` route with `ssr:false`

- If you specify `ssr:false` without a `prerender` config, this is considered "SPA Mode" and the generated `index.html` file will only render down to the root route and will be able to hydrate for any valid application path
- If you specify `ssr:false` with a `prerender` config but _do not_ include the `/` path (i.e., `prerender: ['/blog/post']`), then we still generate a "SPA Mode" `index.html` file that can hydrate for any path in the application
- However, previously if you specified `ssr:false` and included the `/` path in your `prerender` config, we would prerender the `/` route into `index.html` as a non-SPA page
- The generated HTML would include the root index route which prevented hydration for any other paths
- With this change, we now generate a "SPA Mode" file in `__spa-fallback.html` that will allow you to hydrate for any non-prerendered paths
- You can serve this file from your static file server for any paths that would otherwise 404 if you only want to pre-render _some_ routes in your `ssr:false` app and serve the others as a SPA
- `npx sirv-cli build/client --single __spa-fallback.html`
9 changes: 9 additions & 0 deletions .changeset/spa-mode-root-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@react-router/dev": minor
---

- Allow a `loader` in the root route in SPA mode because it can be called/server-rendered at build time
- `Route.HydrateFallbackProps` now also receives `loaderData`
- This will be defined so long as the `HydrateFallback` is rendering while _children_ routes are loading
- This will be `undefined` if the `HydrateFallback` is rendering because the route has it's own hydrating `clientLoader`
- In SPA mode, this will allow you to render loader root data into the SPA `index.html`
9 changes: 9 additions & 0 deletions .changeset/stale-ways-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"react-router": patch
---

Align dev server behavior with static file server behavior when `ssr:false` is set

- When no `prerender` config exists, only SSR down to the root `HydrateFallback` (SPA Mode)
- When a `prerender` config exists but the current path is not prerendered, only SSR down to the root `HydrateFallback` (SPA Fallback)
- Return a 404 on `.data` requests to non-pre-rendered paths
71 changes: 65 additions & 6 deletions docs/how-to/pre-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ title: Pre-Rendering

# Pre-Rendering

Pre-rendering allows you to render pages at build time instead of on a server to speed up pages loads for static content.
Pre-rendering allows you to render pages at build time instead of on a runtime server to speed up page loads for static content.

## Configuration
In some cases, you'll serve these pages _alongside_ a runtime SSR server. If you wish to pre-render pages and deploy them _without_ a runtime SSR server, please see the [Pre-rendering with `ssr:false`](#Pre-rendering-with-ssrfalse) section below.

## Pre-rendering with ssr:true

### Configuration

Add the `prerender` option to your config, there are three signatures:

```ts filename=react-router.config.ts
```ts filename=react-router.config.ts lines=[7-09,11-12,14-20]
import type { Config } from "@react-router/dev/config";

export default {
// Can be omitted - defaults to true
ssr: true,

// all static route paths
// (no dynamic segments like "/post/:slug")
prerender: true,
Expand All @@ -22,7 +29,7 @@ export default {
prerender: ["/", "/blog", "/blog/popular-post"],

// async function for dependencies like a CMS
async prerender({ getStaticPaths }) {
async pre-render({ getStaticPaths }) {
let posts = await fakeGetPostsFromCMS();
return ["/", "/blog"].concat(
posts.map((post) => post.href)
Expand All @@ -31,7 +38,7 @@ export default {
} satisfies Config;
```

## Data Loading and Pre-rendering
### Data Loading and Pre-rendering

There is no extra application API for pre-rendering. Pre-rendering uses the same route loaders as server rendering:

Expand All @@ -50,7 +57,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 @@ -74,3 +81,55 @@ 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`

The above examples assume you are deploying a runtime server, but are pre-rendering some static pages in order to serve them faster and avoid hitting the server.

To disable runtime SSR, you can set the `ssr:false` config flag:

```ts filename=react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
ssr: false, // disable runtime server rendering
prerender: true, // pre-render static routes
} satisfies Config;
```

If you specify `ssr:false` without a `prerender` config, React Router refers to that as [SPA Mode](./spa). In SPA Mode, we render a single HTML file that is capable of hydrating for _any_ of your application paths. It can do this because it only renders the `root` route into the HTML file and then determines which child routes to load based on the browser URL during hydration. This means you can use a `loader` on the root route, but not on any other routes because we don't know which routes to load until hydration in the browser.

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. Usually, with `prerender:true`, you'll be pre-rendering all of your application routes into a full SSG setup.

### 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.

You can do this using the combination of config options as well - just limit your `prerender` config to the paths that you want to pre-render and React Router will also output a "SPA Fallback" HTML file that can be served to hydrate any other paths (using the same approach as [SPA Mode](./spa)).

This will be written to one of the following paths:

- `build/client/index.html` - If the `/` path is not pre-rendered
- `build/client/__spa-fallback.html` - If the `/` path is pre-rendered

```ts filename=react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
ssr: false,

// SPA fallback will be written to build/client/index.html
prerender: ["/about-us"],

// SPA fallback will be written to build/client/__spa-fallback.html
prerender: ["/", "/about-us"],
} satisfies Config;
```

You can configure your deployment server to serve this file for any path that otherwise would 404.

Here's an example of how you can do this with the [`sirv-cli`](https://www.npmjs.com/package/sirv-cli#user-content-single-page-applications) tool:

```sh
sirv-cli build/client --single __spa-fallback.html
```
9 changes: 5 additions & 4 deletions docs/how-to/spa.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ There are two ways to ship a single page app with React Router

## 1. Disable Server Rendering

Server rendering is enabled by default. Set the ssr flag to false in `react-router.config.ts` to disable it.
Server rendering is enabled by default. Set the `ssr` flag to `false` in `react-router.config.ts` to disable it.

```ts filename=react-router.config.ts lines=[4]
import { type Config } from "@react-router/dev/config";
Expand Down Expand Up @@ -65,10 +65,11 @@ If you're getting 404s at valid routes for your app, it's likely you need to con

Typical Single Pages apps send a mostly blank `index.html` template with little more than an empty `<div id="root"></div>`.

In contrast `react-router build` (with server rendering disabled) pre-renders your root and index routes. This means you can:
In contrast `react-router build` (with server rendering disabled) pre-renders your root route at build time. This means you can:

- Send more than an empty div
- Use React components to generate the initial page users see
- Use a root `loader` to load data for your application shell
- Use React components to generate the initial page users see (root `HydrateFallback`)
- Re-enable server rendering later without changing anything about your UI

React Router will still server render your index route to generate that `index.html` file. This is why your project still needs a dependency on `@react-router/node` and your routes need to be SSR-safe. That means you can't call `window` or other browser-only APIs during the initial render, even when server rendering is disabled.
Therefore, setting `ssr:false` only disables _runtime server rendering_. React Router will still server render your index route at _build time_ to generate the `index.html` file. This is why your project still needs a dependency on `@react-router/node` and your routes need to be SSR-safe. That means you can't call `window` or other browser-only APIs during the initial render, even when server rendering is disabled.
24 changes: 17 additions & 7 deletions integration/helpers/create-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,16 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
prerender: init.prerender,
requestDocument(href: string) {
let file = new URL(href, "test://test").pathname + "/index.html";
let html = fse.readFileSync(
path.join(projectDir, "build/client" + file)
let mainPath = path.join(projectDir, "build", "client", file);
let fallbackPath = path.join(
projectDir,
"build",
"client",
"__spa-fallback.html"
);
let html = fse.existsSync(mainPath)
? fse.readFileSync(mainPath)
: fse.readFileSync(fallbackPath);
return new Response(html, {
headers: {
"Content-Type": "text/html",
Expand Down Expand Up @@ -284,15 +291,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
return new Promise(async (accept) => {
let port = await getPort();
let app = express();
app.use(express.static(path.join(fixture.projectDir, "build/client")));
app.use(
express.static(path.join(fixture.projectDir, "build", "client"))
);
app.get("*", (req, res, next) => {
let dir = path.join(fixture.projectDir, "build", "client");
let file = req.path.endsWith(".data")
? req.path
: req.path + "/index.html";
res.sendFile(
path.join(fixture.projectDir, "build/client", file),
next
);
if (file.endsWith(".html") && !fse.existsSync(path.join(dir, file))) {
file = "__spa-fallback.html";
}
res.sendFile(path.join(dir, file), next);
});
let server = app.listen(port);
accept({ stop: server.close.bind(server), port });
Expand Down
Loading