Blazor JavaScript Interop: Bridging .NET and Browser APIs

Calling JavaScript from C#

Blazor WebAssembly runs inside the browser, but sometimes you need to reach beyond the sandbox and use native browser features—localStorage, geolocation, or even a third-party map. The runtime exposes IJSRuntime for exactly that purpose.

Creating a Facade Function

Start with a plain JavaScript function that will act as the public façade between the two worlds. Parameters and return values must be JSON-serializable because they travel over a text-based channel.

// wwwroot/js/storageBridge.js
window.storageBridge = {
  read: key => key in localStorage ? JSON.parse(localStorage[key]) : null,
  write: (key, value) => localStorage[key] = JSON.stringify(value),
  remove: key => delete localStorage[key]
};

Reference the file in index.html after the Blazor boot script:

<script src="_framework/blazor.webassembly.js"></script>
<script src="js/storageBridge.js"></script>

Invoking the Facade from C#

Inject IJSRuntime and use the asynchronous helpers:

@inject IJSRuntime JS

private async Task<int> GetLastCountAsync()
{
    return await JS.InvokeAsync<int>("storageBridge.read", "counter");
}

private async Task SaveCountAsync(int value)
{
    await JS.InvokeVoidAsync("storageBridge.write", "counter", value);
}

Prefer InvokeAsync/InvokeVoidAsync; synchronous calls (IJSInProcessRuntime) only work in WebAssembly and break under Blazor Server.

Persisting a Counter Across Reloads

@page "/counter"
@inject IJSRuntime JS

<h1>Counter</h1>
<p role="status">Current count: @count</p>
<button class="btn btn-primary" @onclick="Increment">Click me</button>

@code {
    private int count;

    protected override async Task OnInitializedAsync()
    {
        try { count = await JS.InvokeAsync<int>("storageBridge.read", "counter"); }
        catch { /* first run */ }
    }

    private async Task Increment()
    {
        count++;
        await JS.InvokeVoidAsync("storageBridge.write", "counter", count);
    }
}

Passing Element References

Sometimes JavaScript needs direct access to a DOM element. Capture it with @ref and pass it only after rendering is complete.

<input @ref="inputRef" @bind="count" />

@code {
    private ElementReference inputRef;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
            await JS.InvokeVoidAsync("focusElement", inputRef);
    }
}
window.focusElement = el => el.focus();

ElementReference is an opaque handle; do not cache it or pass it betwean components.

Calling .NET from JavaScript

Expose a public method decorated with [JSInvokable]:

[JSInvokable]
public async Task OnStorageChanged()
{
    count = await JS.InvokeAsync<int>("storageBridge.read", "counter");
    StateHasChanged();
}

Wrap the component instance in DotNetObjectReference and hand it to JavaScript:

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        var dotNetRef = DotNetObjectReference.Create(this);
        await JS.InvokeVoidAsync("storageBridge.watch", dotNetRef);
    }
}
window.storageBridge.watch = function (dotNetRef) {
  window.addEventListener('storage', _ => dotNetRef.invokeMethodAsync('OnStorageChanged'));
};

Open two tabs and change localStorage in one; the other tab updates instantly.

Hiding Interop Behind a Service

Tight coupling to IJSRuntime makes componants hard to test. Encapsulate the calls in a service.

public interface IClientStorage
{
    ValueTask<T> GetAsync<T>(string key);
    ValueTask SetAsync<T>(string key, T value);
    ValueTask WatchAsync<T>(T instance) where T : class;
}

public class ClientStorage : IClientStorage
{
    private readonly IJSRuntime _js;
    public ClientStorage(IJSRuntime js) => _js = js;

    public ValueTask<T> GetAsync<T>(string key) =>
        _js.InvokeAsync<T>("storageBridge.read", key);

    public ValueTask SetAsync<T>(string key, T value) =>
        _js.InvokeVoidAsync("storageBridge.write", key, value);

