In the previous article we learned how to use a State Container to share data between pages. However, its content lives in memory: if we reload the application or the circuit ends, the data is lost.
To preserve part of that state, the browser offers two stores associated with the application’s origin:
localStorage: Persists data across sessions until the user, browser, or application removes it. Useful for preferences, drafts, and carts without sensitive information.sessionStorage: Persists data during a tab’s session and survives its reloads. Useful for multi-step forms or temporary filters.
Browser Storage is a local, limited persistence controlled by the user. It does not replace a database nor make stored information reliable.
From C# we access these APIs through JS interop. This implies they are not available during prerendering: we must wait until the component is interactive.
The Option You Should Not Use
In older articles you will find references to ProtectedBrowserStorage, which allowed storing data in localStorage or sessionStorage using ASP.NET Core Data Protection.
The important nuance is that the Microsoft.AspNetCore.ProtectedBrowserStorage package was experimental and not intended for production. Furthermore, current documentation keeps it as a historical reference for legacy applications, not as a recommendation for a modern Blazor application.
Do not base a new application on ProtectedBrowserStorage.
If you need to protect data, only store an identifier in the browser and keep the sensitive data on the server. The browser should always be considered user-controlled memory.
A Custom Service with JS Interop
We can encapsulate the JavaScript calls and serialization so that components do not repeat this logic:
using System.Text.Json;
using Microsoft.JSInterop;
public sealed class BrowserStorageService(IJSRuntime js)
{
public async ValueTask SetLocalAsync<T>(string key, T value)
{
var json = JsonSerializer.Serialize(value);
await js.InvokeVoidAsync("localStorage.setItem", key, json);
}
public async ValueTask<T?> GetLocalAsync<T>(string key)
{
var json = await js.InvokeAsync<string?>("localStorage.getItem", key);
return json is null
? default
: JsonSerializer.Deserialize<T>(json);
}
public ValueTask RemoveLocalAsync(string key) =>
js.InvokeVoidAsync("localStorage.removeItem", key);
}To work with sessionStorage, we can provide equivalent methods that invoke sessionStorage.setItem, sessionStorage.getItem, and sessionStorage.removeItem.
We register our service with the same scope as the UI state:
builder.Services.AddScoped<BrowserStorageService>();Blazored.LocalStorage was a popular option, but its repository was archived in December 2025. In an existing project we can keep it while evaluating migration; for new code, a small abstraction like this avoids depending on a package without active maintenance.
Loading Data When the Component is Interactive
During prerendering, there is no connection to the browser APIs. OnAfterRenderAsync executes when the component can use JS interop:
@inject BrowserStorageService Storage
<button @onclick="GuardarCarrito">Save cart</button>
@if (!cargado)
{
<p>Loading cart...</p>
}
@code {
private List<Producto> carrito = [];
private bool cargado;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
carrito = await Storage.GetLocalAsync<List<Producto>>("mi-carrito") ?? [];
cargado = true;
StateHasChanged();
}
private Task GuardarCarrito() =>
Storage.SetLocalAsync("mi-carrito", carrito).AsTask();
}The explicit call to StateHasChanged is necessary because Blazor does not automatically re-render when a task started from OnAfterRenderAsync completes.
State Persistence Pattern
It is advisable that components do not depend directly on the persistence mechanism. We can combine it with the State Container from the previous article:
- When the UI is already interactive, the state service loads data from the browser.
- When modifying the state, it updates the memory and persists the change.
public sealed class CarritoService(BrowserStorageService storage)
{
public List<Producto> Items { get; private set; } = [];
public async Task InitializeAsync()
{
Items = await storage.GetLocalAsync<List<Producto>>("cart") ?? [];
}
public async Task AddItemAsync(Producto producto)
{
Items.Add(producto);
await storage.SetLocalAsync("cart", Items);
}
}The component can call InitializeAsync in its first OnAfterRenderAsync, just like in the previous example.
Limitations and Security:
- Do not store secrets: Do not store passwords, access tokens, or sensitive personal data. Any JavaScript executed on the same origin can read them.
- The quota varies: The available space and eviction policies depend on the browser. Store only what is essential and handle write errors.
- Data is not trustworthy: The user can modify it. Always validate its content and account for version changes in the JSON format.
- JS interop is async: Keep these operations out of rendering and avoid writing on every keystroke if you can batch changes.