.NET 8's Best Blazor is not Blazor as we know it

.NET 8's Best Blazor is not Blazor as we know it Background
17 min read

The best way to find out what's new in .NET 8 Blazor is to watch the excellent Full stack web UI with Blazor in .NET 8 presentation by Daniel Roth and Steve Sanderson, which covers how Blazor has become a Full Stack UI Web Technology for developing any kind of .NET Web App.

Your first .NET 8 Blazor App

You don't get to appreciate what this means until you create your first .NET 8 Blazor App where you'll be pleasantly surprised that Blazor Apps render fast, clean HTML without needing to load large Web Assembly assets needed for Blazor WebAssembly Apps or starting a stateful Web Socket connection required for Blazor Server Interactive Apps.

This is because the default rendering mode for Blazor uses neither of these technologies, instead it returns to traditional Web App development where Blazor Pages now return clean, glorious HTML - courtesy of Blazor's Static render mode.

Choose your compromise

Previously we were forced to choose upfront whether we wanted to build a Blazor Web Assembly App or a Blazor Server App and the compromises that came with them, which for public Internet Web Apps wasn't even a choice as Blazor Server Apps perform poorly over high latency Internet connections.

This meant choosing Blazor Web Assembly Apps which required downloading a large Web Assembly runtime with users experiencing a long delay before the App was functional. To minimize this impact our Blazor WebAssembly Tailwind template included built-in prerendering where as part of deployment would generate static .html pages that were deployed with the Blazor Web Assembly front-end UI that can be hosted on CDN edge networks to further improve load times.

Whilst this meant the App's UI would be rendered immediately, it still wouldn't be functional until the Web Assembly runtime was downloaded and initialized, which would flicker as the static UI was replaced with Blazor's WASM rendered UI, then later Authenticated users would experience further delay and UI jank whilst the App signs in the Authenticated User. Whilst prerendering is an improvement over Blazor WASM's default blank loading screen, it's still not ideal for public facing Web Apps.

.NET 8 Blazor is a Game Changer

The situation has greatly improved in .NET 8 where your entire App no longer needs to be bound to a single Interactivity mode. Even better, Blazor's default static rendering mode results in the best UX where the Website Layout and important landing pages can be rendered instantly.

Interactive only when you need it

Only pages that need Blazor's interactivity features can opt-in to whichever Blazor interactive rendering mode makes the most sense, either on a page-by-page or component basis, or by choosing RenderMode.InteractiveAuto which uses InteractiveWebAssembly if the WASM runtime is loaded or InteractiveServer if it isn't.

Enhanced Navigation FTW

Ultimately I expect Blazor's new Enhanced Navigation is likely the feature that will deliver the biggest UX improvement users will experience given it's enabled by default and gives traditional statically rendered Web Apps instant SPA-like navigation responsiveness where new pages are swapped in without needing to perform expensive full page reloads.

It's beauty lies in being able to do this as a mostly transparent detail without the traditional SPA complexity of needing to manage complex state or client-side routing. It's a smart implementation that's able to perform fine-grained DOM updates to only parts of pages that have changed, providing the ultimate UX of preserving page state, like populated form fields and scroll position, to deliver a fast and responsive UX that previously wasn't attainable from the simplicity of a Server Rendered App.

Its implementation does pose some challenges in implementing certain features, but we'll cover some approaches below we've used to overcome them below.

Full Stack Web UI

Blazor's static rendering with enhanced navigation and its opt-in flexibility makes .NET 8 Blazor a game changer, expanding it from a very niche set of use-cases that weren't too adversely affected by its Interactivity mode downsides, to becoming a viable solution for developing any kind of .NET Web App, especially as it can also be utilized within existing ASP.NET MVC and Razor Pages Apps.

Benefits over MVC and Razor Pages

In addition, Blazor's superior component model allows building better encapsulated, more reusable and easier-to-use UI components which has enabled Blazor's rich 3rd Party library ecosystem to flourish, that we ourselves utilize to develop the high productivity Tailwind Components in the ServiceStack.Blazor component library.

So far there's only upsides for .NET Web App development, the compromises only kick in when you need Blazor's interactivity features, luckily these can now be scoped to just the Pages and Components that need them. But how often do we need them?

When do you need Blazor's Interactivity features?

It ultimately depends on what App your building, but a lot of Websites can happily display dynamic content, navigate quickly with enhanced navigation, fill out and submit forms - all in Blazor's default static rendering mode.

Not even advanced features like Streaming Rendering used in Blazor Template's Weather.razor page require interactivity, as its progressive rendered UI updates are achieved in a single request without interactivity.

In fact the only time @rendermode InteractiveServer is needed in the default Blazor template is in the Counter.razor page whose C# Event Handling require it.

