Mert Özen Further With Every Line
Frontend Blazor — #05

Blazor Without Interactivity: Static SSR, Streaming Rendering, Enhanced Navigation and Form Posts

Mert Özen Nov 15, 2026 12 min 8 views
Blazor Without Interactivity: Static SSR, Streaming Rendering, Enhanced Navigation and Form Posts

With Static SSR a Blazor component is rendered on the server per request and sent as plain HTML; interaction is built with links, query strings and form posts, and [StreamRendering] sends the skeleton immediately and appends the data to the same response. We show book search and paging in the Bookstore panel, enhanced navigation, an antiforgery-protected stock count form, a 404 with NotFound() from .NET 10 and the TempData coming in .NET 11 with real output.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15); browser behaviour was observed with Playwright 1.65's headless Chromium, and the example in the .NET 11 section was run with the .NET 11 SDK 11.0.100.

Predict First

In this post the panel's book list gets search and paging, and it has no render mode at all: it is entirely Static SSR. The page has the [StreamRendering] attribute: first a skeleton that says "Loading books...", then the table once the data arrives. When you request the page with curl, what do you see: only the skeleton, only the table, or both? Second question: there is no circuit and no WebAssembly on the page. When you click a paging link, does the browser load the page from scratch? Third: when you open the book details with an ISBN that does not exist, what is the status code of the response? In its 02 version it was 200.

Static SSR: A Request Comes In, HTML Goes Out

Static SSR follows the same model as MVC and Razor Pages: a request comes in, the component is created, its lifecycle runs, the HTML is written to the response and the component is thrown away. No circuit, no per-user state on the server, no .NET runtime downloaded to the browser. That has three consequences. The content arrives complete in the first response; a search engine and an app that builds link previews both see the whole page. The server stays stateless; adding instances behind a load balancer is enough. Resource usage is bounded by the request's duration; in 01 we measured 2.2 MB downloaded on the first visit to an Auto page, while the same page with Static SSR was 12 KB of HTML.

The cost is clear too: since the component does not live in the browser, event handlers such as @onclick do not work. Interaction is built with the old, solid tools of the web: links, query strings and form posts. Blazor adds two things on top of them: streaming rendering and enhanced navigation. The panel's book list now looks like this:

@page "/books"
@attribute [StreamRendering]
@inject BookstoreApiClient Api
@inject NavigationManager Navigation

<PageTitle>Books</PageTitle>

<h1>Books</h1>

<form method="get" data-enhance class="d-flex gap-2 mb-3">
    <input name="search" value="@Search" placeholder="Title, author or ISBN" class="form-control w-auto" />
    <select name="category" class="form-select w-auto">
        <option value="">All categories</option>
        @foreach (CategoryView category in categories)
        {
            <option value="@category.Slug" selected="@(category.Slug == Category)">@category.Name</option>
        }
    </select>
    <button type="submit" class="btn btn-primary">Search</button>
    <a href="books" class="btn btn-link">Clear</a>
</form>

@if (result is null)
{
    <p><em>Loading books...</em></p>
}
else if (result.Items.Count == 0)
{
    <p>No books match your search.</p>
}
else
{
    <table class="table">
        <thead>
            <tr><th>Title</th><th>Author</th><th>Category</th><th class="text-end">Price</th></tr>
        </thead>
        <tbody>
            @foreach (BookSummary book in result.Items)
            {
                <tr>
                    <td><a href="books/@book.Isbn">@book.Title</a></td>
                    <td>@book.Author</td>
                    <td>@book.Category</td>
                    <td class="text-end">@book.Price.ToString("N2") TRY</td>
                </tr>
            }
        </tbody>
    </table>
    <nav class="d-flex gap-2">
        @for (int number = 1; number <= pageCount; number++)
        {
            <a class="btn btn-sm @(number == result.Page ? "btn-primary" : "btn-outline-primary")"
               href="@Navigation.GetUriWithQueryParameter("page", number)">@number</a>
        }
    </nav>
    <p class="text-muted mt-2">@result.Total books, page @result.Page of @pageCount</p>
}

@code {
    private const int PageSize = 5;
    private PagedResult<BookSummary>? result;
    private IReadOnlyList<CategoryView> categories = [];
    private int pageCount;

    [SupplyParameterFromQuery]
    public string? Search { get; set; }

    [SupplyParameterFromQuery]
    public string? Category { get; set; }

    [SupplyParameterFromQuery(Name = "page")]
    public int? PageNumber { get; set; }

    protected override async Task OnInitializedAsync()
    {
        Task<IReadOnlyList<CategoryView>> categoriesTask = Api.GetCategoriesAsync();
        Task<PagedResult<BookSummary>> booksTask = Api.GetBooksAsync(Search, Category, PageNumber ?? 1, PageSize);
        await Task.WhenAll(categoriesTask, booksTask);

        categories = categoriesTask.Result;
        result = booksTask.Result;
        pageCount = (int)Math.Ceiling(result.Total / (double)PageSize);
    }
}

