Back to articles

Blazor: moving from Interactive Server to static rendering (SSR), my site went from 70 to 100 on Lighthouse

Blazor: moving from Interactive Server to static rendering (SSR), my site went from 70 to 100 on Lighthouse

My website is built with Blazor. I like my website. But one day I ran Lighthouse on mobile, and the verdict was in: 67. An orange score, on a site whose whole point is to show that I know my way around .NET... not great.

And yet the site doesn't do anything fancy: a blog, a resume, a few project pages, a job board and a contact form. Nothing that calls for a real-time application.

The culprit? Interactive Server mode, switched on pretty much everywhere "because it's convenient". In this article, I'll show you how I moved every public page to static server-side rendering (SSR), what it changes in the code, and the few traps I fell into along the way.

TL;DR

  • A showcase site or a blog doesn't need one SignalR circuit per visitor: static rendering is enough.
  • Filters become URL parameters, forms become static forms (FormName + [SupplyParameterFromForm]).
  • Anything in OnAfterRenderAsync no longer runs: check that first!
  • Results in production, on mobile: performance 67-74 → 96-100, accessibility 84-89 → 100, LCP 5-7.6 s → 1.5-2.7 s.

Blazor's 3 render modes in 2 minutes

Since .NET 8, a Blazor app can mix several render modes, page by page (or even component by component):

Mode Where does the code run? What the browser gets Best for
Static (SSR) On the server, once HTML. That's it. Content pages, blogs, showcase sites
Interactive Server On the server, continuously HTML + a permanent SignalR connection Back-offices, dashboards
Interactive WebAssembly In the browser HTML + the .NET runtime to download Rich apps, offline scenarios

Static is the default. Interactivity is turned on with a single line:

@rendermode InteractiveServer

And that's exactly where the trap is: this line is so easy to add that you end up putting it everywhere. An @onclick that doesn't respond? Add @rendermode InteractiveServer, and it works.

