Web components are some of the most exciting technology I’ve seen in a long time. They promise to revolutionize how we write, maintain, and ship HTML-based applications. With web components, you can extend the set of HTML tags specific to your site while still providing the functionality you intended with less markup.

And then there’s Web Assembly, or Wasm for short, a technology that opens up a world of possibilities. It enables all ecosystems to package functionality in a reusable format that can be deployed across a wide range of platforms.

Could we combine them to provide ASP.NET Core with brand-new server-side rendering functionality while avoiding the client-side costs of web components as they attach to the DOM? Sure we can! With Enhance Wasm.

What is Enhance WASM?

Enhance WASM is an open-source initiative to bring the features of Enhance, a dynamic web apps framework, to the server for all technology stacks through Wasm. As mentioned in the opening, web components are significant but take some time to attach to an active web page. This can be less than ideal. If we can render some of the web component’s HTML in advance, we can provide users with a better and faster experience while the page catches up.

Web components are a standard way of building components for HTML apps without libraries like React, Angular, Vue, or Svelte. They are also very lightweight, typically not needing much, if any, build steps. The Enhance project has guiding tenants that make it appealing for folks suffering from SPA fatigue:

  • Author and deliver HTML Pages
  • Use Web Standards
  • Progressively enhance working HTML

ASP.NET Core and Razor are perfect fits for this philosophy as they are mature and reliable technologies. Let’s see how to get these technologies working together.

Getting Started

You’ll first need to download the latest version of Enhance WASM from its GitHub page. Then, place the enhance-ssr.wasm file as Content in an ASP.NET Core application and set it to always copy to your build output directory.

Next, install the following packages.

<ItemGroup>
  <PackageReference Include="Extism.runtime.all" Version="1.2.0" />
  <PackageReference Include="Extism.Sdk" Version="1.2.0" />
</ItemGroup>

The SDK package is a wrapper for the runtime packages. Both are required to make the following code work.

Now we’re ready to write a wrapper for Enhance Wasm.

Running Enhance WASM in .NET

This part is pretty straightforward. We need to load the enhance-ssr.wasm file into memory, then load it as an Extism plugin.

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Extism.Sdk;

namespace EnhanceWebComponents.Services;

public class EnhanceServerSideRenderer(Dictionary<string, string> webComponentElements)
{
    private static readonly byte[] Wasm =
        File.ReadAllBytes("enhance-ssr.wasm");

    private readonly Plugin plugin = new(Wasm, [], withWasi: true);

    private static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    public EnhanceResult Process(EnhanceInput input)
    {
        var value = new EnhanceInputWithComponents(input.Markup, webComponentElements, input.InitialState);
        var json = JsonSerializer.Serialize(value, Options);
        var result = plugin.Call("ssr", json);
        
        return result is null
            ? throw new Exception("unable to process web component")
            : JsonSerializer.Deserialize<EnhanceResult>(result, Options)!;
    }
}

public record EnhanceInput(
    string Markup,
    object? InitialState = null
);

internal record EnhanceInputWithComponents(
    string Markup,
    Dictionary<string, string> Elements,
    object? InitialState
);

public record EnhanceResult(
    string Document,
    string Body,
    string Styles
);

Now, we can call this code with web component definitions and elements to process.

using EnhanceWebComponents.Services;
using Xunit.Abstractions;

namespace EnhanceWebComponents.Tests;

public class EnhanceServerSideRendererTests(ITestOutputHelper output)
{
    private EnhanceServerSideRenderer sut = new(
        webComponentElements: new()
        {
            {
                "my-header",
                // lang=javascript
                """
                function MyHeader({ html }) 
                {
                    return html`<style>h1{color:red;}</style><h1><slot></slot></h1>` 
                }
                """
            },
            {
                "my-component-state",
                // lang=javascript
                """
                function MyComponentState({ html, state }) {
                  const { store } = state
                  return html`<span>${ store?.name }</span>`
                }
                """
            }
        }
    );

    [Fact]
    public void Can_process_web_component()
    {
        var input = new EnhanceInput(
            "<my-header>Hello World</my-header>"
        );

        var result = sut.Process(input);
        
        output.WriteLine(result.Body);

        Assert.NotNull(result);
        Assert.Equal("""<my-header enhanced="✨"><h1>Hello World</h1></my-header>""", result.Body);
        Assert.Equal("my-header h1 {\n  color: red;\n}", result.Styles);
    }
    
    [Fact]
    public void Can_process_web_component_with_state()
    {
        var input = new EnhanceInput(
            "<my-component-state></my-component-state>",
            // accessed via state.store.name in JavaScript
            new { name = "Khalid" }
        );

        var result = sut.Process(input);
        
        output.WriteLine(result.Body);

        Assert.NotNull(result);
        Assert.Equal("""<my-component-state enhanced="✨"><span>Khalid</span></my-component-state>""", result.Body);
    }
}

Wooooooooah! It works! Well, it’s nice but it could be nicer. Let’s make a tag helper for ASP.NET Core.

