Getting Started
Install
npm install -D vite-plugin-react-server react react-dom
vprs runs on stable React 19.2+ (react / react-dom at ^19.2.7). The
react-server-dom-esm ESM transport is provided by the
react-server-loader
dependency, installed for you by every package manager — no extra step. To
switch to the experimental React train (e.g. for correct CSS preloading),
install all three at @experimental — react@experimental,
react-dom@experimental, react-server-loader@experimental — which npm
dedupes to a single copy. See
React Compatibility.
Upgrading from 1.x
vprs 1.x required react@experimental and bundled the transport in-repo. For
2.0:
- Switch to stable React:
npm install react@^19.2.7 react-dom@^19.2.7(experimental still works, but is no longer required). - The transport moved.
react-server-dom-esmnow ships inside thereact-server-loaderdependency. If you imported the transport through vprs'svite-plugin-react-server/react-server-dom-esm/*self-export, import it fromreact-server-loaderinstead (e.g.react-server-loader/server,/client). Barereact-server-dom-esm/*imports inside a vprs app keep working — the plugin resolves them. - No API changes to the plugin itself: your
vite.config, page files, and directives are unchanged.
Create a Page
// src/page.tsx
export const Page = ({ title }: { title: string }) => (
<div>
<h1>{title}</h1>
<p>Welcome to my app!</p>
</div>
);
// src/props.ts
export const props = { title: "Home Page" };
Configure Vite
// vite.config.ts
import { defineConfig } from "vite";
import { vitePluginReactServer } from "vite-plugin-react-server";
export default defineConfig({
plugins: vitePluginReactServer({
moduleBase: "src",
Page: "src/page.tsx",
props: "src/props.ts",
build: { pages: ["/"] },
}),
});
Development Server
{
"scripts": {
"dev": "vite",
"dev:rsc": "NODE_OPTIONS='--conditions react-server' vite",
"build": "vite build --app",
"build:rsc": "NODE_OPTIONS='--conditions react-server' vite build --app",
"preview": "vite preview"
}
}
The plain and :rsc variants serve and build the same app — neither mode is
required. Whichever condition the main thread runs, the plugin spawns a worker
for the mirrored half, so server components and client hydration both always
have their proper React context. The :rsc variants put the react-server side
on the main thread: an optional optimization (slightly faster, better stack
traces). See Architecture for how the mirroring
works.
npm run dev
# Open http://localhost:5173
Build
npm run build
Output:
dist/
├── static/ # Deploy this folder
│ ├── index.html
│ └── index.rsc
├── client/ # Client ESM modules
└── server/ # Server ESM modules
Deploy to GitHub Pages
The dist/static/ folder is a complete static site. Deploy it anywhere.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "23"
- run: npm ci
- run: npm run build
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist/static
For GitHub Pages with a subdirectory, set moduleBaseURL and Vite's base:
export default defineConfig({
base: "/my-repo/",
plugins: vitePluginReactServer({
moduleBase: "src",
moduleBaseURL: "/my-repo/",
Page: "src/page.tsx",
build: { pages: ["/"] },
}),
});
Add More Pages
// vite.config.ts
export default defineConfig({
plugins: vitePluginReactServer({
moduleBase: "src",
Page: (url) => `src/pages${url}page.tsx`,
props: (url) => `src/pages${url}props.ts`,
build: { pages: ["/", "/about/", "/contact/"] },
}),
});
src/pages/
├── page.tsx # /
├── props.ts
├── about/
│ ├── page.tsx # /about/
│ └── props.ts
└── contact/
├── page.tsx # /contact/
└── props.ts
Add Client Components
// src/components/Counter.client.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// src/page.tsx
import { Counter } from "./components/Counter.client.js";
export const Page = () => (
<div>
<h1>Home</h1>
<Counter />
</div>
);
The .client. filename is optional
vprs recognises a file as a client module when either of these is true:
- the filename matches
(^|[/.])client.[cm]?[jt]sx?$— i.e.Button.client.tsx,bar.client.mjs, or the standalone basenamesrc/client.tsx/client.tsx, or - the file starts with a top-of-file
"use client"directive (leading whitespace, line/block comments, and an optional"use strict"prologue are tolerated above it).
Either is sufficient. Substrings like clientUtils.tsx, clientId.ts, or clients.tsx are not treated as client modules, and a "use client" directive placed after real code does not count.
// src/components/Counter.tsx ← no `.client.` suffix
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// src/page.tsx (a server component)
import { Counter } from "./components/Counter.js";
// ...renders <Counter /> as a client reference
Client entry
Most projects have an index.html with something like:
<script type="module" src="/src/client.tsx"></script>
vprs leaves that file to Vite's normal entry-point discovery — you do not need to set clientEntry, even though the file may carry a "use client" directive. The clientEntry option still exists for advanced cases that don't go through index.html.
Add an HTML Shell
// src/Html.tsx
import { Css } from "vite-plugin-react-server/components";
import type { HtmlProps } from "vite-plugin-react-server/types";
export const Html = ({ Root, cssFiles, globalCss, pageProps, Page }: HtmlProps) => (
<html>
<head>
<Css cssFiles={globalCss} />
<title>{pageProps?.title || "My App"}</title>
</head>
<body>
<Root as="div" id="root" cssFiles={cssFiles} Page={Page} pageProps={pageProps} />
</body>
</html>
);
// vite.config.ts — add Html option
vitePluginReactServer({
// ...
Html: "src/Html.tsx",
})
HMR Setup
For automatic RSC refetching when server components change:
// Client entry
import { createReactFetcher, setupRscHmr } from "vite-plugin-react-server/utils";
const { initialContent, refetch } = createReactFetcher({ callServer });
if (import.meta.hot) {
setupRscHmr(import.meta.hot, refetch);
}
Next Steps
- Build Output — understand what you're deploying
- Configuration — all plugin options
- Examples — static sites, dynamic servers, server actions