@vitejs/plugin-rsc
Version:
React Server Components (RSC) support for Vite.
609 lines (460 loc) • 21.5 kB
Markdown
# /plugin-rsc
This package provides [React Server Components](https://react.dev/reference/rsc/server-components) (RSC) support for Vite.
## Features
- **Framework-agnostic**: The plugin implements [RSC bundler features](https://react.dev/reference/rsc/server-components) and provides low level RSC runtime (`react-server-dom`) API without framework-specific abstractions.
- **Runtime-agnostic**: Built on [Vite environment API](https://vite.dev/guide/api-environment.html) and works with other runtimes (e.g., [`@cloudflare/vite-plugin`](https://github.com/cloudflare/workers-sdk/tree/main/packages/vite-plugin-cloudflare)).
- **HMR support**: Enables editing both client and server components without full page reloads.
- **CSS support**: CSS is automatically code-split both at client and server components and they are injected upon rendering.
## Getting Started
You can create a starter project by:
```sh
npm create vite -- --template rsc
```
## Examples
**Start here:** [`./examples/starter`](./examples/starter) - Recommended for understanding the package
- Provides an in-depth overview of API with inline comments to explain how they function within RSC-powered React application.
**Integration examples:**
- [`./examples/basic`](./examples/basic) - Advanced RSC features and testing
- This is mainly used for e2e testing and includes various advanced RSC usages (e.g. `"use cache"` example).
- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity.
- [`./examples/react-router`](./examples/react-router) - React Router RSC integration
- Demonstrates how to integrate [experimental React Router RSC API](https://remix.run/blog/rsc-preview). React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components), so it's recommended to follow React Router's official documentation for the latest integration.
## Basic Concepts
This example is a simplified version of [`./examples/starter`](./examples/starter). You can read [`./examples/starter/src/framework/entry.{rsc,ssr,browser}.tsx`](./examples/starter/src/framework) for more in-depth commentary, which includes server function handling and client-side RSC re-fetching/re-rendering.
This is the diagram to show the basic flow of RSC rendering process. See also https://github.com/hi-ogawa/vite-plugins/discussions/606.
```mermaid
graph TD
subgraph "<strong>rsc environment</strong>"
A["React virtual dom tree"] --> |"[@vitejs/plugin-rsc/rsc]<br /><code>renderToReadableStream</code>"| B1["RSC Stream"];
end
B1 --> B2
B1 --> B3
subgraph "<strong>ssr environment</strong>"
B2["RSC Stream"] --> |"[@vitejs/plugin-rsc/ssr]<br /><code>createFromReadableStream</code>"| C1["React virtual dom tree"];
C1 --> |"[react-dom/server]<br/>SSR"| E["HTML String/Stream"];
end
subgraph "<strong>client environment</strong>"
B3["RSC Stream"] --> |"[@vitejs/plugin-rsc/browser]<br /><code>createFromReadableStream</code>"| C2["React virtual dom tree"];
C2 --> |"[react-dom/client]<br/>CSR: mount, hydration"| D["DOM Elements"];
end
style A fill:#D6EAF8,stroke:#333,stroke-width:2px
style B1 fill:#FEF9E7,stroke:#333,stroke-width:2px
style B2 fill:#FEF9E7,stroke:#333,stroke-width:2px
style B3 fill:#FEF9E7,stroke:#333,stroke-width:2px
style C1 fill:#D6EAF8,stroke:#333,stroke-width:2px
style C2 fill:#D6EAF8,stroke:#333,stroke-width:2px
style D fill:#D5F5E3,stroke:#333,stroke-width:2px
style E fill:#FADBD8,stroke:#333,stroke-width:2px
```
- [`vite.config.ts`](./examples/starter/vite.config.ts)
```js
import rsc from '/plugin-rsc'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
// add plugin
rsc(),
],
// specify entry point for each environment.
environments: {
// `rsc` environment loads modules with `react-server` condition.
// this environment is responsible for:
// - RSC stream serialization (React VDOM -> RSC stream)
// - server functions handling
rsc: {
build: {
rollupOptions: {
input: {
index: './src/framework/entry.rsc.tsx',
},
},
},
},
// `ssr` environment loads modules without `react-server` condition.
// this environment is responsible for:
// - RSC stream deserialization (RSC stream -> React VDOM)
// - traditional SSR (React VDOM -> HTML string/stream)
ssr: {
build: {
rollupOptions: {
input: {
index: './src/framework/entry.ssr.tsx',
},
},
},
},
// client environment is used for hydration and client-side rendering
// this environment is responsible for:
// - RSC stream deserialization (RSC stream -> React VDOM)
// - traditional CSR (React VDOM -> Browser DOM tree mount/hydration)
// - refetch and re-render RSC
// - calling server functions
client: {
build: {
rollupOptions: {
input: {
index: './src/framework/entry.browser.tsx',
},
},
},
},
},
})
```
- [`entry.rsc.tsx`](./examples/starter/src/framework/entry.rsc.tsx)
```tsx
import { renderToReadableStream } from '/plugin-rsc/rsc'
// the plugin assumes `rsc` entry having default export of request handler
export default async function handler(request: Request): Promise<Response> {
// serialize React VDOM to RSC stream
const root = (
<html>
<body>
<h1>Test</h1>
</body>
</html>
)
const rscStream = renderToReadableStream(root)
// respond direct RSC stream request based on framework's convention
if (request.url.endsWith('.rsc')) {
return new Response(rscStream, {
headers: {
'Content-type': 'text/x-component;charset=utf-8',
},
})
}
// delegate to SSR environment for html rendering
// `loadModule` is a helper API provided by the plugin for multi environment interaction.
const ssrEntry = await import.meta.viteRsc.loadModule<
typeof import('./entry.ssr.tsx')
>('ssr', 'index')
const htmlStream = await ssrEntry.handleSsr(rscStream)
// respond html
return new Response(htmlStream, {
headers: {
'Content-type': 'text/html',
},
})
}
// add `import.meta.hot.accept` to handle server module change efficiently
if (import.meta.hot) {
import.meta.hot.accept()
}
```
- [`entry.ssr.tsx`](./examples/starter/src/framework/entry.ssr.tsx)
```tsx
import { createFromReadableStream } from '/plugin-rsc/ssr'
import { renderToReadableStream } from 'react-dom/server.edge'
export async function handleSsr(rscStream: ReadableStream) {
// deserialize RSC stream back to React VDOM
const root = await createFromReadableStream(rscStream)
// helper API to allow referencing browser entry content from SSR environment
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
// render html (traditional SSR)
const htmlStream = renderToReadableStream(root, {
bootstrapScriptContent,
})
return htmlStream
}
```
- [`entry.browser.tsx`](./examples/starter/src/framework/entry.browser.tsx)
```tsx
import { createFromReadableStream } from '/plugin-rsc/browser'
import { hydrateRoot } from 'react-dom/client'
async function main() {
// fetch and deserialize RSC stream back to React VDOM
const rscResponse = await fetch(window.location.href + '.rsc')
const root = await createFromReadableStream(rscResponse.body)
// hydration (traditional CSR)
hydrateRoot(document, root)
}
main()
```
## Environment helper API
The plugin provides an additional helper for multi environment interaction.
### Available on `rsc` or `ssr` environment
#### `import.meta.viteRsc.loadModule`
- Type: `(environmentName: "ssr" | "rsc", entryName: string) => Promise<T>`
This allows importing `ssr` environment module specified by `environments.ssr.build.rollupOptions.input[entryName]` inside `rsc` environment and vice versa.
During development, by default, this API assumes both `rsc` and `ssr` environments execute under the main Vite process. When enabling `rsc({ loadModuleDevProxy: true })` plugin option, the loaded module is implemented as a proxy with `fetch`-based RPC to call in node environment on the main Vite process, which for example, allows `rsc` environment inside cloudflare workers to access `ssr` environment on the main Vite process. This proxy mechanism uses [turbo-stream](https://github.com/jacob-ebey/turbo-stream) for serializing data types beyond JSON, with custom encoders/decoders to additionally support `Request` and `Response` instances.
During production build, this API will be rewritten into a static import of the specified entry of other environment build and the modules are executed inside the same runtime.
For example,
```js
// ./entry.rsc.tsx
const ssrModule = await import.meta.viteRsc.loadModule("ssr", "index");
ssrModule.renderHTML(...);
// ./entry.ssr.tsx (with environments.ssr.build.rollupOptions.input.index = "./entry.ssr.tsx")
export function renderHTML(...) {}
```
### Available on `rsc` environment
#### `import.meta.viteRsc.loadCss`
> [!NOTE]
> The plugin automatically injects CSS for server components. See the [CSS Support](#css-support) section for detailed information about automatic CSS injection.
- Type: `(importer?: string) => React.ReactNode`
This allows collecting css which is imported through a current server module and injecting them inside server components.
```tsx
import './test.css'
import dep from './dep.tsx'
export function ServerPage() {
// this will include css assets for "test.css"
// and any css transitively imported through "dep.tsx"
return (
<>
{import.meta.viteRsc.loadCss()}
...
</>
)
}
```
When specifying `loadCss(<id>)`, it will collect css through the server module resolved by `<id>`.
```tsx
// virtual:my-framework-helper
export function Assets() {
return <>
{import.meta.viteRsc.loadCss("/routes/home.tsx")}
{import.meta.viteRsc.loadCss("/routes/about.tsx")}
{...}
</>
}
// user-app.tsx
import { Assets } from "virtual:my-framework-helper";
export function UserApp() {
return <html>
<head>
<Assets />
</head>
<body>...</body>
</html>
}
```
### Available on `ssr` environment
#### `import.meta.viteRsc.loadBootstrapScriptContent("index")`
This provides a raw js code to execute a browser entry file specified by `environments.client.build.rollupOptions.input.index`. This is intended to be used with React DOM SSR API, such as [`renderToReadableStream`](https://react.dev/reference/react-dom/server/renderToReadableStream)
```js
import { renderToReadableStream } from 'react-dom/server.edge'
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent('index')
const htmlStream = await renderToReadableStream(reactNode, {
bootstrapScriptContent,
})
```
### Available on `client` environment
#### `rsc:update` event
This event is fired when server modules are updated, which can be used to trigger re-fetching and re-rendering of RSC components on browser.
```js
import { createFromFetch } from '/plugin-rsc/browser'
import.meta.hot.on('rsc:update', async () => {
// re-fetch RSC stream
const rscPayload = await createFromFetch(fetch(window.location.href + '.rsc'))
// re-render ...
})
```
## Plugin API
### `/plugin-rsc`
- Type: `rsc: (options?: RscPluginOptions) => Plugin[]`;
```js
import rsc from '/plugin-rsc'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
rsc({
// this is only a shorthand of specifying each rollup input via
// `environments[name].build.rollupOptions.input.index`
entries: {
rsc: '...',
ssr: '...',
client: '...',
},
// by default, the plugin sets up middleware
// using `default` export of `rsc` environment `index` entry.
// this behavior can be customized by `serverHandler` option.
serverHandler: false,
// the plugin provides build-time validation of 'server-only' and 'client-only' imports.
// this is enabled by default. See the "server-only and client-only import" section below for details.
validateImports: true,
// by default, the plugin uses a build-time generated encryption key for
// "use server" closure argument binding.
// This can be overwritten by configuring `defineEncryptionKey` option,
// for example, to obtain a key through environment variable during runtime.
// cf. https://nextjs.org/docs/app/guides/data-security#overwriting-encryption-keys-advanced
defineEncryptionKey: 'process.env.MY_ENCRYPTION_KEY',
// when `loadModuleDevProxy: true`, `import.meta.viteRsc.loadModule` is implemented
// through `fetch` based RPC, which allows, for example, rsc environment inside
// cloudflare workers to communicate with node ssr environment on main Vite process.
loadModuleDevProxy: true,
// by default, `loadCss()` helper is injected based on certain heuristics.
// if it breaks, it can be opt-out or selectively applied based on files.
rscCssTransform: { filter: (id) => id.includes('/my-app/') },
// see `RscPluginOptions` for full options ...
}),
],
// the same options can be also specified via top-level `rsc` property.
// this allows other plugin to set options via `config` hook.
rsc: {
// ...
},
})
```
## RSC runtime (react-server-dom) API
### `/plugin-rsc/rsc`
This module re-exports RSC runtime API provided by `react-server-dom/server.edge` and `react-server-dom/client.edge` such as:
- `renderToReadableStream`: RSC serialization (React VDOM -> RSC stream)
- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM). This is also available on rsc environment itself. For example, it allows saving serialized RSC and deserializing it for later use.
- `decodeAction/decodeReply/decodeFormState/loadServerAction/createTemporaryReferenceSet`
- `encodeReply/createClientTemporaryReferenceSet`
### `/plugin-rsc/ssr`
This module re-exports RSC runtime API provided by `react-server-dom/client.edge`
- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM)
### `/plugin-rsc/browser`
This module re-exports RSC runtime API provided by `react-server-dom/client.browser`
- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM)
- `createFromFetch`: a robust way of `createFromReadableStream((await fetch("...")).body)`
- `encodeReply/setServerCallback`: server function related...
## Tips
### CSS Support
The plugin automatically handles CSS code-splitting and injection for server components. This eliminates the need to manually call [`import.meta.viteRsc.loadCss()`](#importmetaviterscloadcss) in most cases.
1. **Component Detection**: The plugin automatically detects server components by looking for:
- Function exports with capital letter names (e.g., `export function Page() {}`)
- Default exports that are functions with capital names (e.g., `export default function Page() {}`)
- Const exports assigned to functions with capital names (e.g., `export const Page = () => {}`)
2. **CSS Import Detection**: For detected components, the plugin checks if the module imports any CSS files (`.css`, `.scss`, `.sass`, etc.)
3. **Automatic Wrapping**: When both conditions are met, the plugin wraps the component with a CSS injection wrapper:
```tsx
// Before transformation
import './styles.css'
export function Page() {
return <div>Hello</div>
}
// After transformation
import './styles.css'
export function Page() {
return (
<>
{import.meta.viteRsc.loadCss()}
<div>Hello</div>
</>
)
}
```
### Using React Canary and Experimental versions
To use React's [canary](https://react.dev/community/versioning-policy#canary-channel) or [experimental](https://react.dev/community/versioning-policy#all-release-channels) versions with `@vitejs/plugin-rsc`, you have two options:
**Option 1: Use preview releases from pkg.pr.new**
You can use preview releases that bundle specific React versions. See [PR #524](https://github.com/vitejs/vite-plugin-react/pull/524) for instructions on installing these preview packages.
**Option 2: Install `react-server-dom-webpack` directly**
By default, `/plugin-rsc` includes a vendored version of `react-server-dom-webpack`. However, when `react-server-dom-webpack` is installed in your project's dependencies, the plugin will automatically use it instead. This allows you to:
- Stay up-to-date with the latest React Server Components runtime without waiting for plugin updates
- Use specific React versions (stable, canary, or experimental)
Simply install the version you need:
```json
{
"dependencies": {
"react": "canary",
"react-dom": "canary",
"react-server-dom-webpack": "canary"
}
}
```
Or for experimental:
```json
{
"dependencies": {
"react": "experimental",
"react-dom": "experimental",
"react-server-dom-webpack": "experimental"
}
}
```
### Using `/plugin-rsc` as a framework package's `dependencies`
By default, `/plugin-rsc` is expected to be used as `peerDependencies` similar to `react` and `react-dom`. When `@vitejs/plugin-rsc` is not available at the project root (e.g., in `node_modules/@vitejs/plugin-rsc`), you will see warnings like:
```sh
Failed to resolve dependency: /plugin-rsc/vendor/react-server-dom/client.browser, present in client 'optimizeDeps.include'
```
This can be fixed by updating `optimizeDeps.include` to reference `/plugin-rsc` through your framework package. For example, you can add the following plugin:
```js
// package name is "my-rsc-framework"
export default function myRscFrameworkPlugin() {
return {
name: 'my-rsc-framework:config',
configEnvironment(_name, config) {
if (config.optimizeDeps?.include) {
config.optimizeDeps.include = config.optimizeDeps.include.map(
(entry) => {
if (entry.startsWith('/plugin-rsc')) {
entry = `my-rsc-framework > ${entry}`
}
return entry
},
)
}
},
}
}
```
### Typescript
Types for global API are defined in `/plugin-rsc/types`. For example, you can add it to `tsconfig.json` to have types for `import.meta.viteRsc` APIs:
```json
{
"compilerOptions": {
"types": ["vite/client", "@vitejs/plugin-rsc/types"]
}
}
```
```ts
import.meta.viteRsc.loadModule
// ^^^^^^^^^^
// <T>(environmentName: string, entryName: string) => Promise<T>
```
See also [Vite documentation](https://vite.dev/guide/api-hmr.html#intellisense-for-typescript) for `vite/client` types.
### `server-only` and `client-only` import
<!-- references? -->
<!-- https://nextjs.org/docs/app/getting-started/server-and-client-components#preventing-environment-poisoning -->
<!-- https://overreacted.io/how-imports-work-in-rsc/ -->
You can use the `server-only` import to prevent accidentally importing server-only code into client bundles, which can expose sensitive server code in public static assets.
For example, the plugin will show an error `'server-only' cannot be imported in client build` for the following code:
- server-utils.js
```tsx
import 'server-only'
export async function getData() {
const res = await fetch('https://internal-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
```
- client.js
```tsx
'use client'
import { getData } from './server-utils.js' // ❌ 'server-only' cannot be imported in client build
...
```
Similarly, the `client-only` import ensures browser-specific code isn't accidentally imported into server environments.
For example, the plugin will show an error `'client-only' cannot be imported in server build` for the following code:
- client-utils.js
```tsx
import 'client-only'
export function getStorage(key) {
// This uses browser-only APIs
return window.localStorage.getItem(key)
}
```
- server.js
```tsx
import { getStorage } from './client-utils.js' // ❌ 'client-only' cannot be imported in server build
export function ServerComponent() {
const data = getStorage("settings")
...
}
```
Note that while there are official npm packages [`server-only`](https://www.npmjs.com/package/server-only) and [`client-only`](https://www.npmjs.com/package/client-only) created by React team, they don't need to be installed. The plugin internally overrides these imports and surfaces their runtime errors as build-time errors.
This build-time validation is enabled by default and can be disabled by setting `validateImports: false` in the plugin options.
## Credits
This project builds on fundamental techniques and insights from pioneering Vite RSC implementations.
Additionally, Parcel and React Router's work on standardizing the RSC bundler/app responsibility has guided this plugin's API design:
- [Waku](https://github.com/wakujs/waku)
- [/react-server](https://github.com/lazarv/react-server)
- [-ebey/vite-react-server-dom](https://github.com/jacob-ebey/vite-plugins/tree/main/packages/vite-react-server-dom)
- [React Router RSC](https://remix.run/blog/rsc-preview)
- [Parcel RSC](https://parceljs.org/recipes/rsc)