Skip to content

Knowit-Objectnet/SSR.Net

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

34 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SSR.Net

SSR.Net enables you to Server-Side Render (SSR) components in React 17, React 18, React 19 and Vue 3 in .Net Framework/8/9/10. It is open for extension and can support other frontend frameworks. It aims at being a performant, minimalistic way to support SSR in .Net.

It is based on many of the ideas in ReactJS.Net: https://github.com/reactjs/React.NET.

Table of Contents

Installation

This library can be installed through NuGet. You will also need to install a JavaScriptEngineSwitcher engine and native engine libraries according to your environment.

Configuration and usage

The easiest way to get started is to copy or look at one of the example projects:

To build frontend, just run the build scripts in package.json.

To configure and use SSR.Net, you need to do several things:

We will look at how to do this for React 19 and .Net 10 in the following chapters. There are examples for React 17, React 18, React 19 and Vue 3 for both .Net Framework and .Net 10 in the code base. The examples in .Net 10 should be quite descriptive for .Net Core 2 and forward.

Building a JavaScript bundle fit for SSR

We start by creating a bundle with some components. For React 19, there is an example here: https://github.com/Knowit-Objectnet/SSR.Net/tree/main/src/Frontend/React19Source

We will create a FrontPage component for this example (source code here: https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/Frontend/React19Source/src/Components/FrontPage.tsx).


import React, { useState } from "react";

interface Link {
  text: string;
  href: string;
}

interface Props {
  header?: string;
  links?: Link[];
}

const FrontPage = ({ header, links }: Props) => {
  const [expanded, setExpanded] = useState(false);
  return (
    <div>
      <h1>{header}</h1>
      {links && (
        <>
          <button onClick={() => setExpanded(!expanded)}>Expand links</button>
          {expanded && (
            <ul>
              {links.map((link, idx) => (
                <li key={idx}>
                  <a href={link.href}>{link.text}</a>
                </li>
              ))}
            </ul>
          )}
        </>
      )}
    </div>
  );
};

export default FrontPage;


This component has a simple expand/collapse function. This will show us that React is actually working interactively on the client side.

We then create an entry point for our bundle. Here, we expose the React, ReactDOMServer and ReactDOMClient libraries as global variables. We also expose the components we've created. It works well to just import them all and add them to a global Components variable. See an example of how this is done here: https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/Frontend/React19Source/src/app.tsx

import * as React from 'react'
import * as ReactDOMServer from 'react-dom/server'
import * as ReactDOMClient from 'react-dom/client'
import FrontPage from './Components/FrontPage'
import ArticlePage from './Components/ArticlePage'

globalThis.React = React;
globalThis.ReactDOMServer = ReactDOMServer;
globalThis.ReactDOMClient = ReactDOMClient;
globalThis.Components = {FrontPage, ArticlePage};

In this simple example we use esbuild alone to build the bundle, but it's no problem to use Webpack or Rspack. You can also use Vite, but you will need to turn off the built in minification and find a different way of minifying it, otherwise the global variables will be removed from the bundle during tree shaking. The esbuild command we use is in the package.json (https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/Frontend/React19Source/package.json):

  "scripts": {
...
    "build-dotnet10": "esbuild ./src/app.tsx --bundle --outfile=../../SSR.Net.DotNet10/wwwroot/react19example.js"
  },

We run this through npm:

npm run build-dotnet10

The resulting js bundle is built to the wwwroot folder in the DotNet10 project as react19example.js: https://github.com/Knowit-Objectnet/SSR.Net/tree/main/src/SSR.Net.DotNet10/wwwroot

Set up a web project

Let's look at this example web project: https://github.com/Knowit-Objectnet/SSR.Net/tree/main/src/SSR.Net.DotNet10

We have an extension method for adding a React19Renderer to IoC, which is the normal way of adding things to .Net Core and forward: https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/SSR.Net.DotNet10/Services/ServiceCollectionExtensionsReact19SSR.cs

using JavaScriptEngineSwitcher.V8;
using SSR.Net.Services;