    public ValueTask WatchAsync<T>(T instance) where T : class =>
        _js.InvokeVoidAsync("storageBridge.watch", DotNetObjectReference.Create(instance));
}

Register it in Program.cs:

builder.Services.AddSingleton<IClientStorage, ClientStorage>();

Now components simply @inject IClientStorage storage and stay ignorant of JavaScript.

Dynamic JavaScript Modules

Static script tags load even when the page doesn’t need them. ES modules solve this by loading code on demand and avoiding global scope pollution.

Refactor the façade to a module:

// wwwroot/js/storage.js
export const read  = key => key in localStorage ? JSON.parse(localStorage[key]) : null;
export const write = (key, value) => localStorage[key] = JSON.stringify(value);
export const watch = async (dotNetRef) => {
  window.addEventListener('storage', _ => dotNetRef.invokeMethodAsync('OnStorageChanged'));
};

Update the service to import the module lazily:

public class ClientStorageModule : IClientStorage
{
    private readonly IJSRuntime _js;
    private IJSObjectReference _module;

    public ClientStorageModule(IJSRuntime js) => _js = js;

    public async ValueTask InitAsync()
    {
        _module ??= await _js.InvokeAsync<IJSObjectReference>("import", "./js/storage.js");
    }

    public async ValueTask<T> GetAsync<T>(string key)
    {
        await InitAsync();
        return await _module.InvokeAsync<T>("read", key);
    }

    public async ValueTask SetAsync<T>(string key, T value)
    {
        await InitAsync();
        await _module.InvokeVoidAsync("write", key, value);
    }

    public async ValueTask WatchAsync<T>(T instance) where T : class
    {
        await InitAsync();
        await _module.InvokeVoidAsync("watch", DotNetObjectReference.Create(instance));
    }
}

The module is fetched only when InitAsync is first called.

Embedding an Interactive Map

Leaflet is a lightweight, open-source mapping library. Add its assets to index.html:

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

Create a Razor class library LeafletMap with a module wwwroot/leaflet-bridge.js:

export function attachMap(elementId, zoom, markers) {
  const el = document.getElementById(elementId);
  if (!el.map) {
    el.map = L.map(elementId).setView([50.88, 4.30], zoom);
    L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '© OpenStreetMap contributors'
    }).addTo(el.map);
    el.map.markers = [];
  }

  // remove outdated markers
  el.map.markers.forEach(m => m.remove());
  el.map.markers = markers.map(m =>
    L.marker([m.lat, m.lng]).addTo(el.map).bindPopup(m.text)
  );

  if (markers.length)
    el.map.fitBounds(L.featureGroup(el.map.markers).getBounds().pad(0.1));
}

The Razor component:

@using Microsoft.JSInterop
@inject IJSRuntime JS

<div id="@id" style="height:100%;width:100%;"></div>

@code {
    [Parameter] public double Zoom { get; set; } = 15;
    [Parameter] public List<Marker> Points { get; set; } = new();

    private string id = "map_" + Guid.NewGuid();
    private IJSObjectReference module;

    protected override async Task OnInitializedAsync() =>
        module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/LeafletMap/leaflet-bridge.js");

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (module is not null)
            await module.InvokeVoidAsync("attachMap", id, Zoom, Points);
    }

    public class Marker
    {
        public double Lat { get; set; }
        public double Lng { get; set; }
        public string Text { get; set; }
    }
}

Consume it in the host app:

<Map Zoom="16" Points="@pins" />

@code {
    List<LeafletMap.Marker> pins = new()
    {
        new() { Lat = 50.88, Lng = 4.30, Text = "PizzaPlace HQ" },
        new() { Lat = 50.87, Lng = 4.28, Text = "You are here" }
    };
}

No script tags cluttering index.html; the map module is fetched only when the component is instantiated.

Tags: Blazor WebAssembly javascript-interop localstorage leaflet

Posted on Fri, 10 Jul 2026 18:08:33 +0000 by ReD_BeReT