diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts
index fdb366064..b1b167e38 100644
--- a/packages/plugin-rsc/e2e/helper.ts
+++ b/packages/plugin-rsc/e2e/helper.ts
@@ -4,12 +4,12 @@ export const testNoJs = test.extend({
javaScriptEnabled: ({}, use) => use(false),
})
-export async function waitForHydration(page: Page) {
+export async function waitForHydration(page: Page, locator: string = 'body') {
await expect
.poll(
() =>
page
- .locator('body')
+ .locator(locator)
.evaluate(
(el) =>
el &&
diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts
index f0d6aa233..1ee2c2e0e 100644
--- a/packages/plugin-rsc/e2e/starter.test.ts
+++ b/packages/plugin-rsc/e2e/starter.test.ts
@@ -1,6 +1,12 @@
import { expect, test } from '@playwright/test'
import { type Fixture, useFixture } from './fixture'
-import { expectNoReload, testNoJs, waitForHydration } from './helper'
+import {
+ expectNoReload,
+ testNoJs,
+ waitForHydration as waitForHydration_,
+} from './helper'
+import path from 'node:path'
+import fs from 'node:fs'
test.describe('dev-default', () => {
const f = useFixture({ root: 'examples/starter', mode: 'dev' })
@@ -22,7 +28,24 @@ test.describe('build-cloudflare', () => {
defineTest(f)
})
-function defineTest(f: Fixture) {
+test.describe('dev-no-ssr', () => {
+ const f = useFixture({ root: 'examples/no-ssr', mode: 'dev' })
+ defineTest(f, 'no-ssr')
+})
+
+test.describe('build-no-ssr', () => {
+ const f = useFixture({ root: 'examples/no-ssr', mode: 'build' })
+ defineTest(f, 'no-ssr')
+
+ test('no ssr build', () => {
+ expect(fs.existsSync(path.join(f.root, 'dist/ssr'))).toBe(false)
+ })
+})
+
+function defineTest(f: Fixture, variant?: 'no-ssr') {
+ const waitForHydration: typeof waitForHydration_ = (page) =>
+ waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body')
+
test('basic', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)
@@ -48,6 +71,8 @@ function defineTest(f: Fixture) {
})
testNoJs('server action @nojs', async ({ page }) => {
+ test.skip(variant === 'no-ssr')
+
await page.goto(f.url())
await page.getByRole('button', { name: 'Server Counter: 1' }).click()
await expect(
@@ -71,6 +96,12 @@ function defineTest(f: Fixture) {
page.getByRole('button', { name: 'Client [edit] Counter: 1' }),
).toBeVisible()
+ if (variant === 'no-ssr') {
+ editor.reset()
+ await page.getByRole('button', { name: 'Client Counter: 1' }).click()
+ return
+ }
+
// check next ssr is also updated
const res = await page.goto(f.url())
expect(await res?.text()).toContain('Client [edit] Counter')
diff --git a/packages/plugin-rsc/examples/no-ssr/README.md b/packages/plugin-rsc/examples/no-ssr/README.md
new file mode 100644
index 000000000..db13dfe8d
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/README.md
@@ -0,0 +1 @@
+[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) without SSR environment
diff --git a/packages/plugin-rsc/examples/no-ssr/index.html b/packages/plugin-rsc/examples/no-ssr/index.html
new file mode 100644
index 000000000..01b0331d7
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/plugin-rsc/examples/no-ssr/package.json b/packages/plugin-rsc/examples/no-ssr/package.json
new file mode 100644
index 000000000..64a3f79b4
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@vitejs/plugin-rsc-examples-no-ssr",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@vitejs/plugin-rsc": "latest",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "latest",
+ "vite": "^7.0.2"
+ }
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/public/vite.svg b/packages/plugin-rsc/examples/no-ssr/public/vite.svg
new file mode 100644
index 000000000..e7b8dfb1b
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/plugin-rsc/examples/no-ssr/src/action.tsx b/packages/plugin-rsc/examples/no-ssr/src/action.tsx
new file mode 100644
index 000000000..4fc55d65b
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/action.tsx
@@ -0,0 +1,11 @@
+'use server'
+
+let serverCounter = 0
+
+export async function getServerCounter() {
+ return serverCounter
+}
+
+export async function updateServerCounter(change: number) {
+ serverCounter += change
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg b/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg
new file mode 100644
index 000000000..6c87de9bb
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/plugin-rsc/examples/no-ssr/src/client.tsx b/packages/plugin-rsc/examples/no-ssr/src/client.tsx
new file mode 100644
index 000000000..29bb5d367
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/client.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import React from 'react'
+
+export function ClientCounter() {
+ const [count, setCount] = React.useState(0)
+
+ return (
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx
new file mode 100644
index 000000000..0d3451c56
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx
@@ -0,0 +1,122 @@
+import * as ReactClient from '@vitejs/plugin-rsc/browser'
+import React from 'react'
+import * as ReactDOMClient from 'react-dom/client'
+import type { RscPayload } from './entry.rsc'
+
+async function main() {
+ // stash `setPayload` function to trigger re-rendering
+ // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr)
+ let setPayload: (v: RscPayload) => void
+
+ const initialPayload = await ReactClient.createFromFetch(
+ fetch(window.location.href),
+ )
+
+ // browser root component to (re-)render RSC payload as state
+ function BrowserRoot() {
+ const [payload, setPayload_] = React.useState(initialPayload)
+
+ React.useEffect(() => {
+ setPayload = (v) => React.startTransition(() => setPayload_(v))
+ }, [setPayload_])
+
+ // re-fetch/render on client side navigation
+ React.useEffect(() => {
+ return listenNavigation(() => fetchRscPayload())
+ }, [])
+
+ return payload.root
+ }
+
+ // re-fetch RSC and trigger re-rendering
+ async function fetchRscPayload() {
+ const payload = await ReactClient.createFromFetch(
+ fetch(window.location.href),
+ )
+ setPayload(payload)
+ }
+
+ // register a handler which will be internally called by React
+ // on server function request after hydration.
+ ReactClient.setServerCallback(async (id, args) => {
+ const url = new URL(window.location.href)
+ const temporaryReferences = ReactClient.createTemporaryReferenceSet()
+ const payload = await ReactClient.createFromFetch(
+ fetch(url, {
+ method: 'POST',
+ body: await ReactClient.encodeReply(args, { temporaryReferences }),
+ headers: {
+ 'x-rsc-action': id,
+ },
+ }),
+ { temporaryReferences },
+ )
+ setPayload(payload)
+ return payload.returnValue
+ })
+
+ // hydration
+ const browserRoot = (
+
+
+
+ )
+ ReactDOMClient.createRoot(document.body).render(browserRoot)
+
+ // implement server HMR by trigering re-fetch/render of RSC upon server code change
+ if (import.meta.hot) {
+ import.meta.hot.on('rsc:update', () => {
+ fetchRscPayload()
+ })
+ }
+}
+
+// a little helper to setup events interception for client side navigation
+function listenNavigation(onNavigation: () => void) {
+ window.addEventListener('popstate', onNavigation)
+
+ const oldPushState = window.history.pushState
+ window.history.pushState = function (...args) {
+ const res = oldPushState.apply(this, args)
+ onNavigation()
+ return res
+ }
+
+ const oldReplaceState = window.history.replaceState
+ window.history.replaceState = function (...args) {
+ const res = oldReplaceState.apply(this, args)
+ onNavigation()
+ return res
+ }
+
+ function onClick(e: MouseEvent) {
+ let link = (e.target as Element).closest('a')
+ if (
+ link &&
+ link instanceof HTMLAnchorElement &&
+ link.href &&
+ (!link.target || link.target === '_self') &&
+ link.origin === location.origin &&
+ !link.hasAttribute('download') &&
+ e.button === 0 && // left clicks only
+ !e.metaKey && // open in new tab (mac)
+ !e.ctrlKey && // open in new tab (windows)
+ !e.altKey && // download
+ !e.shiftKey &&
+ !e.defaultPrevented
+ ) {
+ e.preventDefault()
+ history.pushState(null, '', link.href)
+ }
+ }
+ document.addEventListener('click', onClick)
+
+ return () => {
+ document.removeEventListener('click', onClick)
+ window.removeEventListener('popstate', onNavigation)
+ window.history.pushState = oldPushState
+ window.history.replaceState = oldReplaceState
+ }
+}
+
+main()
diff --git a/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx
new file mode 100644
index 000000000..da968de3a
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx
@@ -0,0 +1,51 @@
+import * as ReactServer from '@vitejs/plugin-rsc/rsc'
+import type { ReactFormState } from 'react-dom/client'
+import { Root } from '../root.tsx'
+
+export type RscPayload = {
+ root: React.ReactNode
+ returnValue?: unknown
+ formState?: ReactFormState
+}
+
+export default async function handler(request: Request): Promise {
+ const isAction = request.method === 'POST'
+ let returnValue: unknown | undefined
+ let formState: ReactFormState | undefined
+ let temporaryReferences: unknown | undefined
+ if (isAction) {
+ const actionId = request.headers.get('x-rsc-action')
+ if (actionId) {
+ const contentType = request.headers.get('content-type')
+ const body = contentType?.startsWith('multipart/form-data')
+ ? await request.formData()
+ : await request.text()
+ temporaryReferences = ReactServer.createTemporaryReferenceSet()
+ const args = await ReactServer.decodeReply(body, { temporaryReferences })
+ const action = await ReactServer.loadServerAction(actionId)
+ returnValue = await action.apply(null, args)
+ } else {
+ const formData = await request.formData()
+ const decodedAction = await ReactServer.decodeAction(formData)
+ const result = await decodedAction()
+ formState = await ReactServer.decodeFormState(result, formData)
+ }
+ }
+
+ const rscStream = ReactServer.renderToReadableStream({
+ root: ,
+ returnValue,
+ formState,
+ })
+
+ return new Response(rscStream, {
+ headers: {
+ 'content-type': 'text/x-component;charset=utf-8',
+ vary: 'accept',
+ },
+ })
+}
+
+if (import.meta.hot) {
+ import.meta.hot.accept()
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/src/index.css b/packages/plugin-rsc/examples/no-ssr/src/index.css
new file mode 100644
index 000000000..f4d2128c0
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/index.css
@@ -0,0 +1,112 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
+
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 1rem;
+}
+
+.read-the-docs {
+ color: #888;
+ text-align: left;
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/src/root.tsx b/packages/plugin-rsc/examples/no-ssr/src/root.tsx
new file mode 100644
index 000000000..9baa7b9c2
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/src/root.tsx
@@ -0,0 +1,44 @@
+import './index.css' // css import is automatically injected in exported server components
+import viteLogo from '/vite.svg'
+import { getServerCounter, updateServerCounter } from './action.tsx'
+import reactLogo from './assets/react.svg'
+import { ClientCounter } from './client.tsx'
+
+export function Root() {
+ return
+}
+
+function App() {
+ return (
+
+
+
Vite + RSC
+
+
+
+
+
+
+
+ -
+ Edit
src/client.tsx
to test client HMR.
+
+ -
+ Edit
src/root.tsx
to test server HMR.
+
+
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/tsconfig.json b/packages/plugin-rsc/examples/no-ssr/tsconfig.json
new file mode 100644
index 000000000..4c355ed3c
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "erasableSyntaxOnly": true,
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "moduleResolution": "Bundler",
+ "module": "ESNext",
+ "target": "ESNext",
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "@vitejs/plugin-rsc/types"],
+ "jsx": "react-jsx"
+ }
+}
diff --git a/packages/plugin-rsc/examples/no-ssr/vite.config.ts b/packages/plugin-rsc/examples/no-ssr/vite.config.ts
new file mode 100644
index 000000000..ce349c6e9
--- /dev/null
+++ b/packages/plugin-rsc/examples/no-ssr/vite.config.ts
@@ -0,0 +1,65 @@
+import rsc from '@vitejs/plugin-rsc'
+import react from '@vitejs/plugin-react'
+import { defineConfig, type Plugin } from 'vite'
+import fsp from 'node:fs/promises'
+
+export default defineConfig({
+ plugins: [
+ spaPlugin(),
+ react(),
+ rsc({
+ entries: {
+ rsc: './src/framework/entry.rsc.tsx',
+ },
+ }),
+ ],
+})
+
+function spaPlugin(): Plugin[] {
+ // serve index.html before rsc server
+ return [
+ {
+ name: 'serve-spa',
+ configureServer(server) {
+ return () => {
+ server.middlewares.use(async (req, res, next) => {
+ try {
+ if (req.headers.accept?.includes('text/html')) {
+ const html = await fsp.readFile('index.html', 'utf-8')
+ const transformed = await server.transformIndexHtml('/', html)
+ res.setHeader('Content-type', 'text/html')
+ res.setHeader('Vary', 'accept')
+ res.end(transformed)
+ return
+ }
+ } catch (error) {
+ next(error)
+ return
+ }
+ next()
+ })
+ }
+ },
+ configurePreviewServer(server) {
+ return () => {
+ server.middlewares.use(async (req, res, next) => {
+ try {
+ if (req.headers.accept?.includes('text/html')) {
+ const html = await fsp.readFile(
+ 'dist/client/index.html',
+ 'utf-8',
+ )
+ res.end(html)
+ return
+ }
+ } catch (error) {
+ next(error)
+ return
+ }
+ next()
+ })
+ }
+ },
+ },
+ ]
+}
diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts
index 8698f7283..d53f31e15 100644
--- a/packages/plugin-rsc/src/plugin.ts
+++ b/packages/plugin-rsc/src/plugin.ts
@@ -220,10 +220,42 @@ export default function vitePluginRsc(
},
},
},
+ // TODO: use buildApp hook on v7?
builder: {
sharedPlugins: true,
sharedConfigBuild: true,
async buildApp(builder) {
+ // no-ssr case
+ // rsc -> client -> rsc -> client
+ if (!builder.environments.ssr?.config.build.rollupOptions.input) {
+ isScanBuild = true
+ builder.environments.rsc!.config.build.write = false
+ builder.environments.client!.config.build.write = false
+ await builder.build(builder.environments.rsc!)
+ await builder.build(builder.environments.client!)
+ isScanBuild = false
+ builder.environments.rsc!.config.build.write = true
+ builder.environments.client!.config.build.write = true
+ await builder.build(builder.environments.rsc!)
+ // sort for stable build
+ clientReferenceMetaMap = sortObject(clientReferenceMetaMap)
+ serverResourcesMetaMap = sortObject(serverResourcesMetaMap)
+ await builder.build(builder.environments.client!)
+
+ const assetsManifestCode = `export default ${JSON.stringify(
+ buildAssetsManifest,
+ null,
+ 2,
+ )}`
+ const manifestPath = path.join(
+ builder.environments!.rsc!.config.build!.outDir!,
+ BUILD_ASSETS_MANIFEST_NAME,
+ )
+ fs.writeFileSync(manifestPath, assetsManifestCode)
+ return
+ }
+
+ // rsc -> ssr -> rsc -> client -> ssr
isScanBuild = true
builder.environments.rsc!.config.build.write = false
builder.environments.ssr!.config.build.write = false
@@ -632,6 +664,8 @@ export default function vitePluginRsc(
return
},
writeBundle() {
+ // TODO: move this to `buildApp`.
+ // note that we already do this in buildApp for no-ssr case.
if (this.environment.name === 'ssr') {
// output client manifest to non-client build directly.
// this makes server build to be self-contained and deploy-able for cloudflare.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 388dcc123..d710fb269 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -558,6 +558,31 @@ importers:
specifier: ^4.23.0
version: 4.23.0
+ packages/plugin-rsc/examples/no-ssr:
+ dependencies:
+ '@vitejs/plugin-rsc':
+ specifier: latest
+ version: link:../..
+ react:
+ specifier: ^19.1.0
+ version: 19.1.0
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.1.0(react@19.1.0)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.1.8
+ version: 19.1.8
+ '@types/react-dom':
+ specifier: ^19.1.6
+ version: 19.1.6(@types/react@19.1.8)
+ '@vitejs/plugin-react':
+ specifier: latest
+ version: link:../../../plugin-react
+ vite:
+ specifier: ^7.0.2
+ version: 7.0.2(@types/node@22.16.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1)
+
packages/plugin-rsc/examples/react-router:
dependencies:
'@vitejs/plugin-rsc':