Search and Paging with the Query String

[SupplyParameterFromQuery] binds a value from the query string to a property: for a request to /books?search=Austen&page=2, Search becomes "Austen" and PageNumber becomes 2. If the name differs from the property, it is given with Name; that is why I bound the page parameter to PageNumber. The supported types are strings, numbers, bool, DateTime, Guid and their nullable and array forms. The search box is an ordinary HTML form: since it is method="get", its fields are written into the query string on submit and the same page renders again with the new parameters. The paging links are produced with NavigationManager.GetUriWithQueryParameter("page", number); this method keeps the current query string and changes only page, so the search term is not lost when the page changes.

There are two API calls in OnInitializedAsync, and both run in parallel with Task.WhenAll; two sequential awaits would add up two network round trips on every request. With Static SSR the page's duration is directly the time the user waits, so the difference matters. The API's pageSize limit and ordering already live in Bookstore.Api; the panel only passes the parameters along.

Streaming Rendering

Normally with Static SSR the server does not send the response until all of the component's async work is done. If the API is slow, the user looks at an empty tab. [StreamRendering] changes that: the server sends the first render, the state up to the first await in OnInitializedAsync, immediately, keeps the connection open and appends the updated HTML to the end of the same response when the work finishes. The relevant parts of the curl -N "/books?search=Austen" response (shortened):

<p><em>Loading books...</em></p><!--/bl:16--></article>
...
    <script src="_framework/blazor.web.whefm3rrpu.js"></script></body></html>
<blazor-ssr><template blazor-component-id="24">
...
        <tbody><tr><td><a href="books/9780141439587">Emma</a></td>
...
    <p class="text-muted mt-2">2 books, page 1 of 1</p></template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>

The answer to the first question: both, in a single response. First the page with the skeleton arrives up to </html>; the browser shows it right away. Then, inside the same response, comes a <blazor-ssr> element with a <template> inside; blazor.web.js puts the HTML in this template in place of the component whose blazor-component-id is 24. A client that runs no JavaScript gets the table too, just at the end of the page. For search engines the content is still in the first response.

Streaming has a consequence: once the response has started, you cannot write HTTP headers or cookies. Anything that needs a status code, a redirect or a cookie must happen before the first await. That is why the book details page, which handles a form post, has no [StreamRendering].

Enhanced Navigation

A Static SSR page still loads blazor.web.js in the browser, and that script intercepts link clicks. If the target is inside the app, instead of the browser's normal page change it sends a fetch request, compares the returned HTML with the current DOM and updates only the parts that changed. When I put a marker on the page with Playwright (window.__sameDocument = true) and clicked the paging link, the search form and the "Clear" link given data-enhance-nav="false":

click page 2: url=/books?page=2, same document=true
  GET /books?page=2 fetch (enhanced)
search form: url=/books?search=Dune&category=, same document=true
  GET /books?search=Dune&category= fetch (enhanced)
Clear link with data-enhance-nav="false": url=/books, same document=false
  GET /books document

The answer to the second question: no. The paging link and the search form each became a fetch request and the page stayed the same document; the scroll position and the state of JavaScript running on the page are kept. These requests carry blazor-enhanced-nav=on in their Accept header; the server produces the response accordingly. For the link with data-enhance-nav="false", the browser made a classic document request and the marker was gone. You use that attribute on links to pages Blazor does not manage (for example a Razor Pages screen or a file download endpoint) and on pages with scripts that must load from scratch on navigation. If the target is not a Blazor endpoint, the script falls back to a full page load anyway.

The search form was enhanced too, because the <form> has data-enhance. For forms this is not the default and it is not hierarchical either: the attribute has to be on the form itself; putting it on a div above it does nothing.

Form Posts

The book details page got a form for entering the quantity on hand after a warehouse count:

@page "/books/{Isbn}"
@inject BookstoreApiClient Api
@inject NavigationManager Navigation
@inject ILogger<BookDetailsPage> Logger

<PageTitle>@(book?.Title ?? "Book")</PageTitle>

