If you followed my previous series on writing a blog site with .NET 6 and React JS and stood up your own site, you might have experienced what I have: being invisible to the search engines. In this article, we will rewrite the UI to use server-side rendering (SSR) with ReactJS.NET. To achieve this, we will keep everything but the ‘blog.ui’ project in our solution. Keep a copy of the old blog.ui, as you will want to pull in a great deal of source code from it.
ReactJS.NETI followed theirtutorial and adapted it to .NET 6 for this article. I’ll not repeat all the details of what the functionality is here, rather, I will expect that at some point you will read that tutorial and the docs for further information.
Once you have removed ‘blog.ui’ to a holding location and it is no longer in your solution folder, go into PowerShell and create a new ‘blog.ui’ project.
dotnet new mvc -n blog.ui
In your preferred text editor, edit the .gitignore file and add the following line if it is not already in the file.
/wwwroot/dist/
Launch Visual Studio 2022 and open the UI project. Expand the ‘wwwrooot’ folder and delete ‘js’ and ‘lib’. Those will not be needed for this. If you have Visual Studio Enterprise, launch the live testing. Now, in another instance of Visual Studio, open the old blog.ui project and copy over the API controllers: BlogController.cs, FileController.cs, LoginController.cs. Then copy over all the Models and the content from appsettings.Development.json.
Add the project references to ‘blog.data’ and ‘blog.objects’, now add BCrypt.Net-Next into the project. At this point, all your unit tests should be passing again.
Next we bring over our ‘Program.cs’ code that handles dependency injection and authentication/authorization. Bring the following over to your new project:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
builder.Services.AddAuthorization(options =>
{
var policyBuilder = new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme);
policyBuilder.RequireAuthenticatedUser();
options.DefaultPolicy = policyBuilder.Build();
options.AddPolicy("RequireAdminRole", policy => policy.RequireRole("admin"));
});
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddTransient();
And:
app.UseAuthentication();
app.UseAuthorization();
Add the needed ‘using’ clauses, verify you can build and all the tests are still passing.
Let’s get the server-side rendering going. Open the NuGet manager and add the following packages:
JavaScriptEngineSwitcher.Extensions.MsDependencyInjection
JavaScriptEngineSwitcher.V8
Microsoft.ClearScript.V8.Native.win-x64
Microsoft.ClearScript.V8.Native.win-x86
React.AspNet
React.Router
We are using both win-x64 and win-x86 so the site can run on both a 64bit and 32bit image. If you are using Linux, install the native package for that environment.
Open ‘Program.cs’ and before this line:
builder.Services.AddControllersWithViews();
Insert the following code:
builder.Services.AddSingleton();
builder.Services.AddJsEngineSwitcher(options => options.DefaultEngineName = V8JsEngine.EngineName)
.AddV8();
builder.Services.AddReact();
Add the needed ‘using’ clauses.
Now move down to the line:
app.UseStaticFiles();
And insert the following above it:
app.UseReact(config =>
{
config.SetReuseJavaScriptEngines(true)
.SetLoadBabel(true)
.SetLoadReact(true)
.SetReactAppBuildPath("~/dist");
});
Add any necessary ‘using’ statements.
We need to tell Visual Studio to compile our React JS code, so open the ‘blog.ui’ project file and insert the following:
Add a new folder to ‘blog.ui’ called ‘Content’. Then under ‘Content’, add ‘components’ and ‘utils’. Copy the ‘Sessions.js’ file from the old project to your ‘utils’ folder.
Next, in the ‘components’ file, create a new file called ‘expose-components.js’ and add the following content:
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import RootComponent from './App';
import { ServerStyleSheet } from 'styled-components';
import { JssProvider, SheetsRegistry } from 'react-jss';
import { renderStylesToString } from 'emotion-server';
import Helmet from 'react-helmet';
global.React = React;
global.ReactDOM = ReactDOM;
global.ReactDOMServer = ReactDOMServer;
global.Styled = { ServerStyleSheet };
global.ReactJss = { JssProvider, SheetsRegistry };
global.EmotionServer = { renderStylesToString };
global.Helmet = Helmet;
global.Components = { RootComponent };
I pulled this file straight from the ReactJS.net template project. Note that we named our app ‘RootComponent’. We will use this later to tell the engine what to render. We also listed some dependencies that we have yet to install. We’ll do that now. Unlike the previous project, install the dependencies straight in the ‘blog.ui’ folder. First, fill in a ‘package.json’ file.
{
"version": "1.0.0",
"name": "blog.ui",
"license": "MIT",
"scripts": {
"build": "rimraf wwwroot/dist && webpack"
},
"dependencies": {},
"devDependencies": {}
}
We are using emotion@9.2 because in version 11, I ran into server-side compatibility issues. In PowerShell, change into the ‘blog.ui’ folder and run the following:
npm i --save react@16.14 react-dom@16.14 react-router-dom@5.3 styled-components react-jss emotion@9.2 emotion-server@9.2 react-helmet bootstrap reactstrap
Now we set up our development dependencies. I’m going to speed through this, as I expect if you are reading this blog, you are already familiar with the tooling. I will note here that we are using webpack 4, as I ran into compatibility issues trying to use version 5.
npm i --save-dev @babel/core @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread @babel/plugin-syntax-dynamic-import @babel/plugin-transform-runtime @babel/preset-env @babel/preset-react babel-loader babel-runtime css-loader rimraf style-loader webpack@4 webpack-cli webpack-manifest-plugin
We need to configure babel with a new .babelrc file.
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"plugins": [
"@babel/proposal-object-rest-spread",
"@babel/plugin-syntax-dynamic-import",
"@babel/proposal-class-properties",
"@babel/plugin-transform-runtime"
]
}
And configure webpack with a new webpack.config.js file.
const path = require('path');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
module.exports = {
entry: './Content/components/expose-components.js',
output: {
filename: '[name].[contenthash:8].js',
globalObject: 'this',
path: path.resolve(__dirname, 'wwwroot/dist'),
publicPath: '/dist/'
},
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
optimization: {
runtimeChunk: {
name: 'runtime', // necessary when using multiple entrypoints on the same page
},
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'vendor',
chunks: 'all',
},
},
},
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel-loader',
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new WebpackManifestPlugin({
fileName: 'asset-manifest.json',
generate: (seed, files) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed);
const entrypointFiles = files.filter(x => x.isInitial && !x.name.endsWith('.map')).map(x => x.path);
return {
files: manifestFiles,
entrypoints: entrypointFiles,
};
},
}),
]
};
We are almost ready to run a test build, we just need an App.js to import. In /Content/components create the App.js file. I use either Atom or VS Code for creating JS files, as Visual Studio as a BOM at the beginning of the file, which causes npm warning.
import React from 'react'
const App = () => {
return (
Hello, world!
)
}
export default App
In PowerShell run:
npm run build
It should be successfully building. Now we are ready to server side render our “Hello, world!”.
In Visual Studio, add the following packages. We’ve already wired up the engine, so let’s modify the default ‘Home’ view to serve the content.
In the project, open the ‘Views’ folder and open _ViewImports.cshtml. It should look like this:
@using blog.ui
@using blog.ui.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Here we will ad the necessary using clauses for the ReactJS.Net renderer. Above the first line insert the following three lines:
@using React.AspNet
@using React.RenderFunctions
@using React.Router
This will import the extension methods for all our view, though we will only use one for this app. Now, expand ‘Shared’ and open _Layout.cshtml. Delete everything between the and tags. Then navigate to the section. Since we deleted the lib folder, remove the stylesheet reference for bootstrap. I replaced it with the bootstrap CDN for my site, you can insert this line in place of the old one:
With server-side rendering, we cannot import css files into our components like we did for the other app. Instead, we will be using styled components as they will work for both server-side and client-side rendering. To support this, we need to call an extension method to inject the styles from the styled components into the page. After all the stylesheet references and before tag, insert these lines:
@Html.Raw(ViewBag.ServerStyles)
@Html.ReactGetStylePaths()
Rendering the body takes only three lines of code, the traditional render call, and two extension method calls for React JS. Between the and tags insert these lines:
@RenderBody()
@Html.ReactGetScriptPaths()
@Html.ReactInitJavaScript()
We are almost there. Now, open the ‘Home’ folder and open Index.cshtml. Delete all the contents. Next, we need use the layout we updated and tell the engine how to render the React JS code. At this time, we have no props to send to our JavaScript, so we create the ReactJS.Net functions and call the extension method. Insert the following lines:
@{
Layout = "_Layout";
var emotionFunctions = new EmotionFunctions();
var styledComponentsFunctions = new StyledComponentsFunctions();
var reactJssFunctions = new ReactJssFunctions();
var helmetFunctions = new ReactHelmetFunctions();
var chainedFunctions = new ChainedRenderFunctions(emotionFunctions, styledComponentsFunctions, reactJssFunctions, helmetFunctions);
}
@Html.ReactRouter("Components.RootComponent", new {}, renderFunctions: chainedFunctions)
@{
ViewBag.ServerStyles = styledComponentsFunctions.RenderedStyles + reactJssFunctions.RenderedStyles;
}
Later, we will handle routes and passing in properties. Right now, we want to see it run, so build and run the app. There we go! A server-side rendered React JS page. Close that now and we will get the blogs displaying again.
First, we will update App.js to handle the routing. One issue that I ran into with routing is that the default router, BrowserHistory, cannot be run on the server-side. Instead MemoryHistory must be used. The problem with that is that MemoryHistory will not behave on the client-side in a fashion that works well. It does not properly update the pages for client-side routes as they are browsed into by the user. There is a StaticHistory that should solve this problem, but when doing this project, I was unable to get it working. Instead, I solved the problem by using MemoryHistory on the server-side and BrowserHistory on the client site. In your App function in App.js add the imports after the first line.
import ReactDOM from 'react-dom';
import {Router, Switch, Route } from 'react-router-dom'
import { createMemoryHistory, createBrowserHistory } from 'history'
Then update the App function to receive props from the server-side, and create the correct history:
const App = (props) => {
const {url} = {...props}
const history = typeof window === 'undefined'
? createMemoryHistory()
: createBrowserHistory()
return (
Hello, world!
)
}
Add the following imports:
import Home from './Home'
import Blog from './Blog'
Then replace the ‘Hello, world’ with the new routes:
} />
} />
Notice how we are passing the props through to Blog and Home. This is important for the server-side rendering. Also note that I left out Login and NewBlog. This is because I discovered that ‘react-draft-wysiwyg’ is not compatible with server-side rendering. It would make this article too long to cover replacing that here, so we will leave it out for now. I do plan a future blog on my solution to this problem.
In the components folder, add a new Home.js, then we will copy the code from the old project. We only need two changes to make this code work on both server-side and client-side. The first is to allow the control to receive the blog entries from backend C# code, the other is to handle useEffect. That method cannot be run on the server-side, but we still need it if the route to Home is executed on the client side.
The first thing we will do is accept a blog list from the server so that we do not have to make a call back to render the page. This is important for both performance and search engines. Update the declaration of Home to the following:
const Home = ({blogList}) => {
const [blogs, setBlogs] = useState(blogList)
Now we will update our call to useEffect to only run on the client side if the blogList has not been passed in.
if (typeof window !== 'undefined' && !blogList) {
useEffect(() => {
getBlogTitles()
},[])
}
Next, create an empty BlogItem.js and copy in the code from the old project. Since we cannot import css files, replace the following line:
import './BlogItem.css'
with
import styled from 'styled-components'
Next, convert the old css file into a styled component:
const BlogItemStyle = styled.div`
background-color: rgba(0,0,0,0.1);
margin-bottom: 5px;
.landing-item-button {
padding-top: 8px;
}
`
Now wrap the row in the style:
|
{blog.title}
{blog.fragment}...
Before we can test this, we need to add in Blog.js. Create the empty file and copy in the code from the old project. Again, we will allow for the server to pass in the blog and avoid the API call to load the blog. Update the beginning of the function as follows:
const Blog = ({blogEntry}) => {
const {id} = useParams()
const history = useHistory()
const [blog, setBlog] = useState(blogEntry)
And we need to fix up the useEffect call. We will not only check for the existence of a blog, but verify the blog id actually matches the id of the passed in blog. We must check the id because if someone lands straight on the blog, instead of going through the home page, it always pulls up the blog they landed on, but not the one they selected. Checking the id fixes this.
if (typeof window !== 'undefined' && (!blogEntry || blogEntry.id != id)) {
useEffect(() => {
getBlog()
},[])
}
Finally, we will style the blog entry. Remove the import for Blog.css then add the component:
import styled from 'styled-components'
And above the Blog function add the styling:
export const BlogDisplayStyle = styled.div`
background-color: rgba(255,255,255,0.8);
pre {
margin-bottom: 0rem;
background-color: black;
color: white;
}
p {
background-color: rgba(0,0,0,0);
font-size: 1.75em;
}
span {
background-color: rgba(0,0,0,0);
font-size: 1.75em;
}
h1 {
font-size: 4em;
}
@media (min-width: 768px) {
h1 {
font-size: 2.75em;
}
p {
font-size: 1.1em;
}
span {
font-size: 1.1em;
}
}
`
Then wrap the html control in the style:
Update the HomeController constructor to accept a IBlogData instance.
private readonly IBlogData _blogData;
public HomeController(ILogger logger, IBlogData data)
{
_logger = logger;
_blogData = data;
}
Then update the Index() method to place the blog titles into the ViewBag.
public IActionResult Index()
{
ViewBag.properties = new
{
url = Request.GetDisplayUrl(),
blogs = _blogData.GetTitles()
};
return View();
}
Next, open the Index.cshtml file and insert this line after the Layout line.
string route = Context.Request.Path;
The ‘route’ variable will be used to help the React router get the correct resources when rendering. Now, update the ReactRouter call as follows:
@Html.ReactRouter("Components.RootComponent", (object)ViewBag.properties, path: route, renderFunctions: chainedFunctions)
@{
ViewBag.ServerStyles = styledComponentsFunctions.RenderedStyles + reactJssFunctions.RenderedStyles;
}
We are now read to run the site and see if we have a blog list… And we do. Notice the header is missing, I’ll leave that to you to add back in. When we navigate to a blog it should display correctly at this point. You’ll notice that if you hit ‘reload’ on the blog, you get an error. We will fix that next so that a direct link to a blog renders correctly. In Visual Studio open the HomeController and add a Blog() function. This function will pull the request blog from the database and add it as a property to the control.
[Route("blog/{id}")]
public IActionResult Blog([FromRoute] string id)
{
ViewBag.properties = new
{
url = Request.GetDisplayUrl(),
blog = _blogData.Get(id)
};
return View("Index");
}
And there you have it, run this and verify that your blog server-side renders and you are up and running. You can find the code for this project here. https://github.com/scrumfish/ssrexample
Please drop your comments below. I hope this helps.