In this post I describe how you can prerender all the pages in a Blazor WebAssembly app, without requiring a host app. That means you can still host your Blazor WebAssembly as static files using GitHub Pages or Netlify, without requiring a backend server.
WebAssembly Prerendering recap
In the previous post, I described how to add prerendering to a Blazor WebAssembly app by hosting it in an ASP.NET Core web app. The host app prerenders the Blazor WebAssembly app to HTML using a Razor Page, and sends the HTML in response to the first request from the server.
On the client-side, the prerendered HTML is displayed to the user while the WebAssembly app downloads in the background. Once the app has downloaded and started up, it takes over the rendering of the page, giving a purely client-side experience.
For many people, the fact that prerendering requires a server host app won't be a big deal. Probably most Blazor WebAssembly apps will need to talk to an ASP.NET Core Web API backend application, so you already have to run a server application. Adding static file hosting in this case isn't a big deal.
But what if you're creating a Blazor WebAssembly app that doesn't need an ASP.NET Core back-end? Maybe your app is only interacting with third-party APIs. Maybe it uses serverless APIs only. Or maybe it doesn't need any APIs at all! In these cases, requiring a host app just to add prerendering is a bit of a drag.
In this post, I show how to prerender your Blazor WebAssembly application without requiring a host app. Your Blazor WebAssembly app can go back to static-file hosting! This won't work for every use case, but if it fits for you, it can make deployments (among other things) far easier.
The goal: prerender every page in the application
In order to achieve prerendering without a host app, we need to prerender every logical page in our WebAssembly application. I'm calling this approach static prerendering, as it prerenders your application to static files.
For the default Blazor WebAssembly app I created in the previous post, that means prerendering the Index
, Counter
and FetchData
components:
Our end goal is to have a separate prerendered HTML page for each of these pages. This will allow us to upload the app to a static-file hosting site, such as GitHub Pages or Netlify.
This approach, while great on the deployment side, has some limitations.
The limitations of this approach: cases to watch out for
Before we go any further, I want to address some of the problems and limitations of the final result we're aiming for. This approach is not a panacea. There are many cases for which this approach is not a good fit.
Pages displaying highly dynamic data
If you have pages that display rapidly changing data, such as the random FetchData
component, then it won't necessarily make sense to be prerendering your pages way in advance.
When using a host app for prerendering, the prerendering is happening a matter of seconds (at most) before the page is served to clients. However, with our static file approach, we're prerendering pages way in advance.
If your data quickly gets "old", then prerendering may actually give a worse experience than not prerendering at all. Displaying incorrect data (from the prerender, potentially days earlier), only to change it once the WebAssembly app boots up, could be a very bad move. In that case either don't use prerendering, don't prerender the problematic pages, or use a host app for prerendering.
Authenticated or personalised pages
In a similar vein, apps that you authenticate to can cause a problem for static prerendering. With static prerendering, you have a single prerendered HTML file for each page in your application. But if you can authenticate with your app, and that changes the HTML shown on the page (for example showing a "Hi test@test.com!"
message) then the prerendered HTML won't match what the Blazor app will show after it boots up.
As with other dynamic data, you could work around this, but fundamentally the prerendered HTML is shared by all users, so you will run into issues where the prerendered HTML doesn't match the HTML generated by the app after boot up.
Route parameters
One good thing about using a host ASP.NET Core application for prerendering, is that you don't have to worry about the design of the WebAssembly app for the most part (Dependency Injection issues aside). The host application "passes-on" the incoming request URL to the WebAssembly app when prerendering—as long as the WebAssembly app can render something for the URL, we'll be able to prerender it. That means you can use dynamic route parameters in your Blazor WebAssembly pages, and they'll be handled without issue.
For example if you have a Razor Component defined like this:
@page "/Product/{name}"
<h1>The Product is @Name!</h1>
@code {
[Parameter]
public string Name { get; set; }
}
Then the Name
property will be dynamically populated based on the URL:
When prerendering with a host app, route parameters cause no problem. The URL is available at prerender time, and dynamic text can be generated.
For static prerendering, we have to prerender all pages ahead of time. That means we need to know every value that {name}
can take so we can prerender an HTML file for each of them:
In some cases, the possible values of {name}
may be known in advance. For example, you may be able to iterate (and prerender) all of the products in a product catalogue ahead of time. However, if these values aren't known, or the number of options is too great, then static prerendering may not be a good solution.
Now I've dampened everyone's spirits it's time to look at how to actually make this work!
Statically prerendering a Blazor WebAssembly app
At it's heart, the concept behind static prerendering is very simple:
For every routable page in your WebAssembly application, render the app, and save the output as an HTML file.
To achieve this, I'll use two things:
- A host ASP.NET Core application for prerendering, exactly as I configured in my previous post.
- A way to render a given page and save the output. For simplicity, I used the in-memory
TestHost
facilities, typically used for integration testing.
The host app that does the prerendering is exactly the same application as I configured in my previous post. All I'm doing is adding a way of "capturing" the prerendered output from the host app. I used a test app for convenience (as I wanted to run actual integration tests on the output too), but you could easily use a simple console app instead.
In the next section I'll describe how to create the test project that captures the output.
Creating a project to statically prerender
We start by creating an xUnit test project called PreRenderer
, adding the integration testing package, and adding a reference to our host ASP.NET Core app, Blazor1.Server
:
dotnet new xunit -n PreRenderer
dotnet add PreRenderer package Microsoft.AspNetCore.Mvc.Testing
dotnet add PreRenderer reference BlazorApp1.Server
Next, we'll create a simple WebApplicationFactory
that will enable us to run the host ASP.NET Core app in-memory using TestHost
.
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Net.Http;
using Xunit.Abstractions;
public class AppTestFixture : WebApplicationFactory<BlazorApp1.Server.Program>
{
protected override IHostBuilder CreateHostBuilder()
{
var builder = base.CreateHostBuilder();
builder.UseEnvironment(Environments.Production);
return builder;
}
}
This test fixture is very basic currently, we're reusing the server app's IHostBuilder
and making a single modification—we're updating the IHostingEnvironment
to be Production
. That's because we want to render our production data here!
Note that this would mean that whatever is running these tests and generating the prerendered output would need access (and credentials) for production assets. That's another thing to think about when deciding whether static prerendering is right for you!
Now we can create the code that generates the prerendered HTML documents. I opted to use an XUnit theory test, so that each execution of the test renders a separate page.
public class GenerateOutput : IClassFixture<AppTestFixture>
{
private readonly AppTestFixture _fixture;
private readonly HttpClient _client;
private readonly string _outputPath;
public GenerateOutput(AppTestFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateDefaultClient();
var config = _fixture.Services.GetRequiredService<IConfiguration>();
_outputPath = config["RenderOutputDirectory"];
}
[Theory, Trait("Category", "PreRender")]
[InlineData("/")]
[InlineData("/counter")]
[InlineData("/fetchdata")]
public async Task Render(string route)
{
// strip the initial / off
var renderPath = route.Substring(1);
// create the output directory
var relativePath = Path.Combine(_outputPath, renderPath);
var outputDirectory = Path.GetFullPath(relativePath);
Directory.CreateDirectory(outputDirectory);
// Build the output file path
var filePath = Path.Combine(outputDirectory, "index.html");
// Call the prerendering API, and write the contents to the file
var result = await _client.GetStreamAsync(route);
using (var file = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await result.CopyToAsync(file);
}
}
}
Most of this method is building the destination file path to write our prerendered content:
- The path for the page (
/
,/counter
, or/fetchdata
) is injected as a parameter into the method. - We use a configuration value set in the host app's appsettings.json to decide where to create the files. We can control this at runtime, as I'll show shortly.
- We make a request to the in-memory
TestHost
version of the host app to prerender the WebAssembly page. - Finally, we write the stream body to the output file.
You can run the test in Visual Studio/Rider using the normal test runner, if you add a RenderOutputDirectory
key to your host app's appsettings.json file:
{
"AllowedHosts": "*",
"RenderOutputDirectory": "RenderOutput"
}
This will write the content to a sub-folder called RenderOutput in the test project's bin
directory (e.g. ./PreRenderer/bin/Debug/net5.0/RenderOutput
). You can also use an absolute path if you wish.
Unfortunately, if you execute this test as-is, the /fetchdata
path will fail:
The problem is the hacky fix for HttpClient
I put in place in the previous post. Well, one good hack deserves another…
Handling the missing HttpClient
dependency
If you remember back to the previous post, our WebAssembly app uses an HttpClient
instance to asynchronously load data from the server.
In the non-hosted version of the default Blazor WebAssembly template, this loads the data from a static JSON file.
To workaround this, in the previous post I configured an HttpClient
in the host app that looped back to itself, making an outgoing network call to itself:
// register an HttpClient that points to itself
services.AddSingleton<HttpClient>(sp =>
{
// Get the address that the app is currently running at
var server = sp.GetRequiredService<IServer>();
var addressFeature = server.Features.Get<IServerAddressesFeature>();
string baseAddress = addressFeature.Addresses.First();
return new HttpClient { BaseAddress = new Uri(baseAddress) };
});
Unfortunately, when we're running using the TestHost
, this code won't work. The TestHost
doesn't actually expose an HTTP endpoint, it runs in memory, so in this case, addressFeature
will be null
. The easy hacky fix for this is to replace the HttpClient
dependency used by the server app with one that points to the TestHost
itself directly. Update to the test fixture to the following:
public class AppTestFixture : WebApplicationFactory<BlazorApp1.Server.Program>
{
protected override IHostBuilder CreateHostBuilder()
{
var builder = base.CreateHostBuilder();
builder.UseEnvironment(Environments.Production);
// Replace the HttpClient dependency with a new one
builder.ConfigureWebHost(
webHostBuilder => webHostBuilder.ConfigureTestServices(services =>
{
services.Remove(new ServiceDescriptor(typeof(HttpClient), typeof(HttpClient), ServiceLifetime.Singleton));
services.AddSingleton(_ => CreateDefaultClient());
})
);
return builder;
}
}
Unfortunately, the /fetchdata
path still fails…
Serving static assets in the TestHost
The /fetchdata
path fails this time because the request for /sample-data/weather-forecast.json
returns a 404 response …
The problem is that the StaticFileMiddleware
isn't loading the StaticWebAssets
from the WebAssembly app correctly. This post is long enough, so I'm not going to go into the reasons why here, but you can resolve the issue by adding UseStaticWebAssets()
in the ConfigureWebHost
method:
builder.ConfigureWebHost(
webHostBuilder =>
{
webHostBuilder.UseStaticWebAssets(); // Add this line
webHostBuilder.ConfigureTestServices(services =>
{
services.Remove(new ServiceDescriptor(typeof(HttpClient), typeof(HttpClient), ServiceLifetime.Singleton));
services.AddSingleton(_ => CreateDefaultClient());
});
});
With this addition, the tests all pass, and the output is rendered for all our routes:
Generating the WebAssembly app output
So we're (nearly) there now. We have a way of generating prerendered output so lets publish the WebAssembly app, and use the prerendered output. The following bash script can be run from the solution directory to publish the output:
# store the solution directory as a variable
SLN_DIR="$(pwd)"
# Build and publish the WebAssembly app
dotnet publish -c Release -o "output" BlazorApp1
# Set the RenderOutputDirectory environment variable
# and run the Prerender test to generate the output
RenderOutputDirectory="${SLN_DIR}/output/wwwroot" \
dotnet test -c Release --filter Category=PreRender
or, if you prefer PowerShell (and are interactively executing the commands)
# publish the app
dotnet publish -c Release -o output BlazorApp1
# Set the render output (use $PSScriptRoot instead of $pwd inside of *.ps1 script files)
$env:RenderOutputDirectory="$pwd/output/wwwroot"
# Generate the output
dotnet test -c Release --filter Category=PreRender
After running these, you'll have an output that looks something like the following:
You can now deploy these files to static file hosting. For testing, I used the dotnet-serve
global tool to serve the contents of the directory, and to confirm the prerendered files are served correctly:
Success!
What's next?
So there's obviously quite a few issues and limitations here. I highlighted a few of them earlier, and we ran into the issue with StaticWebAssets
earlier, but there's another annoying issue I glossed over: we're currently listing out all the WebAssembly page routes that need to be rendered in the test project as Theory data:
[InlineData("/")]
[InlineData("/counter")]
[InlineData("/fetchdata")]
In the next post, I'll look at improving that aspect, so you don't have to keep track of the possible routes in your test project.
Summary
Prerendering can give an improved experience for users, as meaningful HTML is returned in the initial response. With Blazor WebAssembly, that typically means you must host your application inside an ASP.NET Core app which is responsible for performing the prerendering. Unfortunately, that means you can no longer host your app as static files.
In this post, I showed an approach to "statically prerender" all the pages in your application to HTML files ahead of time. This allows you to both use prerendering and to host your WebAssembly app in static file hosting.
This approach has some trade-offs. Most notably, you must be able to define all the possible routes in your application ahead of time, and these should not change depending on the user, or other dynamic data.