diff --git a/playground/ssr-react-error-handling/README.md b/playground/ssr-react-error-handling/README.md
new file mode 100644
index 000000000..69a62783e
--- /dev/null
+++ b/playground/ssr-react-error-handling/README.md
@@ -0,0 +1,77 @@
+# SSR Error Handling Example
+
+Demonstrates common error handling patterns for React 19 SSR with Vite.
+
+## Patterns covered
+
+### 1. `onError` callback (`entry-server.jsx`)
+
+The `onError` option on `renderToReadableStream` fires whenever a component
+throws during server rendering. Use it to log errors, report to monitoring,
+or return a digest string to the client.
+
+```js
+const stream = await renderToReadableStream(, {
+ onError(error, errorInfo) {
+ console.error('[SSR]', error, errorInfo.componentStack)
+ },
+})
+```
+
+### 2. `digest` for error identification
+
+When a thrown error has a `digest` property, React forwards it to the client
+error boundary without exposing the full error message. This lets you
+correlate server and client errors using an opaque ID.
+
+```js
+onError(error) {
+ if (error?.digest) {
+ return error.digest // sent to client
+ }
+}
+```
+
+### 3. `captureOwnerStack` for debugging (React 19+)
+
+`React.captureOwnerStack()` returns the component stack of the _owner_ that
+rendered the failing component. This is often more useful than the regular
+component stack for tracking down who passed bad props.
+
+```js
+import React from 'react'
+
+onError(error) {
+ const ownerStack = React.captureOwnerStack?.()
+ console.error('[SSR]', error, ownerStack)
+}
+```
+
+### 4. SSR-to-CSR fallback (`entry-server.jsx` + `entry-client.jsx`)
+
+If `renderToReadableStream` itself throws (the shell fails to render), the
+server returns a minimal HTML page with `window.__SSR_ERROR__ = true`. The
+client entry detects this flag and calls `createRoot` instead of
+`hydrateRoot`, mounting the app from scratch on the client.
+
+### 5. `onRecoverableError` on the client (`entry-client.jsx`)
+
+Hydration mismatches and other non-fatal errors surface through the
+`onRecoverableError` callback on `hydrateRoot`. This is the right place to
+log warnings without crashing the app.
+
+## Pages
+
+| Route | Behavior |
+| ---------------- | --------------------------------------------------------- |
+| `/` | Home page with links to error scenarios |
+| `/throws-render` | Throws during render (SSR + CSR), caught by ErrorBoundary |
+| `/throws-effect` | Renders on server, throws in useEffect on client |
+
+## Running
+
+```bash
+# from repo root
+pnpm install
+pnpm --filter @vitejs/test-ssr-react-error-handling dev
+```
diff --git a/playground/ssr-react-error-handling/__tests__/ssr-react-error-handling.spec.ts b/playground/ssr-react-error-handling/__tests__/ssr-react-error-handling.spec.ts
new file mode 100644
index 000000000..bdd02c343
--- /dev/null
+++ b/playground/ssr-react-error-handling/__tests__/ssr-react-error-handling.spec.ts
@@ -0,0 +1,52 @@
+import fetch from 'node-fetch'
+import { expect, test } from 'vitest'
+import { page, viteTestUrl as url } from '~utils'
+
+test('/ renders home page with navigation', async () => {
+ await page.goto(url)
+ await expect.poll(() => page.textContent('h1')).toMatch('Home')
+
+ // raw http request confirms SSR
+ const html = await (await fetch(url)).text()
+ expect(html).toContain('
Home
')
+ expect(html).toContain('Throws during render')
+})
+
+test('/throws-render triggers error boundary on client', async () => {
+ await page.goto(url + '/throws-render')
+
+ // The error boundary should catch the render error and display fallback UI
+ await expect
+ .poll(() => page.textContent('[data-testid="error-fallback"]'))
+ .toContain('Something went wrong')
+
+ // The digest should be visible in the fallback
+ await expect
+ .poll(() => page.textContent('[data-testid="error-fallback"]'))
+ .toContain('RENDER_ERROR_001')
+})
+
+test('/throws-render SSR response includes error script fallback', async () => {
+ // The raw SSR response should still be valid HTML (not a 500 error page).
+ // React streams the shell, then the error boundary catches the throw.
+ const res = await fetch(url + '/throws-render')
+ expect(res.status).toBe(200)
+ const html = await res.text()
+ // Should contain the HTML shell
+ expect(html).toContain('
')
+})
+
+test('/throws-effect renders on server, throws on client', async () => {
+ // Verify SSR produces valid HTML (effect does not run on server)
+ const html = await (await fetch(url + '/throws-effect')).text()
+ expect(html).toContain('Throws in Effect')
+
+ // On the client, the effect triggers an error caught by ErrorBoundary
+ await page.goto(url + '/throws-effect')
+ await expect
+ .poll(() => page.textContent('[data-testid="error-fallback"]'))
+ .toContain('Something went wrong')
+ await expect
+ .poll(() => page.textContent('[data-testid="error-fallback"]'))
+ .toContain('Intentional effect error')
+})
diff --git a/playground/ssr-react-error-handling/index.html b/playground/ssr-react-error-handling/index.html
new file mode 100644
index 000000000..1504a1dd6
--- /dev/null
+++ b/playground/ssr-react-error-handling/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ SSR Error Handling Example
+
+
+
+
+
+
diff --git a/playground/ssr-react-error-handling/package.json b/playground/ssr-react-error-handling/package.json
new file mode 100644
index 000000000..c2c20a207
--- /dev/null
+++ b/playground/ssr-react-error-handling/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@vitejs/test-ssr-react-error-handling",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "workspace:*"
+ }
+}
diff --git a/playground/ssr-react-error-handling/src/App.jsx b/playground/ssr-react-error-handling/src/App.jsx
new file mode 100644
index 000000000..c0338309d
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/App.jsx
@@ -0,0 +1,83 @@
+import React from 'react'
+import { ErrorBoundary } from './ErrorBoundary'
+
+// Auto generates routes from files under ./pages
+// https://vite.dev/guide/features.html#glob-import
+const pages = import.meta.glob('./pages/*.jsx', { eager: true })
+
+const routes = Object.keys(pages).map((path) => {
+ const name = path.match(/\.\/pages\/(.*)\.jsx$/)[1]
+ return {
+ name,
+ path: name === 'Home' ? '/' : `/${name.toLowerCase()}`,
+ component: pages[path].default,
+ }
+})
+
+function NotFound() {
+ return
Not found
+}
+
+/**
+ * @param {{ url: URL }} props
+ */
+export function App(props) {
+ const [url, setUrl] = React.useState(props.url)
+
+ React.useEffect(() => {
+ return listenNavigation(() => {
+ setUrl(new URL(window.location.href))
+ })
+ }, [setUrl])
+
+ const route = routes.find((route) => route.path === url.pathname)
+ const Component = route?.component ?? NotFound
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+/**
+ * @param {() => void} onNavigation
+ */
+function listenNavigation(onNavigation) {
+ /**
+ * @param {MouseEvent} e
+ */
+ function onClick(e) {
+ const link = e.target.closest('a')
+ if (
+ link &&
+ link instanceof HTMLAnchorElement &&
+ link.href &&
+ (!link.target || link.target === '_self') &&
+ link.origin === location.origin &&
+ !link.hasAttribute('download') &&
+ e.button === 0 &&
+ !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
+ ) {
+ e.preventDefault()
+ history.pushState(null, '', link.href)
+ onNavigation()
+ }
+ }
+ document.addEventListener('click', onClick)
+ return () => {
+ document.removeEventListener('click', onClick)
+ }
+}
diff --git a/playground/ssr-react-error-handling/src/ErrorBoundary.jsx b/playground/ssr-react-error-handling/src/ErrorBoundary.jsx
new file mode 100644
index 000000000..a3173481e
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/ErrorBoundary.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+
+/**
+ * A basic error boundary that catches render errors in its subtree.
+ *
+ * During SSR, React cannot run error boundaries (they are a client-only
+ * mechanism). If a component throws during SSR, the error is reported
+ * through renderToReadableStream's onError callback. When the same
+ * component throws during hydration or CSR, this boundary catches it and
+ * renders the fallback UI.
+ */
+export class ErrorBoundary extends React.Component {
+ constructor(props) {
+ super(props)
+ this.state = { error: null }
+ }
+
+ static getDerivedStateFromError(error) {
+ return { error }
+ }
+
+ componentDidCatch(error, errorInfo) {
+ console.error(
+ '[ErrorBoundary] Caught:',
+ error,
+ errorInfo.componentStack ?? '',
+ )
+ }
+
+ render() {
+ if (this.state.error) {
+ // Display a minimal fallback. In production you would show a
+ // user-friendly message and optionally a "retry" button.
+ return (
+
+
Something went wrong
+
{this.state.error.message}
+ {this.state.error.digest && (
+
+ Error ID: {this.state.error.digest}
+
+ )}
+
+
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/playground/ssr-react-error-handling/src/entry-client.jsx b/playground/ssr-react-error-handling/src/entry-client.jsx
new file mode 100644
index 000000000..5449cba5c
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/entry-client.jsx
@@ -0,0 +1,27 @@
+import '@vitejs/plugin-react/preamble'
+import ReactDOM from 'react-dom/client'
+import { App } from './App'
+
+const container = document.getElementById('app')
+
+if (window.__SSR_ERROR__) {
+ // SSR failed: mount fresh via createRoot (CSR fallback).
+ // This avoids hydration mismatches when the server sent an empty shell.
+ console.warn('[Client] SSR error detected, falling back to CSR')
+ const root = ReactDOM.createRoot(container)
+ root.render()
+} else {
+ // Normal path: hydrate the server-rendered HTML.
+ ReactDOM.hydrateRoot(container, , {
+ onRecoverableError(error, errorInfo) {
+ // Hydration mismatches and other recoverable errors surface here.
+ // Log them so they are visible during development.
+ console.error(
+ '[Hydration] Recoverable error:',
+ error,
+ errorInfo.componentStack ?? '',
+ )
+ },
+ })
+}
+console.log('mounted')
diff --git a/playground/ssr-react-error-handling/src/entry-server.jsx b/playground/ssr-react-error-handling/src/entry-server.jsx
new file mode 100644
index 000000000..edfac87db
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/entry-server.jsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import { renderToReadableStream } from 'react-dom/server'
+import { App } from './App'
+
+/**
+ * Renders the app to a ReadableStream with error handling.
+ *
+ * Demonstrates:
+ * - onError callback for logging SSR render errors
+ * - digest for identifying errors without leaking details to the client
+ * - captureOwnerStack for enhanced debugging (React 19+)
+ * - SSR-to-CSR fallback when renderToReadableStream fails
+ */
+export async function render(url) {
+ const captureOwnerStack = React.captureOwnerStack
+
+ try {
+ const stream = await renderToReadableStream(
+ ,
+ {
+ onError(error, errorInfo) {
+ // Check for a digest property, which React attaches to errors
+ // thrown in server components or passed through Suspense boundaries.
+ // The digest acts as an opaque identifier: it can be sent to the
+ // client without leaking server internals.
+ if (
+ error &&
+ typeof error === 'object' &&
+ 'digest' in error &&
+ typeof error.digest === 'string'
+ ) {
+ console.error(
+ `[SSR] Error with digest "${error.digest}":`,
+ error.message,
+ )
+ return error.digest
+ }
+
+ // captureOwnerStack() returns the component stack of the owner
+ // that rendered the component where the error originated. This is
+ // more useful than errorInfo.componentStack for debugging because
+ // it traces the "who rendered this" chain rather than the "where
+ // in the tree" chain.
+ const ownerStack = captureOwnerStack?.() ?? ''
+ console.error(
+ '[SSR] Render error:',
+ error,
+ ownerStack ? `\nOwner stack:\n${ownerStack}` : '',
+ errorInfo.componentStack
+ ? `\nComponent stack:\n${errorInfo.componentStack}`
+ : '',
+ )
+ },
+ },
+ )
+
+ return stream
+ } catch (error) {
+ // If renderToReadableStream itself throws (e.g. the shell fails to
+ // render), fall back to a CSR-only shell. The client entry will detect
+ // window.__SSR_ERROR__ and use createRoot instead of hydrateRoot.
+ console.error('[SSR] Shell render failed, falling back to CSR:', error)
+ return ``
+ }
+}
diff --git a/playground/ssr-react-error-handling/src/pages/Home.jsx b/playground/ssr-react-error-handling/src/pages/Home.jsx
new file mode 100644
index 000000000..4bc7cc807
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/pages/Home.jsx
@@ -0,0 +1,21 @@
+export default function Home() {
+ return (
+ <>
+
Home
+
+ This example demonstrates SSR error handling patterns with React 19 and
+ Vite.
+
+ Throws in effect - client-only error
+ caught by ErrorBoundary
+
+
+ >
+ )
+}
diff --git a/playground/ssr-react-error-handling/src/pages/ThrowsEffect.jsx b/playground/ssr-react-error-handling/src/pages/ThrowsEffect.jsx
new file mode 100644
index 000000000..f996f58f0
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/pages/ThrowsEffect.jsx
@@ -0,0 +1,32 @@
+import React from 'react'
+
+/**
+ * A component that throws inside a useEffect.
+ *
+ * Since useEffect does not run during SSR, this page renders successfully
+ * on the server. The error only surfaces on the client, where it is caught
+ * by the nearest ErrorBoundary.
+ *
+ * This pattern is common when server-rendered content is valid but a
+ * client-side side effect (e.g. a broken API call or a missing browser
+ * API) fails after hydration.
+ */
+export default function ThrowsEffect() {
+ const [ready, setReady] = React.useState(false)
+
+ React.useEffect(() => {
+ // Simulate a client-only failure (e.g. a broken fetch or missing API)
+ setReady(true)
+ }, [])
+
+ if (ready) {
+ throw new Error('Intentional effect error (client-only)')
+ }
+
+ return (
+ <>
+
Throws in Effect
+
This page renders on the server but throws on the client.
+ >
+ )
+}
diff --git a/playground/ssr-react-error-handling/src/pages/ThrowsRender.jsx b/playground/ssr-react-error-handling/src/pages/ThrowsRender.jsx
new file mode 100644
index 000000000..58b64c020
--- /dev/null
+++ b/playground/ssr-react-error-handling/src/pages/ThrowsRender.jsx
@@ -0,0 +1,15 @@
+/**
+ * A component that always throws during render.
+ *
+ * During SSR, this error is reported through the `onError` callback passed
+ * to `renderToReadableStream`. The stream is still sent to the client, and
+ * the ErrorBoundary catches the re-thrown error during hydration/CSR.
+ *
+ * The error includes a `digest` property that can be used to identify the
+ * error on the client side without leaking server details.
+ */
+export default function ThrowsRender() {
+ const error = new Error('Intentional render error')
+ error.digest = 'RENDER_ERROR_001'
+ throw error
+}
diff --git a/playground/ssr-react-error-handling/vite.config.js b/playground/ssr-react-error-handling/vite.config.js
new file mode 100644
index 000000000..e4c033c2f
--- /dev/null
+++ b/playground/ssr-react-error-handling/vite.config.js
@@ -0,0 +1,134 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import url from 'node:url'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+const _dirname = path.dirname(url.fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ appType: 'custom',
+ build: {
+ minify: false,
+ },
+ environments: {
+ client: {
+ build: {
+ outDir: 'dist/client',
+ },
+ },
+ ssr: {
+ build: {
+ outDir: 'dist/server',
+ rollupOptions: {
+ input: path.resolve(_dirname, 'src/entry-server.jsx'),
+ },
+ },
+ },
+ },
+ plugins: [
+ react(),
+ {
+ name: 'ssr-middleware',
+ configureServer(server) {
+ return () => {
+ server.middlewares.use(async (req, res, next) => {
+ const url = req.originalUrl ?? '/'
+ try {
+ const { render } = await server.ssrLoadModule(
+ '/src/entry-server.jsx',
+ )
+ const htmlStream = await render(url)
+
+ const template = fs.readFileSync(
+ path.resolve(_dirname, 'index.html'),
+ 'utf-8',
+ )
+
+ // If render returned a string (CSR fallback), inject it directly
+ if (typeof htmlStream === 'string') {
+ const html = template.replace(``, htmlStream)
+ res.setHeader('content-type', 'text/html').end(html)
+ return
+ }
+
+ // Stream the response for ReadableStream results
+ const [before, after] = template.split('')
+ res.setHeader('content-type', 'text/html')
+ res.write(before)
+
+ const reader = htmlStream.getReader()
+ const decoder = new TextDecoder()
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ res.write(decoder.decode(value, { stream: true }))
+ }
+
+ res.end(after)
+ } catch (e) {
+ // If SSR fails entirely, fall back to CSR shell
+ console.error('[SSR] Fatal error, falling back to CSR:', e)
+ const template = fs.readFileSync(
+ path.resolve(_dirname, 'index.html'),
+ 'utf-8',
+ )
+ const html = template.replace(
+ ``,
+ ``,
+ )
+ res.setHeader('content-type', 'text/html').end(html)
+ }
+ })
+ }
+ },
+ async configurePreviewServer(server) {
+ const template = fs.readFileSync(
+ path.resolve(_dirname, 'dist/client/index.html'),
+ 'utf-8',
+ )
+ const { render } = await import(
+ url.pathToFileURL(
+ path.resolve(_dirname, './dist/server/entry-server.js'),
+ )
+ )
+ return () => {
+ server.middlewares.use(async (req, res, next) => {
+ const url = req.originalUrl ?? '/'
+ try {
+ const result = await render(url)
+ if (typeof result === 'string') {
+ const html = template.replace(``, result)
+ res.setHeader('content-type', 'text/html').end(html)
+ return
+ }
+
+ const [before, after] = template.split('')
+ res.setHeader('content-type', 'text/html')
+ res.write(before)
+
+ const reader = result.getReader()
+ const decoder = new TextDecoder()
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ res.write(decoder.decode(value, { stream: true }))
+ }
+
+ res.end(after)
+ } catch (e) {
+ console.error('[SSR] Fatal error, falling back to CSR:', e)
+ const html = template.replace(
+ ``,
+ ``,
+ )
+ res.setHeader('content-type', 'text/html').end(html)
+ }
+ })
+ }
+ },
+ },
+ ],
+ // tell vitestSetup.ts to use buildApp API
+ builder: {},
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5934d68f5..703b9b44d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1102,6 +1102,19 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-react
+ playground/ssr-react-error-handling:
+ dependencies:
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@vitejs/plugin-react':
+ specifier: workspace:*
+ version: link:../../packages/plugin-react
+
playground/tsconfig-jsx-preserve:
dependencies:
react: