blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Prerendering a Blazor WebAssembly app to static files, without an ASP.NET Core host app

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.

Preloading with WebAssembly and a host app

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:

Image of what we're prerendering

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.

Image of the final result

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 [email protected]!" message) then the prerendered HTML won't match what the Blazor app will show after it boots up.

Image showing the page changing after bootup

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:

The route parameter changes the page that renders

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:

We need to prerender an HTML file for every possible value of {name}

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.

Our test app calls each of the URLs in the WebAssembly app. The host app prerenders the page, and we save the content to an HTML file

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 fetch data path fails

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 fetch data path still fails

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:

The tests pass now

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:

The output after prerendering is complete

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:

Viewing the prerendered application

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.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?