Micro Frontends in Action With ASP.NET Core - Universal Rendering With Blazor WebAssembly Based Web Components

In the last two posts of this series on implementing the Micro Frontends in Action samples in ASP.NET Core, I've focused on Blazor WebAssembly based Web Components as a way to achieve client-side composition. As a result, we have well-encapsulated frontend parts which can communicate with each other and the page. But there is a problem with the client-side rendered fragments, they appear after a delay. While the page loads, the user sees an empty placeholder. This is for sure a bad user experience, but it has even more serious consequences, those fragments may not be visible to search engine crawlers. In the case of something like a buy button, it is very important. So, how to deal with this problem? A possible answer is universal rendering.

What Is Universal Rendering?

Universal rendering is about combining server-side and client-side rendering in a way that enables having a single codebase for both purposes. The typical approach is to handle the initial HTML rendering on the server with help of the server-side composition and then, when the page is loaded in the browser, seamlessly rerender the fragments on the client side. The initial rendering should only generate the static markup, while the rerender brings the full functionality. When done properly, this allows for a fast First Contentful Paint while maintaining encapsulation.

The biggest challenge is usually the single codebase, which in this case means rendering Blazor WebAssembly based Web Components on the server.

Server-Side Rendering for Blazor WebAssembly Based Web Components

There is no standard approach to rendering Web Components on the server. Usually, that requires some creative solutions. But Blazor WebAssembly based Web Components are different because on the server they are Razor components and ASP.NET Core provides support for prerendering Razor components. This support comes in form of a Component Tag Helper. But, before we get to it, we need to modify the Checkout service so it can return the rendered HTML. This is where the choice of hosted deployment with ASP.NET Core will be beneficial. We can modify the hosting application to support Blazor WebAssembly and controllers with views.

...

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

var app = builder.Build();

...

app.UseBlazorFrameworkFiles();
app.UseStaticFiles();

app.UseRouting();

app.MapControllerRoute(
    name: "checkout-fragments",
    pattern: "fragment/buy/{sku}/{edition}",
    defaults: new { controller = "Fragments", action = "Buy" }
);

app.Run();

...

The controller for the defined route doesn't need any sophisticated logic, it only needs to pass the parameters to the view. For simplicity, I've decided to go with a dictionary as a model.

public class FragmentsController : Controller
{
    public IActionResult Buy(string sku, string edition)
    {
        IDictionary<string, string> model = new Dictionar<string, string>
        {
            { "Sku", sku },
            { "Edition", edition }
        };

        return View("Buy", model);
    }
}

The only remaining thing is the view which will be using the Component Tag Helper. In general, two pieces of information should be provided to this tag helper: the type of the component and the render mode. There are multiple render modes that render different markers to be used for later bootstrapping, but here we want to use the Static mode which renders only static HTML.

In addition to the component type and render mode, the Component Tag Helper also enables providing values for any component parameters with a param-{ParameterName} syntax. This is how we will pass the values from the model.

@using Demo.AspNetCore.MicroFrontendsInAction.Checkout.Frontend.Components
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@model IDictionary<string, string>

<component type="typeof(BuyButton)" render-mode="Static" param-Sku="@(Model["Sku"])" param-Edition="@(Model["Edition"])" />

If we start the Checkout service and use a browser to navigate to the controller route, we will see an exception complaining about the absence of IBroadcastChannelService. At runtime Razor components are classes and ASP.NET Core will need to satisfy the dependencies while creating an instance. Sadly there is no support for optional dependencies. The options are either a workaround based on injecting IServiceProvider or making sure that the needed dependency is registered. I believe the latter to be more elegant.

...

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddBroadcastChannel();
builder.Services.AddControllersWithViews();

var app = builder.Build();

...

After this change, navigating to the controller route will display HTML, but in the case of the BuyButton, it is not exactly what we want. The BuyButton component contains the markup for a popup which is displayed upon clicking the button. The issue is, that the popup is hidden only with CSS. This is fine for the Web Component scenario (where the styles are already loaded when the component is being rendered) but not desired for this one. This is why I've decided to put a condition around the popup markup.