Except that in Interactive Server mode, every visitor opens a WebSocket connection to the server, which keeps the state of their page in memory (the "circuit"). For a back-office with three users, no problem. For a blog read by strangers (and by Google's crawlers), it's a waste: more JavaScript to download, a connection to establish before the page becomes usable, and memory used on the server.

My site is hosted on SmarterASP.NET, a shared hosting plan where memory is limited. All the more reason not to keep circuits open for nothing.

Step 1: hunt down useless interactivity

First thing to do: search for every @rendermode in the project and ask one simple question for each of them: what really needs to run on the server once the page is displayed?

My best example: the footer. It was in Interactive Server mode... on every page of the site. All of that for two language buttons:

@rendermode InteractiveServer

<div class="language-selector">
    <button class="btn-href" @onclick="@(() => ChangeLanguage("fr"))">🇫🇷 Français</button>
    <button class="btn-href" @onclick="@(() => ChangeLanguage("en"))">🇬🇧 English</button>
</div>

An interactive footer means a SignalR circuit open on every page, even the simplest ones. Whereas switching language is just... a link to the translated page:

<div class="language-selector">
    <a class="btn-href" href="@LanguageUrl("fr")" hreflang="fr" lang="fr" data-enhance-nav="false">🇫🇷 Français</a>
    <a class="btn-href" href="@LanguageUrl("en")" hreflang="en" lang="en" data-enhance-nav="false">🇬🇧 English</a>
</div>

LanguageUrl computes the URL of the current page in the other language (/fr/projets ↔ /en/projects). The data-enhance-nav="false" attribute forces a real page load, so that the <html lang="..."> tag is properly updated.

Going through the rest of the site, I found the same thing on the resume and the job offer details: interactive, while they only displayed data. One line to delete each time.

Step 2: filters become URL parameters

The blog and the job board had "live" filters: type "Blazor", and the list updates on every keystroke. Very nice, but 100% dependent on the circuit:

@rendermode InteractiveServer

<input type="text" class="form-control" placeholder="🔍 Search by technology..."
       @bind="_searchTechnology" @bind:event="oninput" @onkeyup="FilterOffers" />

<select class="form-select" @bind="_searchContractType" @bind:after="FilterOffers">
    <option value="">All contract types</option>
    <option value="CDI">CDI</option>
    ...
</select>

With static rendering, we go back to web basics: a GET form. The filters end up in the URL (/en/dotnet-job-board?tech=blazor&contract=CDI) and Blazor reads them with [SupplyParameterFromQuery]:

<form method="get" action="/en/dotnet-job-board" data-autosubmit>
    <input type="search" name="tech" value="@Technology" placeholder="🔍 Search by technology..." />

    <select name="contract">
        <option value="">All contract types</option>
        @foreach (var type in ContractTypes)
        {
            <option value="@type" selected="@(type == Contract)">@type</option>
        }
    </select>

    <button type="submit" class="visually-hidden">Filter</button>
</form>

@code {
    [SupplyParameterFromQuery(Name = "tech")] public string? Technology { get; set; }
    [SupplyParameterFromQuery(Name = "contract")] public string? Contract { get; set; }

    protected override void OnParametersSet()
    {
        _filteredOffers = [.. _allOffers.Where(offer =>
            (string.IsNullOrWhiteSpace(Technology) || offer.Technologies.Any(t => t.Contains(Technology, StringComparison.OrdinalIgnoreCase)))
            && (string.IsNullOrWhiteSpace(Contract) || offer.ContractType == Contract))];
    }
}

To keep the comfort of a dropdown that filters on its own, three lines of JavaScript are enough:

document.addEventListener('change', function (event) {
    const form = event.target.closest('form[data-autosubmit]');
    if (form && event.target.matches('select')) {
        form.requestSubmit();
    }
});

And the icing on the cake: this version has benefits the old one didn't:

  • It works without JavaScript (and therefore for every crawler).
  • A filtered search is a URL you can share or bookmark.
  • The filtered and paginated blog pages can be crawled by Google (with a canonical tag pointing to the main page, to avoid duplicate content).
  • Thanks to Blazor's enhanced navigation (blazor.web.js), the page isn't fully reloaded: only the content that changed is replaced. Visually, you can barely tell the difference with the old version.

Step 3: forms without a circuit

The contact form and the blog comment form were interactive too. Good news: EditForm works perfectly well with static rendering. You just need to give it a name, and tell Blazor where to put the posted data:

<EditForm Model="@NewComment" OnValidSubmit="SubmitComment" FormName="blog-comment" Enhance>
    <DataAnnotationsValidator />

    <InputText @bind-Value="NewComment!.AuthorName" class="form-control" />
    <ValidationMessage For="@(() => NewComment!.AuthorName)" />
    ...
    <button type="submit" class="btn btn-primary">Post comment</button>
</EditForm>

@code {
    [SupplyParameterFromForm(FormName = "blog-comment")]
    private CreateBlogCommentDto? NewComment { get; set; }

    protected override async Task OnInitializedAsync()
    {
        NewComment ??= new();
        ...
    }
}

Three things to remember:

  • FormName identifies the form (there can be several on a page) and [SupplyParameterFromForm] receives the posted values.
  • Enhance submits the form in the background and only replaces what changed: no full reload, no jump back to the top of the page.
  • Antiforgery protection is added automatically. Nothing to do.

A small but important detail: you do not initialize the property directly (= new();). On a POST, Blazor fills it with the form data. You only create it when it's empty, in OnInitializedAsync. Blazor's analyzer will remind you anyway, with a BL0008 warning.

Validation ([Required], [EmailAddress]...) works exactly as before, with a round trip to the server instead of "live" validation. For a contact form, honestly, nobody notices.

The trap: OnAfterRenderAsync no longer runs

This is THE thing to check first, and I found out the hard way: the view counter on my articles had stopped moving.

It was incremented here:

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender && post != null)
    {
        _ = _blogService.IncrementViewCount(post.Id);
    }
}

With static rendering, there is no "after render" in the browser: the server generates the HTML and that's it. OnAfterRenderAsync is never called. No error, no warning, just a frozen counter.

I moved the counting to page load, and took the opportunity to stop counting bots (search engines, link previews, Lighthouse...) as well as comment submissions, which also reload the page:

[CascadingParameter]
private HttpContext? HttpContext { get; set; }

protected override async Task OnInitializedAsync()
{
    post = await _blogService.GetPostBySlug(slug, _languageService.CurrentLanguage);
    ...
    if (HttpContext is not null
        && HttpMethods.IsGet(HttpContext.Request.Method)
        && !IsBot(HttpContext.Request.Headers.UserAgent))
    {
        _ = _blogService.IncrementViewCount(post.Id);
    }
}

private static bool IsBot(string? userAgent) =>
    string.IsNullOrEmpty(userAgent) ||
    Regex.IsMatch(userAgent, "bot|crawl|spider|slurp|lighthouse|headless|preview", RegexOptions.IgnoreCase);

By the way, HttpContext is available with static rendering, which isn't the case in interactive mode. Very handy.

My advice: before removing a @rendermode, search the component for OnAfterRender and IJSRuntime. Whatever you find there will have to be moved, or replaced with a bit of JavaScript.

What about the remaining JavaScript?

A few behaviors really needed the browser: the mobile menu, the cookie banner (with Google Analytics loaded only after consent), syntax highlighting for the code in articles. They were scattered across interactive components.

Everything now lives in a single small site.js file, loaded with defer. The only subtlety: with enhanced navigation, the page isn't reloaded, so the script has to run again when the content changes. Blazor raises an event for exactly that:

Blazor.addEventListener('enhancedload', onPageReady);

I also took the opportunity to remove Bootstrap's JavaScript: I was only using the collapsible menu, rewritten in about ten lines.

And the back-office?

The admin area stays in Interactive Server mode, and that's perfectly fine: it uses MudBlazor, dialogs, a Markdown editor with live preview... That's exactly what this mode is made for.

The only thing I changed: no more MudBlazor on public pages. Its CSS, its JavaScript and the Roboto font were loaded on every page, for nothing. In App.razor:

@if (_isAdmin)
{
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
    <link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
}

_isAdmin simply checks whether the URL starts with /admin. A hundred KB or so saved on every public page.

The small things that make a difference

Static rendering did the heavy lifting, but while I was at it, I also fixed what Lighthouse was still complaining about:

  • A single CSS file instead of 9 cascading @imports (each @import is one more request, loaded one after the other). The file is built at startup, compressed and cached for a year.
  • An icon font reduced to the icons actually used: from 127 KB down to 14 KB. The full font stays as a fallback, just in case.
  • Images in WebP, with a small SiteImage component that serves the .webp version when it exists and lazy-loads images (except the main one, loaded with high priority): images went from 6.2 MB down to 1.5 MB.
  • On the server side, the workstation GC instead of the server GC (<ServerGarbageCollection>false</ServerGarbageCollection>), much lighter on memory for a small site.

The results

Lighthouse measurements on mobile, in production, on the main pages of the site (home, blog, article, resume, contact):

Before After
Performance 67 to 74 96 to 100
Accessibility 84 to 89 100
SEO 100 100
LCP (main content displayed) 5 to 7.6 s 1.5 to 2.7 s
SignalR circuits opened by visitors 1 per visitor 0

SEO was already at 100 (Lighthouse mostly checks the tags), but Google does take real-world speed into account: an LCP divided by three makes a difference.

When should you keep Interactive Server?

I'm not saying interactive mode should be banned, far from it. It's still the right choice for:

  • a back-office or a dashboard, used by a few signed-in people;
  • a page with lots of interactions that depend on server data (an editor, a configurator...);
  • real-time features (notifications, live updates).

For everything else (content pages, blogs, showcase sites, simple forms), static rendering is faster, lighter, better for SEO, and frankly simpler to maintain. My rule today: static by default, interactive only where it's essential, and never on a shared component like a header or a footer.

Got a Blazor site struggling on Lighthouse? Try it: look for your @rendermodes and ask yourself the question for each one. You might be surprised.

A question, some feedback? Feel free to get in touch or leave a comment!


To go further, here's how this site is deployed to SmarterASP:

Comments (0)

No comments yet. Be the first to comment!

Leave a comment

Your email will not be published.
An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.