Getting Started
Start from the starter
The fastest path: clone vprs-starter, a minimal app with file-based routes, a one-call client entry, and a per-route choice between CDN snapshots and per-request rendering.
git clone https://github.com/nicobrinkkemper/vprs-starter
cd vprs-starter
npm install
npm run dev # vite dev server
npm run build # → dist/static (+ dist/client, dist/server, dist/server-edge)
npm run preview # serve the prerendered dist/static
npm run edge # render every route per request on :4401
One build serves a static CDN, Vercel, or a local server. The Vercel-specific
files (api/, vercel.json, scripts/prepare-vercel.mjs) are deletable
(retarget the edge script from build:vercel to plain build first); the
rest is host-agnostic. The starter pins the experimental React train (react,
react-dom, react-server-loader on the same React snapshot); the
from-scratch path below uses stable React.
Start from scratch
Install
npm install -D vite-plugin-react-server react react-dom react-server-loader
vprs runs on stable React 19.2+ (react / react-dom at ^19.2.8). The
RSC transport underneath is version-locked by the
react-server-loader
peer dependency, which you install alongside react / react-dom (the
command above covers stable). To switch to the experimental React train
(e.g. for correct CSS preloading), install all three at the exact snapshot
vprs's peer range names — one copy in your tree, no overrides. See
React Compatibility for the exact-version
command.
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({
// The execution paradigm: "isolated" (worker owns react-server, no
// process flag), "main" (react-server on the main thread, needs
// NODE_OPTIONS='--conditions react-server'), or "edge".
runner: "isolated",
moduleBase: "src",
Page: "src/page.tsx",
props: "src/props.ts",
build: { pages: ["/"] },
}),
});
Development Server
{
"scripts": {
"dev": "vite",
"build": "vite build --app",
"preview": "vite preview"
}
}
The scripts match the declared runner. With runner: "isolated" (above) no
script carries a process flag: a worker thread owns react-server resolution,
and the main thread keeps the react-client context hydration needs. Declaring
runner: "main" instead puts react-server on the main thread — slightly
faster, better stack traces, React usable in vite.config.ts — and then
every script states the flag that topology requires:
{
"scripts": {
"dev": "NODE_OPTIONS='--conditions react-server' vite",
"build": "NODE_OPTIONS='--conditions react-server' vite build --app",
"preview": "NODE_OPTIONS='--conditions react-server' vite preview"
}
}
Runner and flag are validated against each other at config-resolve time, so a mismatch is a loud error, not a silently different topology. Either way the plugin spawns a worker for the mirrored half, so server components and client hydration both always have their proper React context. 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
└── server-edge/ # Single-isolate edge bundle (on by default; build.edge: false to skip)
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({
runner: "isolated",
moduleBase: "src",
moduleBaseURL: "/my-repo/",
Page: "src/page.tsx",
build: { pages: ["/"] },
}),
});
Add More Pages
// vite.config.ts
export default defineConfig({
plugins: vitePluginReactServer({
runner: "isolated",
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>
);
What makes it a client component
The "use client" directive, and only that — the same rule as every other React
setup. The .client.tsx name above is a convention for human readers; it carries
no meaning to the build.
// src/components/Counter.tsx ← same component, plainer name
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
A file named .client.tsx without the directive is a server module, and you
get a build warning telling you to add one. (Leading whitespace, comments, and a
"use strict" prologue may sit above the directive; one placed after real code
does not count.)
// 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
Nothing to set up: startClient (the one-line client entry from
Routing) wires RSC HMR along with
hydration and the client router — an edit to anything the server tree
imports refetches the current route's flight automatically: server
components, loaders, and the content they read (markdown through
import.meta.glob(..., { query: "?raw" }), JSON data), wherever it lives in
the project. Assembling the client entry by hand instead? useRscHmr from
vite-plugin-react-server/utils is the same hook startClient uses, and
setupRscHmr() is the non-React form (call it once in your entry; it
refetches the current page's flight on those edits).
Next Steps
- Routing — point
routes: { dir }at a folder and the file tree becomes your URL tree: dynamic params, per-segment loaders, nested layouts, and client-sideLinknavigation - Build Output — understand what you're deploying
- Configuration — all plugin options
- Examples — static sites, dynamic servers, server actions