...

<button type="button" @ref="_buttonElement" @onclick="OnButtonClick">
    buy for @(String.IsNullOrWhiteSpace(Sku) || String.IsNullOrWhiteSpace(Edition)  ? "???" : _prices[Sku][Edition])
</button>
@if (_confirmationVisible)
{
    <div class="confirmation confirmation-visible">
        ...
    </div>
}

...

Now the HTML returned by the controller contains only the button markup.

Combining Server-Side and Client-Side Rendering

The Checkout service is now able to provide static HTML representing the BuyButton fragment, based on a single codebase. In the case of micro frontends that's not everything that is needed for universal rendering. The static HTML needs to be composed into the page before it's served. In this series, I've explored a single server-side composition technique based on YARP Transforms and Server-Side Includes), so I've decided to reuse it. First, I've copied the code for the body transform from the previous sample. Then, I modified the routing in the proxy to transform the request coming to the Decide service. The same as previously, I've created a dedicated route for static content so it doesn't go through the transform unnecessarily.

...

var routes = new[]
{
    ...
    new RouteConfig {
        RouteId = Constants.ROOT_ROUTE_ID,
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Match = new RouteMatch { Path = "/" },
        Metadata = SsiTransformProvider.SsiEnabledMetadata
    },
    (new RouteConfig {
        RouteId = Constants.DECIDE_ROUTE_ID + "-static",
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Match = new RouteMatch { Path = Constants.DECIDE_ROUTE_PREFIX + "/static/{**catch-all}" }
    }).WithTransformPathRemovePrefix(Constants.DECIDE_ROUTE_PREFIX),
    (new RouteConfig {
        RouteId = Constants.DECIDE_ROUTE_ID,
        ClusterId = Constants.DECIDE_CLUSTER_ID,
        Match = new RouteMatch { Path = Constants.DECIDE_ROUTE_PREFIX + "/{**catch-all}" },
        Metadata = SsiTransformProvider.SsiEnabledMetadata
    }).WithTransformPathRemovePrefix(Constants.DECIDE_ROUTE_PREFIX),
    ...
};

...

builder.Services.AddReverseProxy()
    .LoadFromMemory(routes, clusters);

...

Now I could modify the markup returned by the Decide service by placing the SSI directives inside the tag representing the Custom Element.

<html>
  ...
  <body class="decide_layout">
    ...
    <div class="decide_details">
      <checkout-buy sku="porsche" edition="standard">
        <!--#include virtual="/checkout/fragment/buy/porsche/standard" -->
      </checkout-buy>
    </div>
    ...
  </body>
</html>

This way the proxy can inject the static HTML into the markup while serving the initial response and once the JavaScript for Web Components is loaded they will be rerendered. We have achieved universal rendering.

What About Progressive Enhancements?

You might have noticed that there is a problem hiding in this solution. It's deceiving the users. The page looks like it's fully loaded but it's not interactive. There is a delay (until the JavaScript is loaded) before clicking the BuyButton has any effect. This is where progressive enhancements come into play.

I will not go into this subject further here, but one possible approach could be wrapping the button inside a form when the Checkout service is rendering static HTML.

@using Demo.AspNetCore.MicroFrontendsInAction.Checkout.Frontend.Components
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@model IDictionary<string, string>

<form asp-controller="Checkout" asp-action="Buy" method="post">
    <input type="hidden" name="sku" valeu="@(Model["Sku"])">
    <input type="hidden" name="edition" valeu="@(Model["Edition"])">
    <component type="typeof(BuyButton)" render-mode="Static" param-Sku="@(Model["Sku"])" param-Edition="@(Model["Edition"])" />
</form>

Of course, that's not all the needed changes. The button would have to be rendered with submit type and the Checkout service needs to handle the POST request, redirect back to the product page, and manage the cart in the background.

If you are interested in doing that exercise, the sample code with universal rendering that you can use as a starter is available on GitHub.