Skip to content

Architecture

Request flow

Components

PiecePackageRole
ReactView<T>() / ReactView("Name", model)NuGetController extension returning a ReactDotNetCoreResult.
ReactDotNetCoreRendererNuGetSerializes props, calls the sidecar, assembles the HTML page.
ReactSsrClientNuGetTyped HTTP client to the sidecar (/health, /render).
ViteManifestProviderNuGetResolves the client entry → hashed JS + transitive CSS (production).
SidecarHostedServiceNuGetSpawns/supervises the Node sidecar; health-gates startup; auto-restarts.
Node SSR sidecarnpmRenders components to HTML. Production: imports the built bundle. Dev: runs Vite.
createRegistry / mount / createServerRenderernpmRegistry, hydration, and SSR glue used by your entry files.

Why a Node sidecar?

React's server renderer (react-dom/server) is JavaScript. Rather than embed a JS engine in the .NET process, ReactDotNetCore runs a small loopback-only Node HTTP server that the .NET host spawns and supervises. This keeps full compatibility with the React/Vite ecosystem (including HMR in dev) and isolates rendering from your web process.

The sidecar exposes exactly two endpoints:

  • GET /health → readiness probe (used to gate app startup)
  • POST /render{ component, props }{ html } (or { error })

It binds to 127.0.0.1 only and is never exposed publicly.

The page contract

Every rendered page contains:

html
<div id="react-dotnetcore-root" data-react-dotnetcore-component="UserProfile">…SSR HTML…</div>
<script id="react-dotnetcore-props" type="application/json">{…props…}</script>
<!-- production: -->
<link rel="stylesheet" href="/react-dotnetcore/client/assets/entry-client-XXXX.css" />
<script type="module" src="/react-dotnetcore/client/assets/entry-client-XXXX.js"></script>

The client entry (mount) reads the component name from the root element's data-react-dotnetcore-component, parses the embedded props, looks the component up in the registry, and calls hydrateRoot. The same JSON bytes drive SSR and hydration, guaranteeing a match.

Props are escaped for safe embedding inside <script> (<, >, &, U+2028/U+2029 → \uXXXX), which stays valid JSON and round-trips through JSON.parse.

Production vs development

ProductionDevelopment
SSR sourceBuilt bundle (dist/server/entry-server.js)Vite ssrLoadModule (fresh each request)
Client assetsHashed files from the Vite manifest + <link> CSSModules loaded from the Vite dev server origin
ReloadRebuild + restartHMR (Fast Refresh)
Selected byIsDevelopment() == falseIsDevelopment() == true (override with DevMode)

Environment contract

The host passes these to the sidecar process (you only need them if you run the sidecar yourself):

VariableModeMeaning
REACTDOTNETCORE_PORTbothLoopback port to listen on (default 5174).
REACTDOTNETCORE_MODEbothprod (default) or dev.
REACTDOTNETCORE_BUNDLEprodPath to the built SSR bundle exporting render.
REACTDOTNETCORE_ROOTdevVite root containing the entry modules.

Reliability features

  • Free-port auto-selection (SidecarPort = 0) avoids clashes.
  • Health-gated startup — the app waits for the sidecar before serving.
  • Crash auto-restart — bounded by MaxSidecarRestarts.
  • Render timeoutRenderTimeout bounds each SSR call.
  • Graceful shutdown — the sidecar is terminated (process tree) on app stop.
  • No leak in Production — SSR errors are logged server-side and propagate to your configured error handling; internals aren't written to the response.

Released under the MIT License.