ASP.NET Core Enhance Wasm Tag Helper

ASP.NET Core has tag helpers that should make this even more awesome, so let’s do that!

We’ll create two classes: EnhanceTagHelper and EnhanceRequestContext.

using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace EnhanceWebComponents.Services;

[HtmlTargetElement(Attributes = EnhanceSsrAttribute)]
public class EnhanceTagHelper(EnhanceServerSideRenderer enhanceServerSideRenderer, EnhanceRequestContext enhanceCtx) : TagHelper
{
    private const string EnhanceSsrAttribute = "enhance-ssr"; 
    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        output.Attributes.Clear();
        foreach (var attribute in context.AllAttributes)
        {
            if (attribute.Name == "enhance-ssr")
                continue;
            
            output.Attributes.Add(attribute);
        }
        output.Content = await output.GetChildContentAsync();

        var sb = new StringBuilder();
        await using var stringWriter = new StringWriter(sb);
        output.WriteTo(stringWriter, HtmlEncoder.Default);

        var input = new EnhanceInput(sb.ToString());
        
        var result = enhanceServerSideRenderer.Process(input);
        // remove outer-wrapper
        output.TagName = "";
        output.Content.SetHtmlContent(result.Body);
        
        // any scoped css goes into the current context
        enhanceCtx.Add(result);
    }
}

You notice the type EnhanceRequestContext; this request-scoped instance allows us to push processed CSS into memory while all the components are being processed.

using System.Collections.Concurrent;
using System.Text;
using Microsoft.AspNetCore.Html;

namespace EnhanceWebComponents.Services;

public class EnhanceRequestContext
{
    private ConcurrentBag<EnhanceResult> Results { get; }
        = new();

    public void Add(EnhanceResult result) =>
        Results.Add(result);
    
    public IHtmlContent Styles()
    {
        return new HtmlString(
            // lang=html
            $"""
             <style enhanced="✨">
                {GetAllStyles()}
             </style>
             """
        );
    }
    
    private string GetAllStyles()
    {
        var sb = new StringBuilder();
        // we only want the unique styles
        foreach (var result in Results.DistinctBy(x => x.Styles)) 
            sb.AppendLine(result.Styles);
        return sb.ToString();
    }
}

Let’s register these types in Program.cs in our services collection.

builder.Services.AddSingleton(new EnhanceServerSideRenderer(
    // Note: pull these definitions from somewhere else.
    // you could probably read these from a folder in `wwwroot/js` 
    // and register them by convention `name of file` and `contents`.
    webComponentElements: new()
    {
        {
            "my-header",
            // lang=javascript
            """
            function MyHeader({ html }) 
            {
                return html`<style>h1{color:purple;}</style><h1><slot></slot></h1>` 
            }
            """
        }
    }
));

// a "per request" entity to store results so you 
// can then spit out the scoped CSS styles where you need them
builder.Services.AddScoped<EnhanceRequestContext>();

We must also inform Razor about our new tag helper in _ViewImports.cshtml.

@using EnhanceWebComponents
@namespace EnhanceWebComponents.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, EnhanceWebComponents

We also update our _Layout.cshtml file to use the EnhanceRequestContext to render the CSS. Since the _Layout.cshtml is processed last, we know all web components will have already been processed in our views.

@using EnhanceWebComponents.Services
@inject EnhanceRequestContext Enhance

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>@ViewData["Title"] - EnhanceWebComponents</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/>
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
    <link rel="stylesheet" href="~/EnhanceWebComponents.styles.css" asp-append-version="true"/>
    @Enhance.Styles()
</head>

Note: if you want to process web components in the _Layout.cshtml file, they will work, but you need to get any scoped CSS into the collection. This can be done using middleware or some other pre-layout processing. This is an edge case, and I thought it was overkill for a proof of concept.

Finally, we can use the tag helper and our server-side rendered web components in any Razor view.

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    <my-header class="display-4" enhance-ssr>Hello World</my-header>
    <my-header class="display-3" enhance-ssr>Hello Again</my-header>
    <p>Learn about <a href="https://learn.microsoft.com/aspnet/core">
        building Web apps with ASP.NET Core
    </a>.</p>
</div>

With the resulting HTML being what we expected.

<div class="text-center">
    <my-header class="display-4" enhanced="✨"><h1>Hello World</h1></my-header>
    <my-header class="display-3" enhanced="✨"><h1>Hello Again</h1></my-header>
    <p>Learn about <a href="https://learn.microsoft.com/aspnet/core">
        building Web apps with ASP.NET Core
    </a>.</p>
</div>

along with the aggregated style element.

<style enhanced="✨">
my-header h1 {
  color: purple;
}
</style>

Awesome!

Conclusion

If you’re interested in trying this, I’ve made the * *code available on GitHub**. I haven’t thought out some edge cases, like the _Layout.cshtml processing, and what to do with attributes in Razor vs. the ones on web components. That said, there’s a lot of potential here for providing an experience never before seen for ASP.NET Core developers.

Thanks for reading and sharing this post with friends and colleagues. Cheers.