diff --git a/packages/plugin-rsc/e2e/client-first.test.ts b/packages/plugin-rsc/e2e/client-first.test.ts
new file mode 100644
index 000000000..8fb87503f
--- /dev/null
+++ b/packages/plugin-rsc/e2e/client-first.test.ts
@@ -0,0 +1,54 @@
+import { expect, test } from '@playwright/test'
+import { type Fixture, useFixture } from './fixture'
+import { expectNoPageError, waitForHydration } from './helper'
+
+test.describe('dev', () => {
+ const f = useFixture({ root: 'examples/client-first', mode: 'dev' })
+ defineTests(f)
+
+ test('client HMR for a module shared with the RSC graph', async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page)
+ await page.goto(f.url())
+ await waitForHydration(page)
+
+ const counter = page.getByTestId('count')
+ await counter.click()
+ await expect(counter).toHaveText('count: 1')
+
+ const editor = f.createEditor('src/routes/page.tsx')
+ editor.edit((source) =>
+ source.replace('client: baseline', 'client: edited'),
+ )
+
+ await expect(page.getByTestId('client')).toHaveText('client: edited')
+ await expect(counter).toHaveText('count: 1')
+
+ editor.reset()
+ await expect(page.getByTestId('client')).toHaveText('client: baseline')
+ await expect(counter).toHaveText('count: 1')
+ })
+})
+
+test.describe('build', () => {
+ const f = useFixture({ root: 'examples/client-first', mode: 'build' })
+ defineTests(f)
+})
+
+function defineTests(f: Fixture) {
+ test('renders an RSC function result in a client-owned page', async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page)
+ await page.goto(f.url())
+
+ await expect(page.getByTestId('client')).toHaveText('client: baseline')
+ await expect(page.getByTestId('server')).toHaveText('server: baseline')
+
+ await waitForHydration(page)
+ const counter = page.getByTestId('count')
+ await counter.click()
+ await expect(counter).toHaveText('count: 1')
+ })
+}
diff --git a/packages/plugin-rsc/examples/client-first/README.md b/packages/plugin-rsc/examples/client-first/README.md
new file mode 100644
index 000000000..be0927b8b
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/README.md
@@ -0,0 +1,17 @@
+# Client-first RSC
+
+This example sketches the minimum framework machinery for rendering an RSC value inside a client-owned page. The page reads a cached RSC-function promise with React `use` while retaining ordinary client state.
+
+The framework pieces are intentionally direct:
+
+- `routes/page.tsx` co-locates the page and RSC-function handler.
+- `runtime.tsx` creates a callable RSC-function stub and caches its promise for Suspense.
+- `entry.rsc.tsx` executes RSC functions and encodes their results as Flight streams.
+- `entry.ssr.tsx` configures an in-process RSC caller before rendering HTML.
+- `entry.browser.tsx` configures an HTTP RSC caller before hydrating the same page.
+
+For now, `entry.rsc.tsx` imports the co-located handler explicitly. A later module-splitting transform should replace that bridge by moving the handler into the RSC graph while leaving only its caller stub in the SSR and browser graphs.
+
+There is deliberately no SSR-to-browser data handoff yet. SSR and the browser each execute the RSC function independently, which keeps serialization transport separate from the core client-first model.
+
+This example is based on [hi-ogawa/experiments: tanstack-start-rsc](https://github.com/hi-ogawa/experiments/tree/main/tanstack-start-rsc), especially its RSC serialization runtimes and route-level `use` pattern.
diff --git a/packages/plugin-rsc/examples/client-first/package.json b/packages/plugin-rsc/examples/client-first/package.json
new file mode 100644
index 000000000..f07f918db
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@vitejs/plugin-rsc-examples-client-first",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "latest",
+ "@vitejs/plugin-rsc": "latest",
+ "vite": "^8.1.4"
+ }
+}
diff --git a/packages/plugin-rsc/examples/client-first/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/client-first/src/framework/entry.browser.tsx
new file mode 100644
index 000000000..70e7d6eeb
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/src/framework/entry.browser.tsx
@@ -0,0 +1,25 @@
+import { createFromFetch } from '@vitejs/plugin-rsc/browser'
+import { hydrateRoot } from 'react-dom/client'
+import { Root } from '../root'
+import { setRscFnCaller, type RscFnCaller } from './runtime'
+
+function main() {
+ const callRscFn: RscFnCaller = async (id, args) => {
+ return createFromFetch(
+ fetch('/__rsc-function', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-rsc-function-id': id,
+ },
+ body: JSON.stringify(args),
+ }),
+ )
+ }
+
+ setRscFnCaller(callRscFn)
+
+ hydrateRoot(document, )
+}
+
+main()
diff --git a/packages/plugin-rsc/examples/client-first/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/client-first/src/framework/entry.rsc.tsx
new file mode 100644
index 000000000..ad6d550fc
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/src/framework/entry.rsc.tsx
@@ -0,0 +1,52 @@
+import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc'
+import { getServerMessage } from '../routes/page'
+
+export default async function handler(request: Request) {
+ const url = new URL(request.url)
+
+ // handle rsc fetch calls by browser clients
+ if (url.pathname === '/__rsc-function') {
+ const id = request.headers.get('x-rsc-function-id')
+ if (!id) return new Response('Missing RSC function id', { status: 400 })
+
+ const args = (await request.json()) as unknown[]
+ const stream = await executeRscFn(id, args)
+ return new Response(stream, {
+ headers: { 'content-type': 'text/x-component;charset=utf-8' },
+ })
+ }
+
+ // fully delegate to SSR
+ const ssrEntry = await import.meta.viteRsc.loadModule<
+ typeof import('./entry.ssr')
+ >('ssr', 'index')
+ return new Response(await ssrEntry.renderHtml(), {
+ headers: { 'content-type': 'text/html' },
+ })
+}
+
+// hard-coded RSC function registry for demo simplicity
+// TODO: Replace this with a split-module resolver: encoded module IDs with lazy
+// loading in dev, and a generated manifest in build.
+const rscFunctions = { getServerMessage: getServerMessage.handler }
+
+// The browser reaches this executor over HTTP, while SSR invokes it directly
+// through Vite's RSC environment to avoid an internal HTTP round trip.
+export async function executeRscFn(
+ id: string,
+ args: unknown[],
+): Promise> {
+ const rscFn = rscFunctions[id as keyof typeof rscFunctions] as
+ | ((...args: unknown[]) => unknown)
+ | undefined
+ if (!rscFn) {
+ throw new Error(`Unknown RSC function: ${id}`)
+ }
+
+ const result = await rscFn(...args)
+ return renderToReadableStream(result)
+}
+
+if (import.meta.hot) {
+ import.meta.hot.accept()
+}
diff --git a/packages/plugin-rsc/examples/client-first/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/client-first/src/framework/entry.ssr.tsx
new file mode 100644
index 000000000..e0b536e32
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/src/framework/entry.ssr.tsx
@@ -0,0 +1,22 @@
+import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
+import { renderToReadableStream } from 'react-dom/server.edge'
+import { Root } from '../root'
+import { setRscFnCaller, type RscFnCaller } from './runtime'
+
+export async function renderHtml() {
+ const bootstrapScriptContent =
+ await import.meta.viteRsc.loadBootstrapScriptContent('index')
+ return renderToReadableStream(, { bootstrapScriptContent })
+}
+
+// SSR resolves RSC functions in-process because it already runs beside the RSC
+// environment. Browser calls use HTTP instead.
+const callRscFn: RscFnCaller = async (id, args) => {
+ const rscEntry = await import.meta.viteRsc.loadModule<
+ typeof import('./entry.rsc')
+ >('rsc', 'index')
+ const stream = await rscEntry.executeRscFn(id, args)
+ return createFromReadableStream(stream)
+}
+
+setRscFnCaller(callRscFn)
diff --git a/packages/plugin-rsc/examples/client-first/src/framework/runtime.tsx b/packages/plugin-rsc/examples/client-first/src/framework/runtime.tsx
new file mode 100644
index 000000000..5022c7a67
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/src/framework/runtime.tsx
@@ -0,0 +1,26 @@
+export type RscFnCaller = (id: string, args: unknown[]) => Promise
+let rscFnCaller: RscFnCaller
+
+export function setRscFnCaller(callerImpl: RscFnCaller) {
+ rscFnCaller = callerImpl
+}
+
+// React use() requires the same promise when a suspended render restarts. This
+// minimal argument-keyed cache is module-scoped, including during SSR.
+export function createRscFn(
+ id: string,
+ handler: (...args: TArgs) => Promise,
+) {
+ const promises = new Map>()
+ const rscFn = (...args: TArgs) => {
+ const key = JSON.stringify(args)
+ let promise = promises.get(key)
+ if (!promise) {
+ promise = rscFnCaller(id, args) as Promise
+ promises.set(key, promise)
+ }
+ return promise
+ }
+ rscFn.handler = handler
+ return rscFn
+}
diff --git a/packages/plugin-rsc/examples/client-first/src/root.tsx b/packages/plugin-rsc/examples/client-first/src/root.tsx
new file mode 100644
index 000000000..5959f0452
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/src/root.tsx
@@ -0,0 +1,15 @@
+import { Page } from './routes/page'
+
+export function Root() {
+ return (
+
+
+
+ Client-first RSC
+
+
+
+
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/client-first/src/routes/page.tsx b/packages/plugin-rsc/examples/client-first/src/routes/page.tsx
new file mode 100644
index 000000000..c56e008f0
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/src/routes/page.tsx
@@ -0,0 +1,28 @@
+import { use, useState } from 'react'
+import { createRscFn } from '../framework/runtime'
+
+// TODO: Split this module via query params so browser/SSR retain the caller and
+// Page while RSC receives the handler. This temporary export-only transform
+// enables Fast Refresh but does not remove the handler from caller bundles.
+/* @rsc-only-export */
+export const getServerMessage = createRscFn('getServerMessage', async () => (
+ server: baseline
+))
+
+export function Page() {
+ const serverMessage = use(getServerMessage())
+ const [count, setCount] = useState(0)
+
+ return (
+
+ client: baseline
+ {serverMessage}
+
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/client-first/tsconfig.json b/packages/plugin-rsc/examples/client-first/tsconfig.json
new file mode 100644
index 000000000..ebc1ee8cc
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vite/client", "@vitejs/plugin-rsc/types"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/packages/plugin-rsc/examples/client-first/vite.config.ts b/packages/plugin-rsc/examples/client-first/vite.config.ts
new file mode 100644
index 000000000..075202888
--- /dev/null
+++ b/packages/plugin-rsc/examples/client-first/vite.config.ts
@@ -0,0 +1,42 @@
+import react from '@vitejs/plugin-react'
+import rsc from '@vitejs/plugin-rsc'
+import { defineConfig, type Plugin } from 'vite'
+
+export default defineConfig({
+ plugins: [
+ createRscFnPlugin(),
+ rsc({
+ entries: {
+ client: './src/framework/entry.browser.tsx',
+ ssr: './src/framework/entry.ssr.tsx',
+ rsc: './src/framework/entry.rsc.tsx',
+ },
+ }),
+ react(),
+ ],
+})
+
+// Keep marked RSC functions exported in the RSC environment for registry
+// lookup, but make them local in browser/SSR so React sees a component-only
+// export boundary and can preserve state during Fast Refresh. This temporary
+// transform does not remove handler code from caller bundles.
+function createRscFnPlugin(): Plugin {
+ return {
+ name: 'client-first:rsc-only-export',
+ enforce: 'pre',
+ transform(code) {
+ if (
+ this.environment.name !== 'rsc' &&
+ code.includes('@rsc-only-export')
+ ) {
+ return {
+ code: code.replace(
+ /(\/\* @rsc-only-export \*\/\s*)export\b/g,
+ '$1 ',
+ ),
+ map: null,
+ }
+ }
+ },
+ }
+}
diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts
index a57341245..9253de54f 100644
--- a/packages/plugin-rsc/src/plugin.ts
+++ b/packages/plugin-rsc/src/plugin.ts
@@ -852,14 +852,26 @@ export default function vitePluginRsc(
const env = ctx.server.environments.rsc!
const mod = env.moduleGraph.getModuleById(ctx.file)
if (mod) {
+ // Unusually, the same source file can be live in the client graph
+ // while also present in the RSC graph without a "use client"
+ // boundary. For example, a client-first framework may extract an
+ // RSC function handler while treating a component in the same file
+ // as client code by convention. Refresh style/watch importers, but
+ // preserve normal client HMR in this case.
+ let hasNonCssImporter = false
for (const clientMod of ctx.modules) {
for (const importer of clientMod.importers) {
- if (importer.id && isCSSRequest(importer.id)) {
+ if (!importer.id) continue
+ if (isCSSRequest(importer.id)) {
await this.environment.reloadModule(importer)
+ } else {
+ hasNonCssImporter = true
}
}
}
- return []
+ if (!hasNonCssImporter) {
+ return []
+ }
}
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 67677d101..fdd5af7b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -630,6 +630,31 @@ importers:
specifier: ^8.1.4
version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
+ packages/plugin-rsc/examples/client-first:
+ dependencies:
+ react:
+ specifier: ^19.2.8
+ version: 19.2.8
+ react-dom:
+ specifier: ^19.2.8
+ version: 19.2.8(react@19.2.8)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.17
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react':
+ specifier: latest
+ version: link:../../../plugin-react
+ '@vitejs/plugin-rsc':
+ specifier: latest
+ version: link:../..
+ vite:
+ specifier: ^8.1.4
+ version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
+
packages/plugin-rsc/examples/e2e:
devDependencies:
'@rolldown/plugin-babel':