@if (book is not null)
{
    <h1>@book.Title</h1>
    <dl>
        <dt>Author</dt><dd>@book.Author</dd>
        <dt>ISBN</dt><dd>@book.Isbn</dd>
        <dt>Price</dt><dd>@book.Price.ToString("N2") TRY</dd>
        <dt>Available</dt><dd>@book.Available</dd>
    </dl>

    <h2>Stock count</h2>
    @if (Saved)
    {
        <p class="alert alert-success">Stock saved.</p>
    }
    @if (error is not null)
    {
        <p class="alert alert-danger">@error</p>
    }
    <EditForm Model="StockCount" FormName="stock-count" OnSubmit="SaveStockAsync" Enhance>
        <label>
            Counted quantity on hand
            <InputNumber @bind-Value="StockCount!.QuantityOnHand" class="form-control w-auto" />
        </label>
        <button type="submit" class="btn btn-primary">Save</button>
    </EditForm>
}

@code {
    private BookDetails? book;
    private string? error;

    [Parameter]
    public string Isbn { get; set; } = "";

    [SupplyParameterFromQuery]
    public bool Saved { get; set; }

    [SupplyParameterFromForm(FormName = "stock-count")]
    private StockCountForm? StockCount { get; set; }

    protected override async Task OnInitializedAsync()
    {
        StockCount ??= new();
        book = await Api.GetBookAsync(Isbn);
        if (book is null)
        {
            Navigation.NotFound();
        }
    }

    private async Task SaveStockAsync()
    {
        if (!await Api.SetQuantityOnHandAsync(Isbn, StockCount!.QuantityOnHand))
        {
            error = "The counted quantity cannot be lower than the reserved quantity.";
            return;
        }

        Navigation.NavigateTo($"books/{Isbn}?saved=true");
        // With BlazorDisableThrowNavigationException (on in the .NET 10 template) this line still runs.
        Logger.LogInformation("Stock count for {Isbn} saved, redirecting", Isbn);
    }

    public sealed class StockCountForm
    {
        public int QuantityOnHand { get; set; }
    }
}

With Static SSR, submitting a form is an HTTP POST. Blazor binds the incoming form fields to the property marked with [SupplyParameterFromForm] and calls the OnSubmit handler of the form whose FormName matches. FormName is required; since a page can have more than one form, Blazor learns which form the POST belongs to from the hidden _handler field. That is also why the line StockCount ??= new(); exists: on a GET the form is drawn with an empty model, while on a POST the model is already filled with the incoming values. The details of validation and input components are in post 07.

EditForm adds the antiforgery token inside itself. When I sent a POST without the token with curl:

HTTP/1.1 400 Bad Request
A valid antiforgery token was not provided with the request. Add an antiforgery token, or disable antiforgery validation for this endpoint.

This is the same CSRF protection as in the forms post of the ASP.NET Core MVC and Razor Pages series; UseAntiforgery() in Program.cs provides it. When I submitted counts of 1 and 30 from the browser (2 copies of Middlemarch are reserved by an order):

stock count 1: The counted quantity cannot be lower than the reserved quantity.
  after POST: url=/books/9780141439549, same document=true
  POST /books/9780141439549 fetch (enhanced)
stock count 30: Stock saved., available now 28
  after POST: url=/books/9780141439549?saved=true, same document=true
  POST /books/9780141439549 fetch (enhanced)
  GET /books/9780141439549?saved=true fetch
info: Bookstore.Admin.Components.Pages.Books.BookDetailsPage[0]
      Stock count for 9780141439549 saved, redirecting

On the first submit the API returned 400 and the page rendered again with the error message. On the second, NavigateTo produced a redirect and the browser requested the ?saved=true address with a GET; in other words the Post-Redirect-Get pattern, which keeps a page refresh from submitting the form again. Both submits were enhanced; the page did not load from scratch. The log line shows a change in .NET 10: with Static SSR, NavigateTo used to throw a NavigationException and cut the method short. The .NET 10 template sets the BlazorDisableThrowNavigationException MSBuild property to true; now the line after NavigateTo runs as well, and the behaviour matches interactive rendering.

A 404 for a Book That Does Not Exist

In 02, /books/123 returned 200 and the page said "Book not found". NavigationManager.NotFound() in .NET 10 fixes that: with Static SSR it sets the response's status code to 404 and renders the NotFoundPage given to the Router, or failing that the page of the status code re-execute middleware:

HTTP/1.1 404 Not Found
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

The answer to the third question is now 404. After the NotFound() call, the page's own markup is not rendered; the @if (book is not null) check is only there so that the rest of the component does not work with null. In interactive rendering the same call tells the router to show the Not Found content, and with streaming rendering on, without reloading the page. So the same code behaves correctly in all three modes.

What .NET 11 Brings for Static SSR

