Host React with Blazor Server
This article explores my experiment of hosting React within a Blazor Server project. Blazor offers a hosting model called Hosted Blazor, which combines Blazor Server and Blazor WebAssembly (WASM) within a single solution. This allows the application to render HTML with either server-side or client-side generation.
However, React excels due to its robust ecosystem in virtually every aspect, particularly its wide array of UI library choices. This potential advantage motivated this experiment.
Prerequisites
Here are the essentials you'll need to follow along:
-
Node + pnpm (or npm or similar). This guide uses
pnpm
. - Vite for React setup.
- .NET Core (.NET 7 or later -- that supports Blazor project)
Install pnpm
Refer to the official installation instructions here: pnpm Installation Guide.
You can also install Node.js using the pnpm
command:
pnpm env use --global lts
Setup React Side
Let's start by setting up the React application without .NET interoperability.
Steps
-
Create a directory to host a solution:
For example, create the directory called
HostedReact
. -
In that directory, create a solution and a new Blazor Server app:
We'll have a solution directory to host two projects: one is a Blazor server project, another is a React project.
Run the following commands in your terminal:
dotnet new sln dotnet new gitignore md src dotnet new blazor -o ./src/Sample dotnet sln add ./src/Sample
-
Create a React app using Vite in the
src
directory:
cd src pnpm create vite@latest cd <Your-New-Project-Name> pnpm install
-
Install node type definitions for better type safety:
pnpm add -D @types/node
-
Install
nodePolyfilles
for handling environment variabls with Vite:Vite uses environment variables such as
process
for resolving paths, and these tools are essential for compatibility:
pnpm add node-stdlib-browser pnpm add -D vite-plugin-node-stdlib-browser
-
Create a module entry point for the React project to be consumed by the Blazor:
Create a new file
entrypoint.jsx
in the Reactsrc
directory.
import {createRoot} from "react-dom/client"; import {StrictMode} from "react"; import App from "./App.jsx"; import './index.css' export function Initialize(el){ createRoot(el).render( <StrictMode> <App /> </StrictMode> ) }
Modify
main.jsx
to reuse this entry point function.
import './index.css' import {Initialize} from "./entrypoint.jsx"; Initialize(document.getElementById('root'))
-
Configure Vite for library generation (instead of a static web app):
By default, Vite generates a static web app that bundles and
index.html
and necessary JavaScript files. However, to integrate with the Blazor Server project, we need it to output a JavaScript module. Update your Vite configuration (vite.config.js
orvite.config.ts
) as follow:
import path from "path" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' // @ts-expect-error Test import nodePolyfills from "vite-plugin-node-stdlib-browser" // https://vite.dev/config/ export default defineConfig({ base: "/react/", build: { lib: { entry: path.resolve(__dirname, "src/entrypoint.jsx"), name: "ReactApp", fileName: "ReactApp", formats: ["es"] }, outDir: "../Sample/wwwroot/react", emptyOutDir: true }, plugins: [react(), nodePolyfills()], resolve: { alias: { "@": path.resolve(__dirname, "./src") } } })
Notes:
- The
outDir
field specifies the location to generate files, aligning with Blazor's staticwwwroot
directory. - The
base
key is for Vite's dev server and does not include thewwwroot
part.
I recommend using React JavaScript (instead of TypeScript) to avoid additional overhead when calling .NET methods from JS. With JavaScript, you won't need to declare explicit types during interop. However, this is optional and depends on your preference.
- The
-
Build the React project:
Run the following command to generate necessary files under the
wwwroot/react
directory:
pnpm run build
The output will include
ReactApp.css
andReactApp.js
. Ensure that you add/src/Sample/wwwroot/react
to your project's.gitignore
.
Setting Up the Blazor Side
Now that you've configured the React project, it's time to integrate it with Blazor so it can host the React app. This involves making use of the Initialize
function declared earlier in the React entrypoint.jsx
file.
Steps:
-
Simplify the Blazor layout:
Since the React app comes with its own style (css), we may want to get rid of Blazor's style related stuffs. You may even create a layout compoonent specifically for React rendering purpose.
Update
MainLayout.razor
to an empty template:
@inherits LayoutComponentBase @Body
For
App.razor
, keep it minimal:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <base href="/" /> <link rel="stylesheet" href="@Assets["Sample.styles.css"]" /> <ImportMap /> <link rel="icon" type="image/png" href="favicon.png" /> <HeadOutlet /> </head> <body> <Routes /> <script src="_framework/blazor.web.js"></script> </body> </html>
-
Create a JS interop class for React integration:
Blazor uses JavaScript Interop to invoke JavaScript functions and receive data back from JavaScript side. To render the React components, we'll use JavaScript Interop to call the
Initialize
function you defined in yourentrypoint.jsx
file.Create a
ReactJsInterop.cs
file in the project root (or another convenient namespace). Add the following code:
using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; namespace HostedReact; public sealed class ReactJsInterop(IJSRuntime js) : IAsyncDisposable { readonly Lazy<Task<IJSObjectReference>> importModule = new(() => js.InvokeAsync<IJSObjectReference>("import", "./react/ReactApp.js").AsTask()); public ValueTask Initialize(ElementReference el) => InvokeVoidAsync("Initialize", el); async ValueTask InvokeVoidAsync(string identifier, params object?[]? args) { var module = await importModule.Value; await module.InvokeVoidAsync(identifier, args); } async ValueTask<T> InvokeAsync<T>(string identifier, params object?[]? args) { var module = await importModule.Value; return await module.InvokeAsync<T>(identifier, args); } public async ValueTask DisposeAsync() { if (importModule.IsValueCreated){ using var import = importModule.Value; var module = await import; await module.DisposeAsync(); } } }
Notes:
- This class dynamically imports the React app’s JavaScript module (
ReactApp.js
) at runtime and uses theInitialize
function to render React components in a DOM element. - The
ElementReference
parameter is used to pass the DOM element where React will inject the UI.
Next, add the
ReactJsInterop
service to your dependency injection (DI) container. Update theProgram.cs
file to include the registration:
builder.Services.AddScoped<ReactJsInterop>();
- This class dynamically imports the React app’s JavaScript module (
-
Render the React App with Interop:
Use the
ReactJsInterop
class in a Razor page to host the React components. Let's modifyHome.razor
as an example:
@page "/" @rendermode @(new InteractiveServerRenderMode(prerender: false)) @inject ReactJsInterop ReactJs <link rel="stylesheet" href="./react/ReactApp.css" /> <div @ref="div" id="root">Loading...</div> @code { ElementReference div; protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) await ReactJs.Initialize(div); } }
Notes:
- Add the reference to the generated React stylesheet
ReactApp.css
so that React styles are applied in Blazor. - Use the
@ref
directive to link a Blazor-controlleddiv
element (withid="root"
) for React to render its components.
- Add the reference to the generated React stylesheet
-
Run and Verify
Start the Blazor app using the
dotnet run
command, and navigate to the configured route (e.g./
) in your browser. You should see the React app rendered withint the Blazor app's DOM.If any changes are made to the React app, you must rebuild it using:
pnpm run build
This regenerates the
ReactApp.js
andReactApp.css
files inside the Blazorwwwroot/react
directory.
Add two-way interoperability
So far, we've configured React to render inside a Blazor component using the Initialize
function. While this setup enables on-way communication -- from .NET (Blazor) to React -- we can extend it to allow React to call back into .NET, enabling full two-way interaction. This is achieved using Blazor's JavaScript Interop ([JSInvokable]
) alongside enhancements to the React entry point.
This interoperability can be helpful for scenarios like updating React UI components based on data fetched from a Blazor service or triggering Blazor logic from user actions in the React app.
Steps:
-
Expose Methods in Blazor for React to Call
You'll need to define methods in Blazor that React can invoke using the .NET-to-JS interop mechanism. Let's update the
Home.razor
file to include a[JSInvokable]
method that React can call.Update
Home.razor
like this:
@page "/" @rendermode @(new InteractiveServerRenderMode(prerender: false)) @inject ReactJsInterop ReactJs @implements IDisposable <link rel="stylesheet" href="./react/ReactApp.css" /> <div @ref="div" id="root">Loading...</div> @code { ElementReference div; readonly DotNetObjectReference<Home> me; public Home() { me = DotNetObjectReference.Create(this); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) await ReactJs.Initialize(div, me); } [JSInvokable] public int GetSecretNumber() => 123; public void Dispose() { me.Dispose(); } }
GetSecretNumber
is the method that will be called from the React App. Notice that we need to mark JS callable methods withJSInvokableAttribute
. A JS method can be either sync or async.Also notice that
IDisposable
implementation is needed to release resources allocated byDotNetObjectReference
, to avoid the memory leakage. -
Update the React Initialize Function:
In your
entrypoint.jsx
file, extend theInitialize
function to accept the additional.NET
object reference. Use this to call the .NET method from React.Update the
entrypoint.jsx
file like so:
import {createRoot} from "react-dom/client"; import {StrictMode} from "react"; import App from "./App.jsx"; import './index.css' export async function Initialize(el, dotnet){ const number = await dotnet.invokeMethodAsync("GetSecretNumber") createRoot(el).render( <StrictMode> <App n={number} /> </StrictMode> ) }
And that's it. Now we have two-way communication between JS and .NET.
Conclusion
This experiment was initiated to take advantage of React's superior ecosystem and developer experience, particularly its Hot Module Replacement (HMR) feature, which allows faster UI development. While Blazor does have HMR, changes to .NET code or Razor files often require a rebuild, making development less efficient in comparison.
However, this hybrid approach has its trade-offs:
- While React speeds up UI development with better HMR and its rich ecosystem, it introduces an additional API layer between .NET and JavaScript, adding complexity and potential overhead compared to pure Blazor applications.
- The additional
pnpm run build
step for React takes 3–4 seconds to complete, which is roughly comparable to Blazor’s HMR compilation time, minimizing the perceived benefits in simple use cases.
That said, this setup shows its value in projects where React's strengths in UI development already align with an existing API-driven architecture, such as those using HTTP or gRPC for communication. In such cases, the React-Blazor integration can provide a powerful and cohesive solution by leveraging React’s flexibility while still benefiting from Blazor's robust backend/server-side capabilities.
In summary, while this hybrid approach might not be a one-size-fits-all solution, it can be a compelling choice for applications that require both React's dynamic UI ecosystem and seamless integration with Blazor's full-stack .NET environment.
Top comments (0)