namespace SSR.Net.DotNet10.Services
{
    public static class ServiceCollectionExtensionsReact19SSR
    {
        public static void AddReact19Renderer(this IServiceCollection services, IWebHostEnvironment webHostEnvironment)
        {
            var pool = new JavaScriptEnginePool(new V8JsEngineFactory(), config =>
                config
                    .AddScriptFile(Path.Combine(webHostEnvironment.WebRootPath, "React19TextEncoderPolyfill.js"))
                    .AddScriptFile(Path.Combine(webHostEnvironment.WebRootPath, "React19MessageChannelPolyfill.js"))
                    .AddScriptFile(Path.Combine(webHostEnvironment.WebRootPath, "react19example.js"))
            );
            services.AddSingleton(new React19Renderer(pool));
        }
    }
}

The code above adds both two server-side polyfills named React19TextEncoderPolyfill.js and React19MessageChannelPolyfill.js (React 19 needs this, and it's missing in the Java Script Engine), and the bundle we created in Building a JavaScript bundle fit for SSR, react19example.js. These script files will be run on each server-side JavaScript engine during initialization. This means that each JavaScript engine on the server will be able to run React, ReactDOMServer, ReactDOMClient and access our FrontPage component through the global variable Components.

We use the V8 JavaScript engine in this example. This, and the scripts, is configured in a JavaScriptEnginePool. This pool will manage JavaScript engines, create new ones proactively, dispose exhausted engines and forward the rendering calls to the JavaScript engines.

To make this work, we have to reference some packages in our web project: https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/SSR.Net.DotNet10/SSR.Net.DotNet10.csproj

    <PackageReference Include="JavaScriptEngineSwitcher.Core" Version="3.31.0" />
    <PackageReference Include="JavaScriptEngineSwitcher.V8" Version="3.34.1" />
    <PackageReference Include="Microsoft.ClearScript.Core" Version="7.5.1" />
    <PackageReference Include="Microsoft.ClearScript.V8.Native.win-x64" Version="7.5.1" />
    <PackageReference Include="Microsoft.ClearScript.V8.Native.win-x86" Version="7.5.1" />

After this is done, we call the extension method during the initialization of our program in Program.cs: https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/SSR.Net.DotNet10/Program.cs

builder.Services.AddReact19Renderer(builder.Environment);

Set up a controller and a view

To do the actual Server-Side Rendering, we need to serialize props for the React component we want to Server-Side Render and send the props as JSON to the React19Renderer, together with the component name. In this simplified example, we do this directly in the controller (https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/SSR.Net.DotNet10/Controllers/HomeController.cs).

First, we inject the React19Renderer in the controller (some lines are removed for brevity and clarity):

    public class HomeController : Controller {
        private readonly React19Renderer _react19Renderer;

        public HomeController(React19Renderer react19Renderer)
        {
            _react19Renderer = react19Renderer;
        }
	...
	}

Then we add an action to the controller, where we serialize our model and let the React19Renderer create both the HTML and the initialization script. These are returned as a RenderedComponent from the React18Renderer, and passed on to the view:

public ActionResult React19()
{
    var propsJson = JsonConvert.SerializeObject(
        new {
            header = "React 19 with SSR",
            links = new[]{
                new {
                    text = "Firefox",
                    href = "https://www.firefox.com/"
                },
                new {
                    text = "Hacker news",
                    href = "https://news.ycombinator.com"
                }
            }
        });
    var renderedComponent = _react19Renderer.RenderComponent("Components.FrontPage", propsJson);
    return View(renderedComponent);
}

The view is kept very simple (https://github.com/Knowit-Objectnet/SSR.Net/blob/main/src/SSR.Net.DotNet10/Views/Home/React19.cshtml):

@using SSR.Net.Models;
@model RenderedComponent
@{
    Layout = null;
}

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>React 19 with SSR in DotNet 10</title>
    <script src="~/react19example.js"></script>
</head>
<body>
    @Html.Raw(Model.Html)
    <script>@Html.Raw(Model.InitScript)</script>
</body>
</html>

Here we use a RenderedComponent as our model. We set Layout to null, because we only use the markup in this file. We include the react19example.js script that we created in Building a JavaScript bundle fit for SSR in a <script> tag in the tag.

Inside the tag, there are just two lines. The first one takes the HTML from our RenderedComponent and renders it as a raw string in the view. The second one takes the initialization script and renders it as a raw string inside a <script> tag.

Running the solution

When running the solution and visiting the React 19 view, we see that it's working and that we can interact with it (clicking Expand links will expand the links):

React 19 in the browser

If we view the source, we see that we get markup from the server and not just an empty

. We also see the initialization script, which instructs React to latch on to the
with the markup by calling ReactDOMClient.hydrateRoot:

React 19 view source

If the rendering was done without SSR, then the div would be empty and the initialization script would call ReactDOMClient.createRoot:

React 19 view source without SSR

Performance and scaling

In order to be ready to render components quickly, SSR.Net ensures that a configurable number of JavaScript engines are initialized with a given JavaScript bundle. This is done when instantiating a new JavaScriptEnginePool. There are several thresholds controlling this:

  • Minimum engines: When starting or reconfiguring SSR.Net, it will make sure there are at least this number of engines in the engine pool.
  • Maximum engines: The number of engines in the engine pool will never exceed this threshold.
  • Standby engines: The number of vacant engines the pool should have (while not exceeding the maximum number of engines).
  • Maximum engine usages: A given engine can be used this number of times before it gets disposed of.

There are methods to configure this when initializing the JavaScriptEnginePool:

var pool = new JavaScriptEnginePool(new V8JsEngineFactory(), config =>
    config
        .AddScriptFile(Path.Combine(webHostEnvironment.WebRootPath, "React19TextEncoderPolyfill.js"))
        .AddScriptFile(Path.Combine(webHostEnvironment.WebRootPath, "React19MessageChannelPolyfill.js"))
        .AddScriptFile(Path.Combine(webHostEnvironment.WebRootPath, "react19example.js")) 
        .WithMinEngineCount(5)
        .WithStandbyEngineCount(3)
        .WithMaxEngineCount(25)
        .WithMaxUsagesCount(100)
);
services.AddSingleton(new React19Renderer(pool));

Example:

Let's say we have configured a minimum of 5 engines, a maximum of 25 engines and 3 engines on standby. Before any requests come in, there will be 5 engines ready to serve. Then 3 requests come in at the same time, occupying 3 engines. SSR.Net will see that there are only 2 engines on standby, and will initialize another engine to comply with the "3 engines on standby" requirement.

Traffic increases drastically, and at some point 23 engines are occupied concurrently. SSR.Net will ensure that there are 25 engines initialized, but will not exceed this threshold. This is to comply with the "maximum of 25 engines" requirement.

Then, there are only 2-3 concurrent requests for a long time. The engines will eventually be exhausted and disposed of, and the number of engines declines. At some point, there are only 5 engines left. The next time an engine gets exhausted, SSR.Net will dispose of it and see that there are only 4 engines left. Then SSR.Net will initialize a new engine to comply with the "minimum of 5 engines" requirement.

Limitations and workarounds when doing SSR

  • The window object does not exist. This means that your code needs to check if window is defined before using it:
if (typeof window !== 'undefined')
{
//Do stuff with window here
}
  • Global variables must not be used (except the ones described above for exposing objects to SSR.Net). Each JavaScript Engine is used multiple times, so global variables will cause state to be shared between requests, meaning data can leak from one user to another.
  • Some 3rd party packages may fail, if they use the window object or global variables.
  • SSR in React won't run useEffect hooks. So using the window object in a useEffect is safe. Also, if you have code that requires the window object and that doesn't contain markup that you need served from the server, then you can defer rendering it until after the SSR has been performed:
const [initialized, setInitialized] = useState(false);

useEffect(()=>setInitialized(true));

return (<div>{initialized && <ComponentThatDoesNotSupportSSR {...someProps} />}</div>);

About

A library that supports Server Side Rendering (SSR) for React 17, React 18, Vue 3 and potentially other frameworks

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors