Prerendering your Blazor Wasm application with ABP 6.x
Introduction
In this article, we will demonstrate how to create basic hosted monolith Blazor WebAssembly application that will be prerendered by the server.
Prerendering has following advantages:
- 💚 Instant initial load of page content while WebAssembly application downloads and hydrates in the background
- 💚 Improved scores in Chromium Lighthouse
- 💚 Static markup analysis by search engines (SEO) and tools that don't render WebAssembly apps
Prerendering also has some drawbacks:
- 🛑 You can no longer host your app on static file hosting
- 🛑 Different approach is required, no JS Interop calls in
OnInitialized()
, may be a problem with 3rd party components that use JS, but didn't account for non-browser rendering. - 🛑 Double rendering, once on server, once on client. Takes extra steps to preserve state in order to avoid duplicate calls and content swap.
- 🛑 Server rendering doesn't support authentication, so it works only on public pages. For best experience, parts of content specific to unauthenticated/authenticated state should use placeholders until it's decided which one it will be.
⚠ Warning: This isn't production ready solution, it's a rudimentary starter guide on how to make prerendering work. Expect problems.
Source Code
Source code for the final application is available on GitHub.
Screenshot
Requirements
The following tools are needed to be able to run the solution.
- .NET 6.0 SDK
- ABP CLI
- Compatible database engine
Step by step guide
Preparing the solution
- Use the following ABP CLI command to create Blazor WebAssembly app:
abp new Acme.BookStore.WasmPrerendered -u blazor -d mongodb --create-solution-folder --theme basic --preview
cd Acme.BookStore.WasmPrerendered
- Reference
Blazor
project in projectHttpApi.Host
dotnet add src/Acme.BookStore.WasmPrerendered.HttpApi.Host reference src/Acme.BookStore.WasmPrerendered.Blazor
ℹ For simplicity we are reusing
HttpApi.Host
project to have just a single point of entry to run, but nothing stops you from creating separate project to decouple client (+ host) & server API like it's done by default.
- While this step isn't absolutely necessary, when using IDE like Visual Studio Code it's better to build solution for IntelliSense to work correctly.
dotnet build
# Open solution in Visual Studio Code
code .
Adding the prerendering
- Move and rename Blazor's index.html file to our host (
Pages
folder needs to be created first)
- src/Acme.BookStore.WasmPrerendered.Blazor/wwwroot/index.html
+ src/Acme.BookStore.WasmPrerendered.HttpApi.Host/Pages/_Layout.cshtml
- On top of this
_Layout.cshtml
file prepend
+ @using Microsoft.AspNetCore.Components.Web
+ @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
- Inside
<head>
element we can replace static tag<title>
with new dynamic component that will prerender title and metatags coming from pages
- <title>Acme.BookStore.WasmPrerendered.Blazor</title>
+ <component type="typeof(HeadOutlet)" render-mode="WebAssemblyPrerendered" />
- Last thing to do in this file is to replace loading animation which won't be needed anymore
- <div id="ApplicationContainer">...</div>
+ @RenderBody()
- Next to
_Layout.cshtml
create new Razor page_Host.cshtml
that will use _Layout and prerender the application
@page
@using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = "_Layout";
}
@if (HttpContext.Request.Path.StartsWithSegments("/authentication"))
{
<component type="typeof(App)" render-mode="WebAssembly" />
}
else
{
<component type="typeof(App)" render-mode="WebAssemblyPrerendered" />
<persist-component-state />
}
Tag helper
<persist-component-state />
allows us to persist state from first render on server to second render in client preventing duplicate calls and content swap. This doesn't work automatically and has to be implemented for every component separately.
- Open
src\Acme.BookStore.WasmPrerendered.HttpApi.Host\WasmPrerenderedHttpApiHostModule.cs
and to the botom ofOnApplicationInitialization
append
app.UseAbpSerilogEnrichers();
app.UseConfiguredEndpoints();
+ ((WebApplication)app).MapFallbackToPage("/_Host");
}
- To use
_Host
instead of swagger delete the controller
- src\Acme.BookStore.WasmPrerendered.HttpApi.Host\Controllers\HomeController.cs
- Since we are using
HttpApi.Host
project to serve our Blazor application we are interiting its port. To avoid problems with authentication insrc\Acme.BookStore.WasmPrerendered.DbMigrator\appsettings.json
changeWasmPrerendered_Blazor
RootUrl to matchWasmPrerendered_Swagger
.
"WasmPrerendered_Blazor": {
"ClientId": "WasmPrerendered_Blazor",
- "RootUrl": "https://localhost:44351"
+ "RootUrl": "https://localhost:44314"
},
"WasmPrerendered_Swagger": {
"ClientId": "WasmPrerendered_Swagger",
"RootUrl": "https://localhost:44314"
}
We can now migrate our database, run
Acme.BookStore.WasmPrerendered.DbMigrator
projectNow is a good time to run our project to see if everything compiles and host is actually at least trying to prerender our WebAssembly application, don't worry error is to be expected.
dotnet run --project src\Acme.BookStore.WasmPrerendered.HttpApi.Host
⚠ Please take note that you should always run the
HttpApi.Host
project and never theBlazor
project!
Fixing the errors
- Right off the bat we should get DI exception
There is no registered service of type 'Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider'.
Let's fix that, in file WasmPrerenderedHttpApiHostModule.cs
under ConfigureServices
method we need to append
+ context.Services.AddScoped<Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider, Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider>();
- Next in line is router problem
The Router component requires a value for the parameter AppAssembly.
Same file, same method as in previous step
+ Configure<Volo.Abp.AspNetCore.Components.Web.Theming.Routing.AbpRouterOptions>(options => options.AppAssembly = typeof(Blazor.WasmPrerenderedBlazorModule).Assembly);
- Now it's another DI exception
There is no registered service of type 'Volo.Abp.AspNetCore.Components.Web.Theming.Toolbars.IToolbarManager'.
We can solve this with dependency
[DependsOn(
(...)
typeof(AbpSwashbuckleModule),
+ typeof(Volo.Abp.AspNetCore.Components.Web.Theming.AbpAspNetCoreComponentsWebThemingModule)
)]
public class WasmPrerenderedHttpApiHostModule : AbpModule
- Again ... DI exception
InvalidOperationException: Cannot provide a value for property 'ClassProvider' on type 'Blazorise.Badge'. There is no registered service of type 'Blazorise.IClassProvider'.
This is another one for file WasmPrerenderedHttpApiHostModule.cs
under ConfigureServices
context.Services
.AddBootstrap5Providers()
.AddFontAwesomeIcons();
Don't forget the usings
using Blazorise.Bootstrap5;
using Blazorise.Icons.FontAwesome;
⚠ Please note this simplification is duplicating logic from Blazor app, you should definitely create shared code both
Blazor
andHttpApi.Host
will use, otherwise you'll have to make changes in 2 places, asking for trouble.
- Hooray! We should be able to prerender some markup now. But if you run the app now, you'll get flooded by integrity errors and app won't hydrate. It's still far from over.
Before continuing we can do a simple trick that will tell us if we are looking at prerendered markup or at fully loaded WebAssembly app without devtools.
For HttpApi.Host
we can change WasmPrerenderedBrandingProvider.cs
to include message about loading
public override string AppName => "🔁 Wasm is loading ..";
While for Blazor
we can change WasmPrerenderedBrandingProvider.cs
to include message that it finished
public override string AppName => "✅ Wasm has loaded!";
- To fix integrity errors we need a package
Microsoft.AspNetCore.Components.WebAssembly.Server
on ourHttpApi.Host
project
dotnet add src/Acme.BookStore.WasmPrerendered.HttpApi.Host package Microsoft.AspNetCore.Components.WebAssembly.Server
this in turn lets us append new line in WasmPrerenderedHttpApiHostModule.cs
app.UseCorrelationId();
+ app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
- Next error I must admin I'm not sure why is happening
'Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic.App' could not be found in the assembly 'Volo.Abp.AspNetCore.Components.Web.BasicTheme'. This is likely a result of trimming (tree shaking).)
I could swear it's there, until someone figures what is happening we can copy this App.razor
to our Blazor
project, most people will probably override it anyway.
@using Microsoft.Extensions.Options
@using Volo.Abp.AspNetCore.Components.Web.Theming.Routing
@using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic
@inject IOptions<AbpRouterOptions> RouterOptions
<CascadingAuthenticationState>
<Router AppAssembly="RouterOptions.Value.AppAssembly"
AdditionalAssemblies="RouterOptions.Value.AdditionalAssemblies">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (!context.User.Identity.IsAuthenticated)
{
<RedirectToLogin />
}
else
{
<p>You are not authorized to access this resource.</p>
}
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
In HttpApi.Host/Pages/_Host.cshtml
replace the using to use our custom App.razor
- @using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic
+ @using Acme.BookStore.WasmPrerendered.Blazor
- Wasm is loading now! But hey there's still one last error to solve
In Blazor/WasmPrerenderedBlazorModule.cs
we can safely remove
- builder.RootComponents.Add<App>("#ApplicationContainer");
✅ In this state prerendering should be pretty stable with no errors. You can even authenticate! Try looking at static page source (ctrl + u).
Sadly this isn't end of the road, toolbars don't preprerender correctly, you can't manage your account and when you switch language, prerendered and client language will be different.
TODO: To be continued ...
Do you find this article useful? Would you like to see more like it? Coffee helps me stay awake at night to write for you. ❤
Comments
Halil İbrahim Kalkan 118 weeks ago
Thanks for sharing this, good explanation.
Enis Necipoğlu 118 weeks ago
Great article! ❤️🔥
Engincan Veske 118 weeks ago
Really helpful article, thanks.
Reza Heidari 117 weeks ago
Nice work :)