Ultimately some form of Interactivity is going to be required in order to add behavior or client-side functionality that runs after pages have been rendered, but you still have some options left before being forced to opt into an Interactive Blazor solution.

Interactive Feature Options

We can see some of these options utilized in the Blazor Template NavMenu.razor component which uses JavaScript onclick event handlers to add client-side behavior to simulate mouse clicks to toggle UI elements:

<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">

and submitting forms to Logout users:

<LogoutForm id="logout-form" />
<NavLink class="nav-link" onclick="document.getElementById('logout-form').submit(); return false;">
    <span class="bi bi-arrow-bar-left" aria-hidden="true"></span> Logout
</NavLink>

Effectively adding interactivity to Blazor static rendered pages with client-side JavaScript to avoid paying Blazor's Interactivity tax.

Avoid using Interactivity in Layouts

This is especially important for any features you want to add to the Websites Layout or Chrome UI which you'll always want to be statically rendered so landing pages can load fast and render SEO-friendly server rendered content.

This meant we couldn't use ServiceStack.Blazor's existing DarkModeToggle.razor component for toggling on/off DarkMode since its statically rendered inside the Websites Layout and requires Interactivity to work.

Vanilla JS Blazor Components

Fortunately utilizing simple element JavaScript callbacks was enough to be able to re-implement its functionality with Vanilla JS in the new DarkModeToggleLite.razor component which works in all Blazor rendering modes, in both full-page or enhanced navigation loads:

<button type="button" onclick="toggleDarkMode()" class=@ClassNames(DarkModeToggle.ButtonClasses, Class) role="switch" aria-checked="false" @attributes="AdditionalAttributes">
    <span class="@DarkModeToggle.InnerClasses" data-class-light="translate-x-5" data-class-dark="translate-x-0">
        <span class="@DarkModeToggle.IconClasses" data-class-light="opacity-0 ease-out duration-100" data-class-dark="opacity-100 ease-in duration-200" aria-hidden="true">
            <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-400" preserveAspectRatio="xMidYMid meet" viewBox="0 0 32 32"><path fill="currentColor" d="M13.502 5.414a15.075 15.075 0 0 0 11.594 18.194a11.113 11.113 0 0 1-7.975 3.39c-.138 0-.278.005-.418 0a11.094 11.094 0 0 1-3.2-21.584M14.98 3a1.002 1.002 0 0 0-.175.016a13.096 13.096 0 0 0 1.825 25.981c.164.006.328 0 .49 0a13.072 13.072 0 0 0 10.703-5.555a1.01 1.01 0 0 0-.783-1.565A13.08 13.08 0 0 1 15.89 4.38A1.015 1.015 0 0 0 14.98 3Z" /></svg>
        </span>
        <span class="@DarkModeToggle.IconClasses" data-class-light="opacity-100 ease-in duration-200" data-class-dark="opacity-0 ease-out duration-100" aria-hidden="true">
            <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-600" preserveAspectRatio="xMidYMid meet" viewBox="0 0 32 32"><path fill="currentColor" d="M16 12.005a4 4 0 1 1-4 4a4.005 4.005 0 0 1 4-4m0-2a6 6 0 1 0 6 6a6 6 0 0 0-6-6ZM5.394 6.813L6.81 5.399l3.505 3.506L8.9 10.319zM2 15.005h5v2H2zm3.394 10.193L8.9 21.692l1.414 1.414l-3.505 3.506zM15 25.005h2v5h-2zm6.687-1.9l1.414-1.414l3.506 3.506l-1.414 1.414zm3.313-8.1h5v2h-5zm-3.313-6.101l3.506-3.506l1.414 1.414l-3.506 3.506zM15 2.005h2v5h-2z" /></svg>
        </span>
    </span>
</button>

<script>
window.toggleDarkMode = (function() {
    let isDark = localStorage.getItem('color-scheme') === 'dark'
    const html = document.documentElement
    function renderDarkMode() {
        html.style.setProperty('color-scheme', isDark ? 'dark' : null)
        html.classList.toggle('dark', isDark)
        document.querySelectorAll('[data-class-light]').forEach(el => {
            const removeClasses = isDark
                    ? el.dataset.classLight
                    : el.dataset.classDark
            const addClasses = isDark
                    ? el.dataset.classDark
                    : el.dataset.classLight

            removeClasses.split(' ').forEach(c => el.classList.remove(c))
            addClasses.split(' ').forEach(c => el.classList.add(c))
        })
    }
    renderDarkMode()

    document.addEventListener('DOMContentLoaded', () =>
            Blazor.addEventListener('enhancedload', () => {
                isDark = localStorage.getItem('color-scheme') === 'dark'
                html.classList.toggle('dark', isDark)
                renderDarkMode()
            }))

    return function() {
        isDark = !isDark
        localStorage.setItem('color-scheme', isDark ? 'dark' : 'light')
        renderDarkMode()
    }
})()
</script>

To support enhanced navigation you'll need to be aware that <script> tags are only executed once on initial page load. You'll instead need to register a callback with Blazor's enhancedload event for any startup logic that needs re-executing, which is fired after Blazor merges the new page's DOM with the existing DOM, and is where the <DarkModeToggleLite> component re-renders itself with the correct state.

When using callbacks to invoke global functions like this it's recommended to wrap them in an IIFE for better encapsulation of internal component state and functionality to avoid polluting the global namespace.

Try it out!

With that it's ready for action, try it out in a new blazor Project or from its Live Demo by toggling on/off Dark Mode Component in the top right corner:

Declarative JavaScript Modules

Unfortunately a lot of other approaches won't work with Blazor's Enhanced Navigation, for example whilst the built-in ASP.NET Identity Pages all work without Blazor's Interactivity, the EnableAuthenticator.razor page doesn't actually include a solution for providing a visual QR Code barcode which mobile phones can easily scan to setup 2FA Authentication.

Whilst the placeholders are there, that implementation detail is left to us to workout how we best want to implement it within our Apps, perhaps because they don't want to force an Interactivity rendering mode in the default template.

To avoid a degraded UX with Blazor Interactivity you'll naturally want to implement this with JavaScript using the popular qrcodejs library by following its instructions and adding a simple inline script to the page:

<div data-permanent id="qrCode"></div>
<div id="qrCodeData" data-url="@_authenticatorUri"></div>

<script src="lib/js/qrcode.min.js"></script>
<script>
new QRCode(document.getElementById('qrCode'), 
    document.getElementById('qrCodeData').dataset.url)
</script>

Whilst this works as expected in full page reloads, it doesn't work in Blazor's Enhanced Navigation as the <script> tag is only executed once on initial page load and not re-executed when the page is loaded with enhanced navigation.

Your options are to change all links to that page with data-enhance-nav="false" to turn off enhanced navigation to that page, or we need to find another way.

The solution that worked best for us is to use declarative instructions to specify which JavaScript modules should be loaded for any page, which we can do by adding a data-module attribute to any element, e.g:

<div data-module="pages/Account/Manage/EnableAuthenticator.mjs">

These instructions are then handled by app.mjs on each navigation which loads the specified JavaScript module and calls its load() function if it exists:

export async function remount() {
    document.querySelectorAll('[data-module]').forEach(async el => {
        let modulePath = el.dataset.module
        if (!modulePath) return
        if (!modulePath.startsWith('/') && !modulePath.startsWith('.')) {
            modulePath = `../${modulePath}`
        }
        try {
            const module = await import(modulePath)
            if (typeof module.default?.load == 'function') {
                module.default.load()
            }
        } catch (e) {
            console.error(`Couldn't load module ${el.dataset.module}`, e)
        }
    })
}

document.addEventListener('DOMContentLoaded', () =>
    Blazor.addEventListener('enhancedload', remount))

Which for EnableAuthenticator.razor page loads the EnableAuthenticator.mjs JavaScript Module which dynamically loads the qrcode.min.js library and initializes the QR Code on its exported load() function:

import { addScript, $1 } from "@servicestack/client"
const loadJs = addScript('lib/js/qrcode.min.js')

export default {
    async load() {
        await loadJs
        new QRCode($1("#qrCode"), $1('#qrCodeData').dataset.url)
    }
}

Which now works as expected in both full page reloads and Blazor's Enhanced Navigation:

Blazor without Blazor Interactivity

So right now we have a Blazor App that's predominantly statically rendered, with fast and SEO-friendly without any downsides of the Blazor's Interactivity options, but how much of our App's functionality can we implement without Blazor Interactivity?

What doesn't work with Enhanced Navigation

As of now we've managed to implement most of the required functionality with Vanilla JS, however for any moderately complex UI you'll likely want to use one of the popular JavaScript UI libraries, of which we believe Vue.js is the best library for progressively enhancing statically rendered content which offers the best balance of features, performance and size.

The problem being that the natural way to use Vue.js to progressively enhance HTML content doesn't work with Blazor's Enhanced Navigation.

E.g the natural way to implement Blazor's Counter.razor page in Vue is to implement its UI in HTML and use JavaScript to mount the component with the element:

<div id="content">
    <p class="my-4">Current count: {‎{currentCount}‎}</p>

    <primary-button v-on:click="incrementCount">Click me</primary-button>
</div>
<script type="module">
import { ref } from 'vue'
import { mount } from 'app.mjs'

const App = {
    setup() {
        const currentCount = ref(0)
        const incrementCount = () => currentCount.value++

        return { currentCount, incrementCount }
    }
}
mount('#content', App)
</script>