This section is about .NET 11; the series' code stays on .NET 10 LTS. Above we used the ?saved=true query string to carry the "saved" message after a Static SSR form submit. .NET 11 brings TempData, familiar from MVC, to Static SSR. In a small project created from the -int None template with the .NET 11 SDK:

@page "/stock-count"
@inject NavigationManager Navigation

<h1>Stock count</h1>

@if (message is not null)
{
    <p class="flash">@message</p>
}

<EditForm Model="Form" FormName="stock-count" OnSubmit="Save" Enhance>
    <InputNumber @bind-Value="Form!.QuantityOnHand" />
    <button type="submit">Save</button>
</EditForm>

@code {
    [CascadingParameter]
    public ITempData? TempData { get; set; }

    private string? message;

    [SupplyParameterFromForm(FormName = "stock-count")]
    private StockCountForm? Form { get; set; }

    protected override void OnInitialized()
    {
        Form ??= new();
        // .NET 11: Get removes the value after reading; it survives exactly one redirect.
        message = TempData?.Get("Message") as string;
    }

    private void Save()
    {
        TempData!["Message"] = $"Saved: {Form!.QuantityOnHand} copies on hand.";
        Navigation.NavigateTo("stock-count");
    }

    public sealed class StockCountForm
    {
        public int QuantityOnHand { get; set; }
    }
}
first visit:      (no message)
after the POST:   Saved: 30 copies on hand.  url=/stock-count
after a reload:   (no message)

The message showed up on the GET after the redirect and disappeared when the page was reloaded: a one-time flash message. By default the value travels in a cookie encrypted with Data Protection. I first tried the same example the shorter way from the documentation, by giving the property [SupplyParameterFromTempData]; in my run the message still showed after a reload (after a reload: Saved: 30 copies on hand.). If you want one-time behaviour, reading the value with Get is the safe choice. .NET 11 also brings [SupplyParameterFromSession] for session-bound values, CacheView, which caches the HTML output of a component subtree, and URL-based paging for QuickGrid; we will look at them together in post 13. One warning: according to the documentation, cookie-based TempData cannot be written after the response has started on a page that uses streaming rendering.

In Production

A Static SSR page is rendered from scratch on every request, so its cost is per request; the page's duration is the duration of the API and the database. That is why we made the two API calls in the book list parallel. For pages that look the same for everyone, output caching is an option too; not for user-specific pages.

Do not forget that streaming rendering keeps the connection open. If there is a reverse proxy that buffers responses in between (some CDNs, compression middleware), streaming does not work and the page arrives at the end again; the documentation says the page then loads without streaming. In production, check with curl -N that the response really arrives in two parts.

Because enhanced navigation does not load the page from scratch, page-specific <script> tags may not run again on a second visit. If you need JavaScript on Static SSR pages, use Blazor's enhancedload event or the page script approach from the documentation; details in the JS interop post.

Common Pitfalls

Using @onclick on a Static SSR page. As we saw in the first experiment in 01, it raises no error, nothing happens at all. That is why the "Clear" control in the book list is not a button but a link with href="books". If you need interaction, use a link, a GET form or a POST form; if you really need behaviour that runs in the browser, move that piece into a separate interactive component.

Forgetting FormName. When I removed FormName from the form and submitted it, the hidden _handler field was not rendered and the POST came back with this response:

POST status: 400
The POST request does not specify which form is being submitted. To fix this, ensure <form> elements have a @formname attribute with any unique value, or pass a FormName parameter if using <EditForm>.

If a page has two forms, their names must differ as well.

Overwriting the [SupplyParameterFromForm] property with an unconditional new() in OnInitialized. If you write StockCount = new();, the value from the POST is lost and the form always saves 0. The right form is ??=.

Trying to write a redirect or a cookie after the response has started on a streaming page. Once the headers are sent, these cannot be done; leave a page that performs actions without streaming, or finish the work before the first await.

A Small Experiment

★☆☆ Remove [StreamRendering] from BookListPage and look at the curl -N "/books?search=Austen" output again. Is "Loading books..." still in the response? The <blazor-ssr> element?

★★☆ Remove data-enhance from the search form and run the same search with Playwright or the browser's Network tab. What type of request was it? Is the scroll position on the page kept?

★★★ Add a second form to the book details page: a price update (FormName="price"). Write a suitable endpoint in Bookstore.Api and verify that the two forms work on the same page without affecting each other. When one form has an error, what happens to the value entered in the other form?

We saw how much of a screen we can solve with Static SSR without any interactivity. On interactive pages, however, a problem has been waiting since 03: because of prerendering, the data is loaded twice. In the next post we solve this double load with [PersistentState] from .NET 10 and look at circuit state persistence and the security side of persisted state.