Which as you'd expect works in full page reloads, but not with Enhanced Navigation, where no JavaScript is re-executed upon navigation, leaving it as inert HTML.

Declarative Vue Components

Thankfully we can use the same approach we used for loading JavaScript modules to load Vue.js components, by using the data-component attribute to specify which global Vue component to mount with any properties optionally specified in thedata-props attribute, e.g:

<div data-component="GettingStarted" data-props="{template:'blazor'}"></div> 

This does require ensuring all components loaded this way are registered as a global component, as done in:

import GettingStarted from "./components/GettingStarted.mjs"

/** Shared Global Components */
const Components = {
    GettingStarted,
}

export function mount(sel, component, props) {
    const app = createApp(component, props)
    Object.keys(Components).forEach(name => {
        app.component(name, Components[name])
    })
    app.mount(document.querySelector(sel))
}

However this also means that all global components would need to be downloaded before any Vue Components can be rendered the first time a website is accessed. Which wont be an issue after the first page is loaded after the browser caches all its JS Module dependencies, but we can do better.

Lazy Loading Vue Components

To avoid this we can instead use the data-component attribute to specify the path to the Vue component to load, ensuring that only the Vue components required for the current page is loaded, e.g:

<div data-component="pages/Counter.mjs"></div> 

Which is how we can implement Vue Components that work in both statically rendered and enhanced navigation pages:

import { ref } from 'vue'

export default {
    template: `
        <p class="my-4">Current count: {‎{currentCount}‎}</p>

        <PrimaryButton @click="incrementCount">Click me</PrimaryButton>
    `,
    setup() {
        const currentCount = ref(0)
        const incrementCount = () => currentCount.value++

        return { currentCount, incrementCount }
    }
}

Blazor Counter in Vue.js

The new Blazor Vue Template

This ends up being how the Interactive features in the new blazor-vue template are implemented - ideal for building fast, SEO-friendly statically rendered Blazor Web Apps where all its dynamic functionally uses Vue.js to progressively enhance its static rendered content - eliminating Blazor's current limitations of being able to use Blazor static SSR to implement an entire App with:

Blazor Vue Tailwind Template

The new blazor-vue template implements all the features of the blazor template but reimplements all its interactive features with Vue.js to and the Vue Components library.

Blazor Vue Template

Create a new Blazor Vue Tailwind App

Create a new Blazor Vue Tailwind project with your preferred project name:

Faster iterative development

Other benefits of using Vue for Interactivity is the fast iterative feedback loop during development that even applies to its Markdown-powered Blog which itself can embed rich interactive Vue Components and rich JavaScript UIs like Chart.js in its Markdown Blog Posts thanks to its unapologetic, complexity-free #NoBuild solution.

Blazor App Tailwind Template

Alternatively the Blazor Project Template is for C# Developers who prefer to use Blazor end-to-end for all App functionality, which uses Blazor Server and ServiceStack.Blazor Components on its Pages requiring Interactivity:

Whilst Blazor Interactivity may remain the predominant solution amongst .NET developers we believe .NET 8 Blazor opens the doors for progressively enhanced statically-rendered Blazor Apps which has now become our preferred solution for developing most .NET Web Apps.

It overcomes our biggest gripe with Blazor Web Assembly, that we were unsuccessful in prerendering away its poor startup performance and UI jank in Internet Apps.

Blazor Vue Diffusion

So when we learned about .NET 8's static default rendering mode and enhanced navigation we jumped at the opportunity to create the Blazor Vue template which was used to re-implement Blazor Diffusion with Blazor SSR and Vue.js - a statically rendered Blazor App that uses Vue.js for all its functionality.

https://blazordiffusion.com

Blazor Diffusion is our Blazor Demo App we used to showcase how you could use Universal API Components to build Blazor Components and entire Blazor Apps whose source code runs in both Blazor Server and Blazor Web Assembly Interactive modes, which was first developed with Blazor Server then used a sync.bat script to export its source code into a Blazor Web Assembly project that was deployed instead.

The Blazor Vue version starts from a clean slate, utilizing statically rendered Blazor for faster page loads and generating SEO-friendly content:

We're very pleased with the results, much faster loading times, enhanced navigation, no UI jankiness, better SEO - essentially a better UX overall, despite not needing any prerendering solution - all whilst enjoying a faster iterative development experience where all Vue component changes were immediately visible after save.

You can compare the differences of each Blazor Solution from the Live Demos below:

Live Demo Source Code
Blazor Vue blazordiffusion.com BlazorDiffusionVue
Blazor Web Assembly api.blazordiffusion.com BlazorDiffusionWasm
Blazor Server server.blazordiffusion.com BlazorDiffusion

All Live Demos are hosted on a shared Hetzner Cloud VM using SQLite that's replicated to Cloudflare R2 with Litestream